opencode_ctl/
created_set.rs1use std::fs::{File, OpenOptions};
7use std::io;
8use std::path::{Component, Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14pub const SWEEP_MAX_AGE_MS: i64 = 30 * 24 * 60 * 60 * 1000;
16
17pub const PROBE_LIMIT: usize = 5;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct Entry {
22 pub id: String,
23 pub directory: String,
24}
25
26#[derive(Debug, Clone)]
27pub struct CreatedSet {
28 home: PathBuf,
29}
30
31pub fn strip_verbatim(s: &str) -> String {
33 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
34 format!(r"\\{rest}")
35 } else if let Some(rest) = s.strip_prefix(r"\\?\") {
36 rest.to_string()
37 } else {
38 s.to_string()
39 }
40}
41
42fn lexical_normalise(p: &Path) -> PathBuf {
44 let mut out = PathBuf::new();
45 for c in p.components() {
46 match c {
47 Component::CurDir => {}
48 Component::ParentDir => {
49 if !out.pop() && !out.has_root() {
50 out.push("..");
51 }
52 }
53 other => out.push(other.as_os_str()),
54 }
55 }
56 out
57}
58
59pub fn key_for(directory: &Path) -> String {
62 let resolved =
63 std::fs::canonicalize(directory).unwrap_or_else(|_| lexical_normalise(directory));
64 let text = strip_verbatim(&resolved.to_string_lossy());
65 let digest = Sha256::digest(text.as_bytes());
66 digest.iter().take(6).map(|b| format!("{b:02x}")).collect()
67}
68
69fn read_entries(path: &Path) -> io::Result<Vec<Entry>> {
70 match std::fs::read(path) {
71 Ok(bytes) => Ok(serde_json::from_slice(&bytes).unwrap_or_default()),
72 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
73 Err(e) => Err(e),
74 }
75}
76
77static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
78
79impl CreatedSet {
80 pub fn new(home: PathBuf) -> Self {
81 Self { home }
82 }
83
84 fn data_path(&self, key: &str) -> PathBuf {
85 self.home.join(format!("created.{key}.json"))
86 }
87
88 fn lock_path(&self, key: &str) -> PathBuf {
89 self.home.join(format!("created.{key}.lock"))
90 }
91
92 fn open_lock(&self, key: &str) -> io::Result<File> {
93 std::fs::create_dir_all(&self.home)?;
94 let f = OpenOptions::new()
95 .create(true)
96 .read(true)
97 .write(true)
98 .truncate(false)
99 .open(self.lock_path(key))?;
100 f.lock()?;
101 Ok(f)
102 }
103
104 fn mutate(&self, key: &str, f: impl FnOnce(&mut Vec<Entry>)) -> io::Result<()> {
106 let _lock = self.open_lock(key)?;
107 let path = self.data_path(key);
108 let mut entries = read_entries(&path)?;
109 let before = entries.clone();
110 f(&mut entries);
111 if entries == before && path.exists() {
112 return Ok(());
113 }
114 let tmp = self.home.join(format!(
115 "created.{key}.tmp{}-{}",
116 std::process::id(),
117 TMP_SEQ.fetch_add(1, Ordering::Relaxed)
118 ));
119 std::fs::write(
120 &tmp,
121 serde_json::to_vec(&entries).map_err(io::Error::other)?,
122 )?;
123 std::fs::rename(&tmp, &path).inspect_err(|_| {
124 let _ = std::fs::remove_file(&tmp);
125 })
126 }
127
128 pub fn append(&self, directory: &Path, id: &str) -> io::Result<()> {
129 let key = key_for(directory);
130 let entry = Entry {
131 id: id.to_string(),
132 directory: directory.to_string_lossy().into_owned(),
133 };
134 self.mutate(&key, |v| {
135 if !v.iter().any(|e| e.id == id) {
136 v.push(entry);
137 }
138 })
139 }
140
141 fn data_files(&self) -> Vec<(String, PathBuf)> {
143 let Ok(rd) = std::fs::read_dir(&self.home) else {
144 return Vec::new();
145 };
146 let mut out: Vec<(String, PathBuf)> = rd
147 .flatten()
148 .filter_map(|e| {
149 let name = e.file_name().to_string_lossy().into_owned();
150 let key = name.strip_prefix("created.")?.strip_suffix(".json")?;
151 Some((key.to_string(), e.path()))
152 })
153 .collect();
154 out.sort();
155 out
156 }
157
158 pub fn prune(&self, id: &str) -> io::Result<()> {
159 for (key, path) in self.data_files() {
160 if read_entries(&path)
162 .unwrap_or_default()
163 .iter()
164 .any(|e| e.id == id)
165 {
166 self.mutate(&key, |v| v.retain(|e| e.id != id))?;
167 }
168 }
169 Ok(())
170 }
171
172 pub fn contains(&self, id: &str) -> io::Result<Option<PathBuf>> {
174 for (_, path) in self.data_files() {
175 let Ok(bytes) = std::fs::read(&path) else {
176 continue;
177 };
178 let Ok(entries) = serde_json::from_slice::<Vec<Entry>>(&bytes) else {
179 continue;
180 };
181 if let Some(e) = entries.iter().find(|e| e.id == id) {
182 return Ok(Some(PathBuf::from(&e.directory)));
183 }
184 }
185 Ok(None)
186 }
187
188 pub fn ids_for(&self, directory: &Path) -> Vec<String> {
190 read_entries(&self.data_path(&key_for(directory)))
191 .unwrap_or_default()
192 .into_iter()
193 .map(|e| e.id)
194 .collect()
195 }
196
197 pub fn ids(&self) -> Vec<String> {
198 self.data_files()
199 .into_iter()
200 .flat_map(|(_, p)| read_entries(&p).unwrap_or_default())
201 .map(|e| e.id)
202 .collect()
203 }
204
205 pub fn sweep(&self, now_ms: i64, max_age_ms: i64) -> Vec<PathBuf> {
208 let age_of = |p: &Path| -> Option<i64> {
209 let m = std::fs::metadata(p).ok()?.modified().ok()?;
210 let ms = m.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as i64;
211 Some(now_ms - ms)
212 };
213 let mut removed = Vec::new();
214 for (key, path) in self.data_files() {
215 if !age_of(&path).is_some_and(|a| a > max_age_ms) {
216 continue;
217 }
218 let Ok(lock) = self.open_lock(&key) else {
219 continue;
220 };
221 if age_of(&path).is_some_and(|a| a > max_age_ms) && std::fs::remove_file(&path).is_ok()
223 {
224 let _ = std::fs::remove_file(self.lock_path(&key));
225 removed.push(path);
226 }
227 drop(lock);
228 }
229 if let Ok(rd) = std::fs::read_dir(&self.home) {
230 for e in rd.flatten() {
231 let name = e.file_name().to_string_lossy().into_owned();
232 let Some(key) = name
233 .strip_prefix("created.")
234 .and_then(|r| r.strip_suffix(".lock"))
235 else {
236 continue;
237 };
238 if !self.data_path(key).exists()
239 && age_of(&e.path()).is_some_and(|a| a > SWEEP_MAX_AGE_MS)
240 && std::fs::remove_file(e.path()).is_ok()
241 {
242 removed.push(e.path());
243 }
244 }
245 }
246 removed
247 }
248}