1use std::fs::{self, OpenOptions};
16use std::io::Write;
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicU32, Ordering};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use crate::json;
22
23const ENDED_KEEP_MS: u128 = 60_000;
25
26static SEQ: AtomicU32 = AtomicU32::new(0);
28
29pub fn dir() -> Option<PathBuf> {
31 if let Some(d) = std::env::var_os("QUIET_ACTIVE_DIR").filter(|d| !d.is_empty()) {
32 return Some(PathBuf::from(d));
33 }
34 if cfg!(test) {
37 return None;
38 }
39 #[cfg(windows)]
40 {
41 let base = std::env::var_os("LOCALAPPDATA")?;
42 Some(PathBuf::from(base).join("quiet").join("active"))
43 }
44 #[cfg(not(windows))]
45 {
46 let base = match std::env::var_os("XDG_STATE_HOME").filter(|d| !d.is_empty()) {
47 Some(d) => PathBuf::from(d),
48 None => PathBuf::from(std::env::var_os("HOME")?)
49 .join(".local")
50 .join("state"),
51 };
52 Some(base.join("quiet").join("active"))
53 }
54}
55
56fn now_ms() -> u128 {
57 SystemTime::now()
58 .duration_since(UNIX_EPOCH)
59 .map(|d| d.as_millis())
60 .unwrap_or(0)
61}
62
63pub struct Entry {
65 path: PathBuf,
66 fields: Vec<String>,
69 log: PathBuf,
70 done: bool,
71}
72
73impl Entry {
74 pub fn start<'a>(
78 dir: &Path,
79 check: &str,
80 repo: impl FnOnce() -> &'a str,
81 log: &Path,
82 ) -> Option<Entry> {
83 let session = std::env::var("CLAUDE_CODE_SESSION_ID")
86 .ok()
87 .filter(|s| !s.is_empty())?;
88 Entry::start_for(dir, check, repo(), log, &session)
89 }
90
91 fn start_for(dir: &Path, check: &str, repo: &str, log: &Path, session: &str) -> Option<Entry> {
92 create_dir(dir).ok()?;
93 sweep(dir);
94 let pid = std::process::id();
95 let n = SEQ.fetch_add(1, Ordering::Relaxed);
96 let fields = vec![
97 "\"v\":1".to_string(),
98 json::string_field("tool", "amont"),
99 json::string_field("session", session),
100 json::string_field("check", check),
101 json::string_field("repo", repo),
102 json::int_field("pid", i64::from(pid)),
103 format!("\"started\":{}", now_ms()),
104 ];
105 let entry = Entry {
106 path: dir.join(format!("{pid}-{n}.json")),
107 fields,
108 log: log.to_path_buf(),
109 done: false,
110 };
111 write_atomic(&entry.path, &entry.body(None)).ok()?;
112 Some(entry)
113 }
114
115 fn body(&self, end: Option<(Option<i32>, u128)>) -> String {
116 let mut fields = self.fields.clone();
117 fields.push(json::string_field("log", &self.log.to_string_lossy()));
118 if let Some((exit, ended)) = end {
119 fields.push(json::opt_int_field("exit", exit.map(i64::from)));
120 fields.push(format!("\"ended\":{ended}"));
121 }
122 json::object(&fields)
123 }
124
125 pub fn end(&mut self, exit: Option<i32>, final_log: Option<&Path>) {
129 if self.done {
130 return;
131 }
132 self.done = true;
133 if let Some(p) = final_log {
134 self.log = p.to_path_buf();
135 }
136 let _ = write_atomic(&self.path, &self.body(Some((exit, now_ms()))));
140 }
141}
142
143impl Drop for Entry {
144 fn drop(&mut self) {
148 self.end(None, None);
149 }
150}
151
152fn create_dir(dir: &Path) -> std::io::Result<()> {
155 fs::create_dir_all(dir)?;
156 #[cfg(unix)]
157 {
158 use std::os::unix::fs::PermissionsExt;
159 fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
160 }
161 Ok(())
162}
163
164fn write_atomic(path: &Path, body: &str) -> std::io::Result<()> {
167 let tmp = path.with_extension("json.tmp");
168 let mut open = OpenOptions::new();
169 open.write(true).create(true).truncate(true);
170 #[cfg(unix)]
171 {
172 use std::os::unix::fs::OpenOptionsExt;
173 open.mode(0o600);
174 }
175 let mut f = open.open(&tmp)?;
176 f.write_all(body.as_bytes())?;
177 drop(f);
178 fs::rename(&tmp, path)
179}
180
181fn sweep(dir: &Path) {
185 let Ok(read) = fs::read_dir(dir) else { return };
186 let now = now_ms();
187 for e in read.filter_map(|e| e.ok()) {
188 let path = e.path();
189 if path.extension().and_then(|x| x.to_str()) != Some("json") {
190 continue;
191 }
192 let Some(v) = fs::read_to_string(&path)
193 .ok()
194 .and_then(|t| crate::json_read::parse(&t))
195 else {
196 continue;
197 };
198 let num = |k: &str| match v.get(k) {
199 Some(crate::json_read::Value::Num(n)) => n.parse::<u128>().ok(),
200 _ => None,
201 };
202 let stale = match num("ended") {
203 Some(ended) => now.saturating_sub(ended) > ENDED_KEEP_MS,
204 None => {
205 v.get("tool").and_then(|t| t.as_str()) == Some("amont")
206 && num("pid").is_some_and(|pid| u32::try_from(pid).map_or(true, |p| !alive(p)))
208 }
209 };
210 if stale {
211 let _ = fs::remove_file(&path);
213 }
214 }
215}
216
217fn alive(pid: u32) -> bool {
220 #[cfg(unix)]
221 {
222 extern "C" {
223 fn kill(pid: i32, sig: i32) -> i32;
224 }
225 let Ok(pid) = i32::try_from(pid) else {
226 return false;
227 };
228 if unsafe { kill(pid, 0) } == 0 {
230 return true;
231 }
232 std::io::Error::last_os_error().raw_os_error() == Some(1) }
234 #[cfg(not(unix))]
235 {
236 let _ = pid;
240 true
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 fn scratch(tag: &str) -> PathBuf {
249 let d = std::env::temp_dir().join(format!("amont-active-{tag}-{}", std::process::id()));
250 let _ = fs::remove_dir_all(&d);
251 d
252 }
253
254 fn read(path: &Path) -> crate::json_read::Value {
255 crate::json_read::parse(&fs::read_to_string(path).unwrap()).unwrap()
256 }
257
258 #[test]
259 fn an_entry_carries_the_documented_fields_and_ends_in_place() {
260 let dir = scratch("fields");
261 let log = dir.join("a \"quoted\" \\ path.log.tmp");
262 let mut e = Entry::start_for(&dir, "clippy", "/r/repo", &log, "s-test").unwrap();
263 let v = read(&e.path);
264 for k in [
265 "v", "tool", "session", "check", "repo", "log", "pid", "started",
266 ] {
267 assert!(v.get(k).is_some(), "missing {k}");
268 }
269 assert_eq!(v.get("tool").and_then(|t| t.as_str()), Some("amont"));
270 assert_eq!(
271 v.get("log").and_then(|t| t.as_str()),
272 Some(&*log.to_string_lossy())
273 );
274 assert!(v.get("ended").is_none());
275 let done = dir.join("final.log");
276 e.end(Some(101), Some(&done));
277 let v = read(&e.path);
278 assert_eq!(
279 v.get("log").and_then(|t| t.as_str()),
280 Some(&*done.to_string_lossy())
281 );
282 assert!(matches!(v.get("exit"), Some(crate::json_read::Value::Num(n)) if n == "101"));
283 assert!(v.get("ended").is_some());
284 assert!(e.path.exists(), "an ended entry is left for the watcher");
285 }
286
287 #[test]
288 fn two_entries_in_one_process_get_two_files() {
289 let dir = scratch("two");
290 let a = Entry::start_for(&dir, "a", "/r", &dir.join("a.log"), "s-test").unwrap();
291 let b = Entry::start_for(&dir, "b", "/r", &dir.join("b.log"), "s-test").unwrap();
292 assert_ne!(a.path, b.path);
293 assert!(a.path.exists() && b.path.exists());
294 }
295
296 #[test]
297 fn dropping_without_end_records_no_exit() {
298 let dir = scratch("drop");
299 let path = {
300 let e = Entry::start_for(&dir, "x", "/r", &dir.join("x.log"), "s-test").unwrap();
301 e.path.clone()
302 };
303 let v = read(&path);
304 assert!(matches!(v.get("exit"), Some(crate::json_read::Value::Null)));
305 assert!(v.get("ended").is_some());
306 }
307
308 #[test]
309 fn the_sweep_takes_dead_and_old_ended_entries_only() {
310 let dir = scratch("sweep");
311 fs::create_dir_all(&dir).unwrap();
312 let old = now_ms() - 120_000;
313 let recent = now_ms() - 1_000;
314 fs::write(
316 dir.join("dead.json"),
317 r#"{"tool":"amont","pid":2147483632}"#,
318 )
319 .unwrap();
320 fs::write(
321 dir.join("other-tool-dead.json"),
322 r#"{"tool":"amont-agent","pid":2147483632}"#,
323 )
324 .unwrap();
325 fs::write(
326 dir.join("old.json"),
327 format!(r#"{{"tool":"amont-agent","ended":{old}}}"#),
328 )
329 .unwrap();
330 fs::write(
331 dir.join("recent.json"),
332 format!(r#"{{"tool":"amont","ended":{recent}}}"#),
333 )
334 .unwrap();
335 let _live = Entry::start_for(&dir, "live", "/r", &dir.join("l.log"), "s-test").unwrap();
336 assert_eq!(dir.join("dead.json").exists(), !cfg!(unix));
338 assert!(
339 dir.join("other-tool-dead.json").exists(),
340 "another tool's live-looking entry is its own"
341 );
342 assert!(!dir.join("old.json").exists());
343 assert!(dir.join("recent.json").exists());
344 }
345
346 #[test]
347 fn quiet_entries_never_reach_the_real_state_dir() {
348 assert_eq!(std::env::var("CLAUDE_CODE_SESSION_ID").as_deref(), Ok(""));
351 assert!(std::env::var("QUIET_ACTIVE_DIR").is_ok_and(|d| d.contains("target")));
352 assert!(Entry::start(&scratch("nosession"), "x", || "/r", Path::new("/l")).is_none());
353 }
354
355 #[cfg(unix)]
356 #[test]
357 fn the_entry_and_its_dir_are_private() {
358 use std::os::unix::fs::PermissionsExt;
359 let dir = scratch("mode");
360 let e = Entry::start_for(&dir, "x", "/r", &dir.join("x.log"), "s-test").unwrap();
361 assert_eq!(
362 fs::metadata(&e.path).unwrap().permissions().mode() & 0o777,
363 0o600
364 );
365 assert_eq!(
366 fs::metadata(&dir).unwrap().permissions().mode() & 0o777,
367 0o700
368 );
369 }
370
371 #[test]
372 fn an_unwritable_dir_gives_no_entry_and_no_error() {
373 let file = scratch("blocked");
374 fs::write(&file, "not a dir").unwrap();
375 assert!(
376 Entry::start_for(&file.join("sub"), "x", "/r", Path::new("/l"), "s-test").is_none()
377 );
378 }
379}