Skip to main content

amont_agent_process/
attestations.rs

1//! `~/.claude/amont-agent/attestations/` is swept, except what a plan cites.
2//!
3//! That directory is where the worktree-task skill puts evidence (F3 records,
4//! F7 screenshots and `guide.md`) and nothing ever removed anything from it.
5//! A direct child directory is KEPT when its newest entry is younger than 30
6//! days, when a Markdown file under a known repository's `docs/` (or
7//! `~/.claude/plans`) cites it, or when it holds a `.keep`. Everything else is
8//! renamed to `.trash-<unix-secs>-<name>` and deleted once the time in that
9//! name is 7 days old.
10//!
11//! (docs/plans/2026-10-11-attestations-swept-except-cited.md)
12//!
13//! ## Failing safe
14//!
15//! Any read error during the scan for citations stops the removal of every
16//! directory for that run: a directory that a plan cites must not go because
17//! the plan could not be read. The purge of old `.trash-*` entries is not part
18//! of that, because it was decided a week ago.
19//!
20//! ## One run at a time
21//!
22//! `attestations.sweeping` is created with `create_new`; the holder checks the
23//! daily stamp `attestations.swept`, touches it BEFORE scanning (so a failed
24//! scan does not rescan every session) and removes the lock at the end. A
25//! lock older than an hour is a crashed run and is taken over by renaming it
26//! to `attestations.sweeping.stale-<pid>`: of several processes only one
27//! rename wins. The winner re-checks the renamed file's age, because a loser
28//! of an earlier race may have renamed a LIVE lock created in between; under
29//! an hour old, it puts it back and backs off.
30
31use amont_agent_guard::journal::{self, Entry};
32use amont_agent_guard::ui;
33use std::collections::BTreeMap;
34use std::ffi::OsString;
35use std::fs;
36use std::io::{self, IsTerminal, Write};
37use std::path::{Path, PathBuf};
38use std::process::ExitCode;
39use std::time::{Duration, UNIX_EPOCH};
40
41pub const DAY: u64 = 86_400;
42const KEEP_FOR: u64 = 30 * DAY;
43const TRASH_FOR: u64 = 7 * DAY;
44const LOCK_STALE: u64 = 3600;
45const GIT_ROOTS_KEY: &str = r"^amont\.agent\.plan-review\..*\.path$";
46
47/// Where the scan or the sweep could not read, and why.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Failure {
50    pub path: String,
51    pub err: String,
52}
53
54fn fail(path: &Path, e: &io::Error) -> Failure {
55    Failure {
56        path: path.display().to_string(),
57        err: e.to_string(),
58    }
59}
60
61/// Everything the sweep reads, so a test can point it at a scratch tree.
62pub struct Env {
63    /// `attestations/`.
64    pub store: PathBuf,
65    /// Beside the journal: the stamp and the lock live here.
66    pub state: PathBuf,
67    /// The directory repositories sit under; `Err` when it cannot be resolved.
68    pub plan_root: Result<PathBuf, String>,
69    /// `~/.claude/plans`.
70    pub claude_plans: PathBuf,
71    /// The configured `plan-review.<name>.path` roots. `None` asks git, which
72    /// is the only subprocess the sweep runs; a test passes its own answer.
73    pub git: Option<Result<Vec<PathBuf>, String>>,
74}
75
76impl Env {
77    pub fn live() -> Option<Env> {
78        let state = journal::dir()?;
79        Some(Env {
80            store: state.join("attestations"),
81            plan_root: crate::plan_review::root().ok_or_else(|| "no home directory".to_string()),
82            claude_plans: amont_agent_guard::settings::config_dir()?.join("plans"),
83            state,
84            git: None,
85        })
86    }
87    fn stamp(&self) -> PathBuf {
88        self.state.join("attestations.swept")
89    }
90    fn lock(&self) -> PathBuf {
91        self.state.join("attestations.sweeping")
92    }
93}
94
95pub fn now() -> u64 {
96    std::time::SystemTime::now()
97        .duration_since(UNIX_EPOCH)
98        .map(|d| d.as_secs())
99        .unwrap_or(0)
100}
101
102// ---------------------------------------------------------------- citations
103
104/// Every `attestations/<name>` in `text`: the maximal `[A-Za-z0-9._-]` run
105/// after the slash, trailing dots (a sentence end) stripped. Plain
106/// `str::find`; the full path `~/.claude/amont-agent/attestations/x` holds the
107/// same text and counts.
108fn citations(text: &str, mut found: impl FnMut(&str)) {
109    const KEY: &str = "attestations/";
110    let mut rest = text;
111    while let Some(i) = rest.find(KEY) {
112        let after = &rest[i + KEY.len()..];
113        let end = after
114            .find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
115            .unwrap_or(after.len());
116        let name = after[..end].trim_end_matches('.');
117        if !name.is_empty() {
118            found(name);
119        }
120        rest = after;
121    }
122}
123
124fn is_hex(s: &str) -> bool {
125    !s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit())
126}
127
128/// Does a citation of `cited` keep the directory `dir`? Slugs match exactly; a
129/// cited name of at least 7 hex characters also keeps a 40-hex directory that
130/// starts with it, because plans cite short shas.
131fn names_match(cited: &str, dir: &str) -> bool {
132    cited == dir
133        || (cited.len() >= 7
134            && is_hex(cited)
135            && dir.len() == 40
136            && is_hex(dir)
137            && dir.starts_with(cited))
138}
139
140type Cited = BTreeMap<String, String>;
141
142fn sorted_entries(dir: &Path) -> Result<Vec<fs::DirEntry>, Failure> {
143    let mut v = fs::read_dir(dir)
144        .map_err(|e| fail(dir, &e))?
145        .collect::<Result<Vec<_>, _>>()
146        .map_err(|e| fail(dir, &e))?;
147    v.sort_by_key(|e| e.file_name());
148    Ok(v)
149}
150
151fn scan_file(path: &Path, base: &Path, out: &mut Cited) -> Result<(), Failure> {
152    let bytes = fs::read(path).map_err(|e| fail(path, &e))?;
153    let text = String::from_utf8_lossy(&bytes);
154    let shown = path
155        .strip_prefix(base)
156        .unwrap_or(path)
157        .display()
158        .to_string();
159    citations(&text, |name| {
160        out.entry(name.to_string()).or_insert_with(|| shown.clone());
161    });
162    Ok(())
163}
164
165/// Every `*.md` below `dir`. Symlinks are not followed.
166fn scan_docs(dir: &Path, base: &Path, out: &mut Cited) -> Result<(), Failure> {
167    for e in sorted_entries(dir)? {
168        let path = e.path();
169        let ft = e.file_type().map_err(|err| fail(&path, &err))?;
170        if ft.is_dir() {
171            scan_docs(&path, base, out)?;
172        } else if ft.is_file() && path.extension().is_some_and(|x| x == "md") {
173            scan_file(&path, base, out)?;
174        }
175    }
176    Ok(())
177}
178
179/// Scan `<root>/docs` when there is one.
180fn scan_root(root: &Path, base: &Path, out: &mut Cited) -> Result<(), Failure> {
181    let docs = root.join("docs");
182    match fs::metadata(&docs) {
183        Ok(m) if m.is_dir() => scan_docs(&docs, base, out),
184        Ok(_) => Ok(()),
185        Err(e)
186            if matches!(
187                e.kind(),
188                io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
189            ) =>
190        {
191            Ok(())
192        }
193        Err(e) => Err(fail(&docs, &e)),
194    }
195}
196
197/// The `amont.agent.plan-review.<name>.path` roots from global git config.
198/// Exit 1 with no output is git saying no key matched; git not running, or
199/// any other exit, is a failure.
200fn git_roots() -> Result<Vec<PathBuf>, Failure> {
201    let args = [
202        "config",
203        "--global",
204        "--type=path",
205        "--get-regexp",
206        GIT_ROOTS_KEY,
207    ];
208    let bad = |err: String| Failure {
209        path: "git config --global".to_string(),
210        err,
211    };
212    let out = amont_agent_guard::git::output(&args).ok_or_else(|| bad("git did not run".into()))?;
213    roots_from(out.code, &out.stdout, &out.stderr).map_err(bad)
214}
215
216/// What `git config --get-regexp` said, as roots: exit 0 lists them, exit 1
217/// with nothing printed is no key matching (no configured roots), and
218/// anything else is a failure.
219fn roots_from(code: i32, stdout: &str, stderr: &str) -> Result<Vec<PathBuf>, String> {
220    match code {
221        0 => Ok(stdout
222            .lines()
223            .filter_map(|l| l.split_once(' ').map(|(_, v)| PathBuf::from(v.trim())))
224            .collect()),
225        1 if stdout.trim().is_empty() => Ok(Vec::new()),
226        c => Err(format!("exit {c}: {}", stderr.trim())),
227    }
228}
229
230fn scan(env: &Env) -> Result<Cited, Failure> {
231    let mut out = Cited::new();
232    let mut seen: Vec<PathBuf> = Vec::new();
233    match &env.plan_root {
234        Err(why) => {
235            return Err(Failure {
236                path: "plan root".into(),
237                err: why.clone(),
238            })
239        }
240        Ok(root) => {
241            for e in sorted_entries(root)? {
242                let p = e.path();
243                scan_root(&p, root, &mut out)?;
244                seen.push(p);
245            }
246        }
247    }
248    let base = env.plan_root.as_deref().unwrap_or(Path::new(""));
249    let configured = match &env.git {
250        Some(Ok(v)) => v.clone(),
251        Some(Err(why)) => {
252            return Err(Failure {
253                path: "git config --global".into(),
254                err: why.clone(),
255            })
256        }
257        None => git_roots()?,
258    };
259    for p in configured {
260        if seen.contains(&p) {
261            continue;
262        }
263        if let Err(e) = fs::metadata(&p) {
264            return Err(fail(&p, &e));
265        }
266        scan_root(&p, base, &mut out)?;
267        seen.push(p);
268    }
269    match fs::read_dir(&env.claude_plans) {
270        Ok(rd) => {
271            let mut files = rd
272                .collect::<Result<Vec<_>, _>>()
273                .map_err(|e| fail(&env.claude_plans, &e))?;
274            files.sort_by_key(|e| e.file_name());
275            for e in files {
276                let p = e.path();
277                let ft = e.file_type().map_err(|err| fail(&p, &err))?;
278                if ft.is_file() && p.extension().is_some_and(|x| x == "md") {
279                    scan_file(&p, &env.claude_plans, &mut out)?;
280                }
281            }
282        }
283        // No plans directory yet is no plans.
284        Err(e) if e.kind() == io::ErrorKind::NotFound => {}
285        Err(e) => return Err(fail(&env.claude_plans, &e)),
286    }
287    Ok(out)
288}
289
290// ------------------------------------------------------------- the store
291
292/// What is known about one directory.
293struct Probe {
294    newest: u64,
295    bytes: u64,
296    /// An mtime that cannot be read or lies in the future: counts as recent.
297    doubtful: bool,
298}
299
300fn walk(path: &Path, now: u64, p: &mut Probe) {
301    let Ok(rd) = fs::read_dir(path) else {
302        p.doubtful = true;
303        return;
304    };
305    for e in rd {
306        let Ok(e) = e else {
307            p.doubtful = true;
308            continue;
309        };
310        let child = e.path();
311        let Ok(meta) = fs::symlink_metadata(&child) else {
312            p.doubtful = true;
313            continue;
314        };
315        note(&meta, now, p);
316        if meta.is_dir() {
317            walk(&child, now, p);
318        } else {
319            p.bytes += meta.len();
320        }
321    }
322}
323
324fn note(meta: &fs::Metadata, now: u64, p: &mut Probe) {
325    match meta
326        .modified()
327        .ok()
328        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
329    {
330        Some(d) => {
331            p.newest = p.newest.max(d.as_secs());
332            if d.as_secs() > now {
333                p.doubtful = true;
334            }
335        }
336        None => p.doubtful = true,
337    }
338}
339
340pub struct Row {
341    pub name: String,
342    pub age: u64,
343    pub bytes: u64,
344    pub remove: bool,
345    pub reason: String,
346    pub cited: bool,
347}
348
349fn rows(env: &Env, now: u64, cited: Result<&Cited, &Failure>) -> Result<Vec<Row>, Failure> {
350    let entries = match sorted_entries(&env.store) {
351        Ok(v) => v,
352        Err(_) if !env.store.exists() => return Ok(Vec::new()),
353        Err(f) => return Err(f),
354    };
355    let mut out = Vec::new();
356    for e in entries {
357        let name = e.file_name().to_string_lossy().into_owned();
358        if name.starts_with('.') {
359            continue;
360        }
361        // `DirEntry::file_type` does not follow a symlink.
362        if !e.file_type().is_ok_and(|t| t.is_dir()) {
363            continue;
364        }
365        let path = e.path();
366        let mut probe = Probe {
367            newest: 0,
368            bytes: 0,
369            doubtful: false,
370        };
371        match fs::symlink_metadata(&path) {
372            Ok(m) => note(&m, now, &mut probe),
373            Err(_) => probe.doubtful = true,
374        }
375        walk(&path, now, &mut probe);
376        let age = if probe.doubtful {
377            0
378        } else {
379            now.saturating_sub(probe.newest)
380        };
381        let cite = cited.ok().and_then(|c| {
382            c.iter()
383                .find(|(k, _)| names_match(k, &name))
384                .map(|(_, v)| v)
385        });
386        let keep_file = fs::symlink_metadata(path.join(".keep")).is_ok();
387        let (remove, reason) = if probe.doubtful || age < KEEP_FOR {
388            (
389                false,
390                format!("recent {}", amont_agent_guard::doctor::ago(age)),
391            )
392        } else if let Some(by) = cite {
393            (false, format!("cited by {by}"))
394        } else if keep_file {
395            (false, ".keep".to_string())
396        } else if let Err(f) = cited {
397            (false, format!("held (scan failed: {})", f.path))
398        } else {
399            (
400                true,
401                format!("older than {}", amont_agent_guard::doctor::ago(KEEP_FOR)),
402            )
403        };
404        out.push(Row {
405            name,
406            age,
407            bytes: probe.bytes,
408            remove,
409            reason,
410            cited: cite.is_some(),
411        });
412    }
413    Ok(out)
414}
415
416pub struct Report {
417    pub rows: Vec<Row>,
418    pub failure: Option<Failure>,
419    /// (name, bytes) of what moved to the trash.
420    pub removed: Vec<(String, u64)>,
421    /// (name, io error) of what could not.
422    pub errors: Vec<(String, String)>,
423    pub purged: usize,
424}
425
426/// Delete `.trash-<secs>-*` whose time is `TRASH_FOR` old. A name without a
427/// readable time is left alone. Returns how many went, and (entry, io error)
428/// for each that could not: this is the step that really deletes, so its
429/// failures are reported, never dropped.
430fn purge(store: &Path, now: u64) -> (usize, Vec<(String, String)>) {
431    let mut errors = Vec::new();
432    let rd = match fs::read_dir(store) {
433        Ok(rd) => rd,
434        Err(e) if e.kind() == io::ErrorKind::NotFound => return (0, errors),
435        Err(e) => {
436            errors.push((store.display().to_string(), e.to_string()));
437            return (0, errors);
438        }
439    };
440    let mut n = 0;
441    for e in rd {
442        let e = match e {
443            Ok(e) => e,
444            Err(err) => {
445                errors.push((store.display().to_string(), err.to_string()));
446                continue;
447            }
448        };
449        let name = e.file_name().to_string_lossy().into_owned();
450        let Some(rest) = name.strip_prefix(".trash-") else {
451            continue;
452        };
453        let Some((secs, _)) = rest.split_once('-') else {
454            continue;
455        };
456        let Ok(secs) = secs.parse::<u64>() else {
457            continue;
458        };
459        if now.saturating_sub(secs) < TRASH_FOR {
460            continue;
461        }
462        let path = e.path();
463        let gone = match fs::symlink_metadata(&path) {
464            Ok(m) if m.is_dir() => fs::remove_dir_all(&path),
465            Ok(_) => fs::remove_file(&path),
466            Err(e) => Err(e),
467        };
468        match gone {
469            Ok(()) => n += 1,
470            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
471            Err(err) => errors.push((name, err.to_string())),
472        }
473    }
474    (n, errors)
475}
476
477/// Scan, decide and (with `apply`) act. Without `apply` nothing is changed.
478pub fn run(env: &Env, now: u64, apply: bool) -> Report {
479    let scanned = scan(env);
480    let (cited, failure) = match &scanned {
481        Ok(c) => (Ok(c), None),
482        Err(f) => (Err(f), Some(f.clone())),
483    };
484    let mut report = Report {
485        rows: Vec::new(),
486        failure,
487        removed: Vec::new(),
488        errors: Vec::new(),
489        purged: 0,
490    };
491    match rows(env, now, cited) {
492        Ok(r) => report.rows = r,
493        Err(f) => {
494            report.failure.get_or_insert(f);
495        }
496    }
497    if !apply {
498        return report;
499    }
500    let (purged, failed) = purge(&env.store, now);
501    report.purged = purged;
502    report.errors.extend(failed);
503    if report.failure.is_some() {
504        return report;
505    }
506    for row in report.rows.iter_mut().filter(|r| r.remove) {
507        let to = env.store.join(format!(".trash-{now}-{}", row.name));
508        match fs::rename(env.store.join(&row.name), to) {
509            Ok(()) => report.removed.push((row.name.clone(), row.bytes)),
510            // Someone got there first.
511            Err(e) if e.kind() == io::ErrorKind::NotFound => row.remove = false,
512            Err(e) => {
513                row.remove = false;
514                report.errors.push((row.name.clone(), e.to_string()));
515            }
516        }
517    }
518    report
519}
520
521fn record(outcome: &str, excerpt: &str) {
522    journal::record(&Entry {
523        rule: "-",
524        stance: "-",
525        outcome,
526        session: "-",
527        repo: "-",
528        mode: "-",
529        excerpt,
530    });
531}
532
533/// One line per removal, one per run with what remains, and a failed scan last
534/// so that it is the final word `doctor` reads.
535fn journal_report(r: &Report) {
536    for (name, bytes) in &r.removed {
537        record("attestation-swept", &format!("{name} {bytes}"));
538    }
539    let gone: Vec<&str> = r.removed.iter().map(|(n, _)| n.as_str()).collect();
540    let kept: Vec<&Row> = r
541        .rows
542        .iter()
543        .filter(|x| !gone.contains(&x.name.as_str()))
544        .collect();
545    let bytes: u64 = kept.iter().map(|x| x.bytes).sum();
546    record("attestations-kept", &format!("{} {bytes}", kept.len()));
547    if let Some(f) = &r.failure {
548        record("attestations-scan-failed", &f.path);
549    }
550}
551
552// ---------------------------------------------------------------- locking
553
554pub struct Lock(PathBuf);
555
556impl Drop for Lock {
557    fn drop(&mut self) {
558        let _ = fs::remove_file(&self.0);
559    }
560}
561
562fn age_of(path: &Path, now: u64) -> Option<u64> {
563    let m = fs::symlink_metadata(path).ok()?.modified().ok()?;
564    let secs = m.duration_since(UNIX_EPOCH).ok()?.as_secs();
565    Some(now.saturating_sub(secs))
566}
567
568fn create_lock(path: &Path) -> io::Result<Lock> {
569    create_lock_file(path).map(|_| Lock(path.to_path_buf()))
570}
571
572/// Take the lock, or `None` when a live run holds it.
573pub fn acquire(lock: &Path, now: u64) -> Option<Lock> {
574    if let Some(dir) = lock.parent() {
575        let _ = fs::create_dir_all(dir);
576    }
577    for _ in 0..2 {
578        match create_lock(lock) {
579            Ok(l) => return Some(l),
580            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
581            Err(_) => return None,
582        }
583        match age_of(lock, now) {
584            // Vanished between the two calls: try again.
585            None if !lock.exists() => continue,
586            Some(age) if age >= LOCK_STALE => {}
587            _ => return None,
588        }
589        return take_over(lock, now);
590    }
591    None
592}
593
594fn take_over(lock: &Path, now: u64) -> Option<Lock> {
595    let mut name = lock.as_os_str().to_os_string();
596    name.push(format!(".stale-{}", std::process::id()));
597    let stale = PathBuf::from(name);
598    // Only one rename of the same source can succeed.
599    fs::rename(lock, &stale).ok()?;
600    if age_of(&stale, now).is_some_and(|a| a < LOCK_STALE) {
601        // A live lock, created after our age check. It is not put back: its
602        // holder may have finished meanwhile, and a rename back would then
603        // leave a lock nobody holds, blocking every run for an hour (CI
604        // caught exactly that). The lock is advisory; the day's claim
605        // (`claim_today`) is what keeps the daily run single.
606        // holds-until: removal stays idempotent (a rename to the trash with
607        // NotFound tolerated). Then a manual `--apply` overlapping the daily
608        // run, which an advisory lock allows, is harmless; a removal that is
609        // not would need a lock that cannot be dropped this way.
610        let _ = fs::remove_file(&stale);
611        return None;
612    }
613    let got = create_lock(lock).ok();
614    let _ = fs::remove_file(&stale);
615    got
616}
617
618fn touch(path: &Path, now: u64) -> io::Result<()> {
619    let f = fs::OpenOptions::new()
620        .write(true)
621        .create(true)
622        .truncate(false)
623        .open(path)?;
624    f.set_modified(UNIX_EPOCH + Duration::from_secs(now))
625}
626
627fn stamp_fresh(env: &Env, now: u64) -> bool {
628    age_of(&env.stamp(), now).is_some_and(|a| a < DAY)
629}
630
631/// Claim today's run: `attestations.swept.<day>` made with `create_new`, so
632/// exactly one process per day gets it, whatever happened to the lock. The
633/// lock is advisory: a takeover can drop a live holder's lock, and that
634/// holder's `Drop` can then remove a successor's lock, so two processes can
635/// hold "the" lock at once (the stale-lock test found two daily runs before
636/// this claim existed). Older claims are removed.
637fn claim_today(env: &Env, now: u64) -> bool {
638    let day = now / DAY;
639    let mine = format!("attestations.swept.{day}");
640    if create_lock_file(&env.state.join(&mine)).is_err() {
641        return false;
642    }
643    if let Ok(rd) = fs::read_dir(&env.state) {
644        for e in rd.flatten() {
645            let name = e.file_name().to_string_lossy().into_owned();
646            if name.starts_with("attestations.swept.") && name != mine {
647                let _ = fs::remove_file(e.path());
648            }
649        }
650    }
651    true
652}
653
654fn create_lock_file(path: &Path) -> io::Result<fs::File> {
655    fs::OpenOptions::new()
656        .write(true)
657        .create_new(true)
658        .open(path)
659}
660
661/// The daily sweep. `None`: nothing to do, or someone else is doing it.
662pub fn sweep_at(env: &Env, now: u64) -> Option<Report> {
663    if !env.store.is_dir() {
664        return None;
665    }
666    // Not the check that decides (the day's claim does): a stat that keeps
667    // the common SessionStart from creating and removing a lock file.
668    if stamp_fresh(env, now) {
669        return None;
670    }
671    let _lock = acquire(&env.lock(), now)?;
672    if stamp_fresh(env, now) || !claim_today(env, now) {
673        return None;
674    }
675    // Before the scan: a failed scan must not rescan every session.
676    touch(&env.stamp(), now).ok()?;
677    let report = run(env, now, true);
678    journal_report(&report);
679    Some(report)
680}
681
682/// `SessionStart`: sweep at most once a day, in a detached child. Every
683/// failure is silent.
684///
685/// Measured on the real store (119 directories, 34 roots, load average
686/// ~100): 0.4 s warm and 1.8 s cold, over the 300 ms the hook may cost. So
687/// the session pays one stat and, once a day, one spawn; the child takes the
688/// lock, checks the stamp again and journals as an inline run would.
689pub fn sweep() {
690    let Some(env) = Env::live() else { return };
691    if !env.store.is_dir() || stamp_fresh(&env, now()) {
692        return;
693    }
694    let Ok(exe) = std::env::current_exe() else {
695        return;
696    };
697    let mut cmd = std::process::Command::new(exe);
698    cmd.args(["attestations", "--daily"])
699        .stdin(std::process::Stdio::null())
700        .stdout(std::process::Stdio::null())
701        .stderr(std::process::Stdio::null());
702    // Its own group: a hook runner that kills ours on a timeout leaves it.
703    #[cfg(unix)]
704    {
705        use std::os::unix::process::CommandExt;
706        cmd.process_group(0);
707    }
708    let _ = cmd.spawn();
709}
710
711// ------------------------------------------------------------ the subcommand
712
713const USAGE: &str = "\
714usage: amont-agent attestations [--apply]
715
716  lists each directory under ~/.claude/amont-agent/attestations, why it is
717  kept or would be removed; --apply moves the removable ones to
718  .trash-<secs>-<name>, deleted after 7 days
719
720  --daily   internal: the once-a-day run SessionStart starts in the background
721";
722
723fn mb(bytes: u64) -> String {
724    format!("{:.1} MB", bytes as f64 / 1e6)
725}
726
727fn render(rows: &[Row], tty: bool) -> String {
728    let mut ordered: Vec<&Row> = rows.iter().filter(|r| !r.remove).collect();
729    ordered.extend(rows.iter().filter(|r| r.remove));
730    let cells: Vec<[String; 5]> = ordered
731        .iter()
732        .map(|r| {
733            [
734                if r.remove { "remove" } else { "keep" }.to_string(),
735                ui::sanitize(&r.name),
736                amont_agent_guard::doctor::ago(r.age),
737                mb(r.bytes),
738                ui::sanitize(&r.reason),
739            ]
740        })
741        .collect();
742    let mut out = String::new();
743    if !tty {
744        for c in &cells {
745            out.push_str(&c.join("\t"));
746            out.push('\n');
747        }
748        return out;
749    }
750    let width = |i: usize| {
751        cells
752            .iter()
753            .map(|c| c[i].chars().count())
754            .max()
755            .unwrap_or(0)
756    };
757    let (w0, w1, w2, w3) = (width(0), width(1), width(2), width(3));
758    let fixed = w0 + w1 + w2 + w3 + 8;
759    let room = 80usize.saturating_sub(fixed).max(12);
760    for c in &cells {
761        let mut reason = c[4].clone();
762        if reason.chars().count() > room {
763            reason = reason.chars().take(room - 1).collect::<String>() + "…";
764        }
765        out.push_str(&format!(
766            "{:<w0$}  {:<w1$}  {:>w2$}  {:>w3$}  {reason}\n",
767            c[0], c[1], c[2], c[3]
768        ));
769    }
770    out
771}
772
773pub fn run_cli(args: &[OsString]) -> ExitCode {
774    if args.iter().any(|a| a == "--help" || a == "-h") {
775        let _ = io::stdout().write_all(USAGE.as_bytes());
776        return ExitCode::SUCCESS;
777    }
778    let mut apply = false;
779    for a in args {
780        match a.to_string_lossy().as_ref() {
781            "--apply" => apply = true,
782            // The child `sweep` spawns: the daily run, silent.
783            "--daily" => {
784                if let Some(env) = Env::live() {
785                    let _ = sweep_at(&env, now());
786                }
787                return ExitCode::SUCCESS;
788            }
789            "--help" | "-h" => {
790                let _ = io::stdout().write_all(USAGE.as_bytes());
791                return ExitCode::SUCCESS;
792            }
793            other => {
794                eprintln!(
795                    "amont-agent: unknown argument `{}`; try `amont-agent attestations --help`",
796                    ui::sanitize(other)
797                );
798                return ExitCode::from(2);
799            }
800        }
801    }
802    let Some(env) = Env::live() else {
803        eprintln!("amont-agent: no home directory");
804        return ExitCode::FAILURE;
805    };
806    let now = now();
807    let _lock = if apply {
808        match acquire(&env.lock(), now) {
809            Some(l) => Some(l),
810            None => {
811                eprintln!("amont-agent: a sweep is running");
812                return ExitCode::FAILURE;
813            }
814        }
815    } else {
816        None
817    };
818    let report = run(&env, now, apply);
819    if apply {
820        journal_report(&report);
821    }
822    // Not `print!`: nothing on the hook path writes stdout but the emitter.
823    let table = render(&report.rows, io::stdout().is_terminal());
824    let _ = io::stdout().write_all(table.as_bytes());
825
826    let mut code = ExitCode::SUCCESS;
827    if let Some(f) = &report.failure {
828        eprintln!(
829            "amont-agent: {}: {}; no directory removed",
830            ui::sanitize(&f.path),
831            ui::sanitize(&f.err)
832        );
833        code = ExitCode::FAILURE;
834    }
835    for (name, err) in &report.errors {
836        eprintln!("{} {}: {}", ui::error_sign(), ui::sanitize(name), err);
837        code = ExitCode::FAILURE;
838    }
839    let total: u64 = report.rows.iter().map(|r| r.bytes).sum();
840    let (n, bytes) = if apply {
841        (
842            report.removed.len(),
843            report.removed.iter().map(|(_, b)| b).sum::<u64>(),
844        )
845    } else {
846        (
847            report.rows.iter().filter(|r| r.remove).count(),
848            report
849                .rows
850                .iter()
851                .filter(|r| r.remove)
852                .map(|r| r.bytes)
853                .sum(),
854        )
855    };
856    eprintln!("{} directories, {}", report.rows.len(), mb(total));
857    if apply {
858        eprintln!("removed {n} ({}) to .trash-<secs>-<name>", mb(bytes));
859    } else {
860        eprintln!(
861            "nothing removed; amont-agent attestations --apply removes {n} ({})",
862            mb(bytes)
863        );
864    }
865    code
866}
867
868// ------------------------------------------------------------------ doctor
869
870pub struct Summary {
871    pub dirs: usize,
872    pub bytes: u64,
873    pub oldest: u64,
874    /// `None` when the scan failed.
875    pub cited: Option<usize>,
876    /// Seconds since the last daily sweep; `None` when there has not been one.
877    pub swept_ago: Option<u64>,
878}
879
880pub fn summary(env: &Env, now: u64) -> Option<Summary> {
881    if !env.store.is_dir() {
882        return None;
883    }
884    let scanned = scan(env);
885    let rows = rows(env, now, scanned.as_ref()).ok()?;
886    Some(Summary {
887        dirs: rows.len(),
888        bytes: rows.iter().map(|r| r.bytes).sum(),
889        oldest: rows.iter().map(|r| r.age).max().unwrap_or(0),
890        cited: scanned
891            .is_ok()
892            .then(|| rows.iter().filter(|r| r.cited).count()),
893        swept_ago: age_of(&env.stamp(), now),
894    })
895}
896
897/// The path the most recent run could not read, when that run failed.
898pub fn last_scan_failure() -> Option<String> {
899    for p in journal::paths().into_iter().rev() {
900        let Ok(text) = fs::read_to_string(&p) else {
901            continue;
902        };
903        for line in text.lines().rev() {
904            let mut f = line.splitn(9, ' ');
905            let outcome = f.nth(4);
906            match outcome {
907                Some("attestations-scan-failed") => {
908                    return Some(f.nth(3).unwrap_or("").to_string());
909                }
910                Some("attestations-kept") => return None,
911                _ => {}
912            }
913        }
914    }
915    None
916}
917
918pub fn mb_text(bytes: u64) -> String {
919    mb(bytes)
920}
921
922/// `amont-agent doctor`'s line for this store, which `main.rs` hands to
923/// the doctor: how much it holds, and whether the daily sweep is getting
924/// through. Silent when there is no store yet.
925pub fn doctor_finding() -> Option<amont_agent_guard::doctor::Finding> {
926    use amont_agent_guard::doctor::{ago, now, Finding};
927    let env = Env::live()?;
928    let t = now();
929    let s = summary(&env, t)?;
930    let swept = match s.swept_ago {
931        Some(a) => format!("swept {} ago", ago(a)),
932        None => "never swept".to_string(),
933    };
934    let cited = match s.cited {
935        Some(k) => format!("{k} cited"),
936        None => "cited unknown (scan failed)".to_string(),
937    };
938    let line = format!(
939        "attestations: {} directories, {}, oldest {}; {cited}; {swept}",
940        s.dirs,
941        mb_text(s.bytes),
942        ago(s.oldest),
943    );
944    if let Some(path) = last_scan_failure() {
945        return Some(Finding::warn(
946            line,
947            format!(
948                "the last sweep could not read {path} and removed nothing; \
949                 run `amont-agent attestations` to see why"
950            ),
951        ));
952    }
953    if s.swept_ago.is_some_and(|a| a > 2 * DAY) {
954        return Some(Finding::warn(
955            line,
956            "no SessionStart has swept for over 2 days; \
957             run `amont-agent attestations` to see what it would do"
958                .to_string(),
959        ));
960    }
961    Some(Finding::good(line))
962}
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967    use std::sync::atomic::{AtomicU32, Ordering};
968
969    const NOW: u64 = 1_800_000_000;
970
971    struct Scratch(PathBuf);
972    impl Scratch {
973        fn new() -> Scratch {
974            static N: AtomicU32 = AtomicU32::new(0);
975            let d = std::env::temp_dir().join(format!(
976                "amont-agent-att-{}-{}",
977                std::process::id(),
978                N.fetch_add(1, Ordering::SeqCst)
979            ));
980            let _ = fs::remove_dir_all(&d);
981            fs::create_dir_all(d.join("state/attestations")).unwrap();
982            fs::create_dir_all(d.join("root")).unwrap();
983            fs::create_dir_all(d.join("claude/plans")).unwrap();
984            Scratch(d)
985        }
986        fn env(&self) -> Env {
987            Env {
988                store: self.0.join("state/attestations"),
989                state: self.0.join("state"),
990                plan_root: Ok(self.0.join("root")),
991                claude_plans: self.0.join("claude/plans"),
992                git: Some(Ok(Vec::new())),
993            }
994        }
995        fn store(&self) -> PathBuf {
996            self.0.join("state/attestations")
997        }
998        /// A directory with one file, both `days` old.
999        fn dir(&self, name: &str, days: u64) -> PathBuf {
1000            let d = self.store().join(name);
1001            fs::create_dir_all(&d).unwrap();
1002            fs::write(d.join("a.txt"), "12345").unwrap();
1003            set(&d.join("a.txt"), NOW - days * DAY);
1004            set(&d, NOW - days * DAY);
1005            d
1006        }
1007        fn plan(&self, repo: &str, rel: &str, text: &str) {
1008            let f = self.0.join("root").join(repo).join(rel);
1009            fs::create_dir_all(f.parent().unwrap()).unwrap();
1010            fs::write(f, text).unwrap();
1011        }
1012        fn names(&self) -> Vec<String> {
1013            let mut v: Vec<String> = fs::read_dir(self.store())
1014                .unwrap()
1015                .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1016                .collect();
1017            v.sort();
1018            v
1019        }
1020    }
1021    impl Drop for Scratch {
1022        fn drop(&mut self) {
1023            #[cfg(unix)]
1024            {
1025                use std::os::unix::fs::PermissionsExt;
1026                let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o755));
1027            }
1028            let _ = fs::remove_dir_all(&self.0);
1029        }
1030    }
1031
1032    /// A directory opened so that its times can be set: Windows needs
1033    /// `FILE_FLAG_BACKUP_SEMANTICS` and write access to open one at all.
1034    fn open_dir(path: &Path) -> fs::File {
1035        #[cfg(windows)]
1036        {
1037            use std::os::windows::fs::OpenOptionsExt;
1038            const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
1039            fs::OpenOptions::new()
1040                .write(true)
1041                .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
1042                .open(path)
1043                .unwrap()
1044        }
1045        #[cfg(not(windows))]
1046        fs::File::open(path).unwrap()
1047    }
1048
1049    fn set(path: &Path, secs: u64) {
1050        let t = UNIX_EPOCH + Duration::from_secs(secs);
1051        if path.is_dir() {
1052            open_dir(path).set_modified(t).unwrap();
1053        } else {
1054            fs::OpenOptions::new()
1055                .write(true)
1056                .open(path)
1057                .unwrap()
1058                .set_modified(t)
1059                .unwrap();
1060        }
1061    }
1062
1063    fn removed(r: &Report) -> Vec<&str> {
1064        let mut v: Vec<&str> = r.removed.iter().map(|(n, _)| n.as_str()).collect();
1065        v.sort();
1066        v
1067    }
1068
1069    #[test]
1070    fn the_keep_rules_remove_only_what_nothing_keeps() {
1071        let s = Scratch::new();
1072        s.dir("old", 40);
1073        s.dir("fresh", 2);
1074        s.dir("cited", 40);
1075        let pinned = s.dir("pinned", 40);
1076        fs::write(pinned.join(".keep"), "").unwrap();
1077        set(&pinned, NOW - 40 * DAY);
1078        let deep = s.dir("deep", 40);
1079        fs::write(deep.join("new.png"), "x").unwrap();
1080        set(&deep.join("new.png"), NOW - 2 * DAY);
1081        fs::write(s.store().join("loose.txt"), "x").unwrap();
1082        set(&s.store().join("loose.txt"), NOW - 40 * DAY);
1083        let outside = s.0.join("outside");
1084        fs::create_dir_all(&outside).unwrap();
1085        #[cfg(unix)]
1086        std::os::unix::fs::symlink(&outside, s.store().join("link")).unwrap();
1087        s.plan("repo", "docs/plans/p.md", "see attestations/cited/guide.md");
1088
1089        let r = run(&s.env(), NOW, true);
1090        assert_eq!(r.failure, None);
1091        assert_eq!(removed(&r), ["old"]);
1092        let mut want = vec![
1093            ".trash-1800000000-old",
1094            "cited",
1095            "deep",
1096            "fresh",
1097            "loose.txt",
1098            "pinned",
1099        ];
1100        if cfg!(unix) {
1101            want.push("link");
1102        }
1103        want.sort();
1104        assert_eq!(s.names(), want);
1105        assert!(outside.is_dir());
1106    }
1107
1108    #[test]
1109    fn a_missing_plan_root_removes_nothing() {
1110        let s = Scratch::new();
1111        s.dir("old", 40);
1112        let mut env = s.env();
1113        env.plan_root = Ok(s.0.join("nowhere"));
1114        let r = run(&env, NOW, true);
1115        let f = r.failure.expect("scan failed");
1116        assert!(f.path.ends_with("nowhere"), "{f:?}");
1117        assert!(r.removed.is_empty());
1118        assert!(s.store().join("old").is_dir());
1119        assert!(r.rows[0].reason.starts_with("held (scan failed:"));
1120        assert!(!r.rows[0].remove);
1121    }
1122
1123    #[cfg(unix)]
1124    #[test]
1125    fn an_unreadable_plan_removes_nothing() {
1126        use std::os::unix::fs::PermissionsExt;
1127        let s = Scratch::new();
1128        s.dir("old", 40);
1129        s.plan("repo", "docs/plans/p.md", "x");
1130        let p = s.0.join("root/repo/docs/plans/p.md");
1131        fs::set_permissions(&p, fs::Permissions::from_mode(0o000)).unwrap();
1132        if fs::read(&p).is_ok() {
1133            return; // root ignores modes
1134        }
1135        let r = run(&s.env(), NOW, true);
1136        fs::set_permissions(&p, fs::Permissions::from_mode(0o644)).unwrap();
1137        assert!(r.failure.is_some());
1138        assert!(r.removed.is_empty());
1139        assert!(s.store().join("old").is_dir());
1140    }
1141
1142    #[test]
1143    fn a_missing_configured_root_holds_everything_and_a_present_one_is_scanned() {
1144        let s = Scratch::new();
1145        s.dir("by-dots", 40);
1146        s.dir("old", 40);
1147        let dots = s.0.join("dots");
1148        fs::create_dir_all(dots.join("docs/plans")).unwrap();
1149        fs::write(dots.join("docs/plans/x.md"), "attestations/by-dots").unwrap();
1150
1151        let mut env = s.env();
1152        env.git = Some(Ok(vec![dots.clone()]));
1153        let r = run(&env, NOW, false);
1154        assert_eq!(r.failure, None);
1155        let by: Vec<_> = r.rows.iter().map(|x| (x.name.as_str(), x.remove)).collect();
1156        assert_eq!(by, [("by-dots", false), ("old", true)]);
1157
1158        env.git = Some(Ok(vec![s.0.join("missing-dots")]));
1159        let r = run(&env, NOW, true);
1160        assert!(r.failure.unwrap().path.ends_with("missing-dots"));
1161        assert!(r.removed.is_empty());
1162    }
1163
1164    #[test]
1165    fn citations_keep_the_exact_name() {
1166        let s = Scratch::new();
1167        for n in ["x", "v1.2", "m0-duro-web", "y", "z"] {
1168            s.dir(n, 40);
1169        }
1170        s.dir("unrelated", 40);
1171        s.plan(
1172            "r",
1173            "docs/a.md",
1174            "see attestations/x/guide.md and attestations/y.",
1175        );
1176        s.plan(
1177            "r",
1178            "docs/b.md",
1179            "v attestations/v1.2 then ~/.claude/amont-agent/attestations/z\nlast attestations/m0-duro-web",
1180        );
1181        let r = run(&s.env(), NOW, false);
1182        let gone: Vec<_> = r
1183            .rows
1184            .iter()
1185            .filter(|x| x.remove)
1186            .map(|x| &x.name[..])
1187            .collect();
1188        assert_eq!(gone, ["unrelated"]);
1189    }
1190
1191    #[test]
1192    fn a_short_sha_keeps_the_long_one_and_a_slug_is_exact() {
1193        let s = Scratch::new();
1194        let long = format!("d78826f{}", "0".repeat(33));
1195        s.dir(&long, 40);
1196        s.dir("abc1234", 40);
1197        s.plan(
1198            "r",
1199            "docs/a.md",
1200            "attestations/d78826f and attestations/abc",
1201        );
1202        let r = run(&s.env(), NOW, false);
1203        let verdict: Vec<_> = r.rows.iter().map(|x| (x.name.clone(), x.remove)).collect();
1204        assert!(verdict.contains(&(long, false)), "{verdict:?}");
1205        assert!(
1206            verdict.contains(&("abc1234".to_string(), true)),
1207            "{verdict:?}"
1208        );
1209    }
1210
1211    #[test]
1212    fn claude_plans_and_other_docs_directories_count() {
1213        let s = Scratch::new();
1214        s.dir("new-thing", 40);
1215        s.dir("noted", 40);
1216        fs::write(s.0.join("claude/plans/x.md"), "attestations/new-thing").unwrap();
1217        s.plan("r", "docs/notes/y.md", "attestations/noted");
1218        let r = run(&s.env(), NOW, false);
1219        assert!(r.rows.iter().all(|x| !x.remove));
1220    }
1221
1222    #[cfg(unix)]
1223    #[test]
1224    fn a_symlink_inside_a_stale_directory_is_not_followed() {
1225        let s = Scratch::new();
1226        let d = s.dir("old", 40);
1227        let outside = s.0.join("outside");
1228        fs::create_dir_all(&outside).unwrap();
1229        fs::write(outside.join("keep.txt"), "x").unwrap();
1230        std::os::unix::fs::symlink(&outside, d.join("l")).unwrap();
1231        set(&d, NOW - 40 * DAY);
1232        // The link's own mtime is real time and cannot be set without
1233        // following it, so the clock runs 40 days past whichever is later,
1234        // NOW or today: the test must not start failing on a calendar date.
1235        let later = NOW.max(now()) + 40 * DAY;
1236        let r = run(&s.env(), later, true);
1237        assert_eq!(removed(&r), ["old"]);
1238        assert!(outside.join("keep.txt").is_file());
1239    }
1240
1241    #[test]
1242    fn git_exit_codes_map_to_roots_or_a_failure() {
1243        assert_eq!(
1244            roots_from(0, "amont.agent.plan-review.dots.path /h/dots\n", ""),
1245            Ok(vec![PathBuf::from("/h/dots")])
1246        );
1247        assert_eq!(roots_from(1, "", ""), Ok(vec![]), "no key matched");
1248        assert!(roots_from(1, "x y\n", "").is_err(), "exit 1 with output");
1249        assert!(roots_from(128, "", "fatal: bad config").is_err());
1250    }
1251
1252    #[cfg(unix)]
1253    #[test]
1254    fn a_trash_entry_that_cannot_be_deleted_is_reported() {
1255        use std::os::unix::fs::PermissionsExt;
1256        let s = Scratch::new();
1257        fs::create_dir_all(s.store()).unwrap();
1258        let t = s.store().join(format!(".trash-{}-x", NOW - 8 * DAY));
1259        fs::create_dir_all(t.join("inner")).unwrap();
1260        fs::write(t.join("inner/f"), "x").unwrap();
1261        fs::set_permissions(t.join("inner"), fs::Permissions::from_mode(0o500)).unwrap();
1262        // Root writes through 0o500, so there is nothing to test there.
1263        if fs::write(t.join("inner/probe"), "x").is_ok() {
1264            fs::set_permissions(t.join("inner"), fs::Permissions::from_mode(0o700)).unwrap();
1265            return;
1266        }
1267        let r = run(&s.env(), NOW, true);
1268        fs::set_permissions(t.join("inner"), fs::Permissions::from_mode(0o700)).unwrap();
1269        assert!(
1270            r.errors.iter().any(|(n, _)| n.starts_with(".trash-")),
1271            "{:?}",
1272            r.errors
1273        );
1274    }
1275
1276    #[test]
1277    fn a_future_mtime_counts_as_recent() {
1278        let s = Scratch::new();
1279        s.dir("odd", 0);
1280        set(&s.store().join("odd"), NOW + 10 * DAY);
1281        set(&s.store().join("odd/a.txt"), NOW + 10 * DAY);
1282        let r = run(&s.env(), NOW, true);
1283        assert!(r.removed.is_empty());
1284        assert!(r.rows[0].reason.starts_with("recent"));
1285    }
1286
1287    #[test]
1288    fn the_trash_clock_is_in_the_name() {
1289        let s = Scratch::new();
1290        s.dir("old", 40);
1291        let t1 = run(&s.env(), NOW, true);
1292        assert_eq!(removed(&t1), ["old"]);
1293        let trash = s.store().join(format!(".trash-{NOW}-old"));
1294        assert!(trash.is_dir());
1295        fs::create_dir_all(s.store().join(".trash-x")).unwrap();
1296        fs::create_dir_all(s.store().join(".trash-abc-y")).unwrap();
1297
1298        run(&s.env(), NOW + DAY, true);
1299        assert!(trash.is_dir(), "still there after one day");
1300        let r = run(&s.env(), NOW + 8 * DAY, true);
1301        assert!(!trash.exists(), "gone after eight");
1302        assert_eq!(r.purged, 1);
1303        assert!(s.store().join(".trash-x").is_dir());
1304        assert!(s.store().join(".trash-abc-y").is_dir());
1305    }
1306
1307    #[test]
1308    fn a_failed_scan_still_purges_the_trash() {
1309        let s = Scratch::new();
1310        s.dir("old", 40);
1311        let t = s.store().join(format!(".trash-{}-x", NOW - 8 * DAY));
1312        fs::create_dir_all(&t).unwrap();
1313        let mut env = s.env();
1314        env.plan_root = Ok(s.0.join("nowhere"));
1315        let r = run(&env, NOW, true);
1316        assert!(r.failure.is_some());
1317        assert!(!t.exists());
1318        assert!(s.store().join("old").is_dir());
1319    }
1320
1321    #[test]
1322    fn a_dry_run_changes_nothing() {
1323        let s = Scratch::new();
1324        s.dir("old", 40);
1325        let t = s.store().join(format!(".trash-{}-x", NOW - 8 * DAY));
1326        fs::create_dir_all(&t).unwrap();
1327        let r = run(&s.env(), NOW, false);
1328        assert!(r.rows[0].remove);
1329        assert!(t.exists());
1330        assert!(s.store().join("old").is_dir());
1331    }
1332
1333    #[test]
1334    fn a_fresh_lock_blocks_the_sweep_and_a_two_hour_one_is_taken_over() {
1335        let s = Scratch::new();
1336        s.dir("old", 40);
1337        let lock = s.0.join("state/attestations.sweeping");
1338        fs::write(&lock, "").unwrap();
1339        set(&lock, NOW - 600);
1340        assert!(sweep_at(&s.env(), NOW).is_none());
1341        assert!(s.store().join("old").is_dir());
1342        assert!(lock.exists());
1343
1344        set(&lock, NOW - 7200);
1345        let r = sweep_at(&s.env(), NOW).expect("took over");
1346        assert_eq!(removed(&r), ["old"]);
1347        assert!(!lock.exists(), "the lock is removed at the end");
1348        let leftovers: Vec<_> = fs::read_dir(s.0.join("state"))
1349            .unwrap()
1350            .flatten()
1351            .filter(|e| e.file_name().to_string_lossy().contains(".stale-"))
1352            .collect();
1353        assert!(leftovers.is_empty());
1354    }
1355
1356    #[test]
1357    fn a_stale_rename_of_a_live_lock_backs_off_and_leaves_no_file() {
1358        let s = Scratch::new();
1359        let lock = s.0.join("state/attestations.sweeping");
1360        fs::write(&lock, "").unwrap();
1361        set(&lock, NOW - 60);
1362        // What a loser of an earlier race does: it saw an old lock, and by
1363        // the time it renames, the file there is a live one.
1364        assert!(take_over(&lock, NOW).is_none());
1365        assert!(!lock.exists(), "not put back: its holder may be gone");
1366        let stale = format!("{}.stale-{}", lock.display(), std::process::id());
1367        assert!(!Path::new(&stale).exists());
1368    }
1369
1370    #[test]
1371    fn the_stamp_limits_the_sweep_to_once_a_day() {
1372        let s = Scratch::new();
1373        s.dir("old", 40);
1374        assert!(sweep_at(&s.env(), NOW).is_some());
1375        s.dir("old2", 40);
1376        assert!(sweep_at(&s.env(), NOW + 3600).is_none());
1377        assert!(s.store().join("old2").is_dir());
1378        assert!(sweep_at(&s.env(), NOW + DAY + 1).is_some());
1379    }
1380
1381    #[test]
1382    fn a_scan_failure_still_advances_the_stamp() {
1383        let s = Scratch::new();
1384        s.dir("old", 40);
1385        let mut env = s.env();
1386        env.plan_root = Ok(s.0.join("nowhere"));
1387        let r = sweep_at(&env, NOW).unwrap();
1388        assert!(r.failure.is_some());
1389        assert!(sweep_at(&env, NOW + 60).is_none());
1390    }
1391
1392    #[test]
1393    fn rendering_is_tab_separated_when_piped_and_sanitised() {
1394        // Rows, not directories: Windows refuses a control character in a
1395        // file name, and rendering never touches the file system.
1396        let row = |name: &str, remove: bool, reason: &str| Row {
1397            name: name.to_string(),
1398            age: if remove { 40 * DAY } else { DAY },
1399            bytes: 5,
1400            remove,
1401            reason: reason.to_string(),
1402            cited: false,
1403        };
1404        let rows = [
1405            row("bad\u{1b}[8mname", true, "older than 30d"),
1406            row("fresh", false, "recent 1d"),
1407        ];
1408        let r = Report {
1409            rows: rows.into(),
1410            failure: None,
1411            removed: Vec::new(),
1412            errors: Vec::new(),
1413            purged: 0,
1414        };
1415        let text = render(&r.rows, false);
1416        let lines: Vec<&str> = text.lines().collect();
1417        assert!(lines[0].starts_with("keep\tfresh\t"), "{text}");
1418        assert!(lines[1].starts_with("remove\tbad\\x1b[8mname\t"), "{text}");
1419        assert_eq!(lines[1].split('\t').count(), 5);
1420        let tty = render(&r.rows, true);
1421        assert!(tty.lines().all(|l| l.chars().count() <= 80), "{tty}");
1422    }
1423}