Skip to main content

agentmux_srv/backend/
cli_prune.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Which installed provider CLIs can go, decided by *disuse* and never by
5//! version number (docs/specs/SPEC_RUNTIME_MENU_REMAINING_GAPS_2026_10_01.md §5).
6//!
7//! `shared/cli/<provider>/<pin>/` is shared by every AgentMux version and
8//! channel on the machine, and they can run at the same time: an older build
9//! pins an older CLI, so "everything but the newest" would pull a CLI out from
10//! under a live agent. A directory is removable only when ALL hold:
11//!
12//! - it is not the current pin of any provider in this build's registry;
13//! - nothing has used it for `max_idle` (see [`record_use`]);
14//! - no live controller's command and no running process's command line
15//!   mentions it (a scan that FAILS means "in use": see [`InUse`]);
16//! - the install lock can be taken without waiting.
17//!
18//! Legacy per-version folders (`instances/v<version>/cli/<provider>`) go by the
19//! same rules, except that their age is their install time and the running
20//! version's own folder is never touched.
21//!
22//! Nothing here runs by itself: [`plan_prune`] only reads, and [`prune`] is
23//! called by whoever decides it is time (§5.8 is an open owner decision on
24//! automatic vs opt-in).
25
26use std::path::{Path, PathBuf};
27use std::time::{Duration, SystemTime};
28
29use agentmux_common::DataPaths;
30
31use super::cli_install::{
32    is_safe_provider_component, is_safe_version_component, try_lock_install, COMPLETE_MARKER,
33};
34
35/// Touched whenever an install is handed out; only its mtime matters.
36pub const LAST_USED_MARKER: &str = ".agentmux-last-used";
37
38/// How often [`record_use`] actually writes: the marker is on the path that
39/// resolves a CLI for every spawn, so it must stay cheap.
40pub const RECORD_USE_EVERY: Duration = Duration::from_secs(60 * 60);
41
42/// The default line (spec §5.4): 30 days without use.
43pub const DEFAULT_MAX_IDLE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
44
45/// Note that `dir` was just used. Best effort, throttled to [`RECORD_USE_EVERY`]
46/// by the marker's own mtime; an error is ignored (a failure to record can only
47/// make the directory look older, and the other guards still apply).
48pub fn record_use(dir: &Path, now: SystemTime) {
49    let marker = dir.join(LAST_USED_MARKER);
50    if let Ok(modified) = std::fs::metadata(&marker).and_then(|m| m.modified()) {
51        if now
52            .duration_since(modified)
53            .map_or(true, |d| d < RECORD_USE_EVERY)
54        {
55            return;
56        }
57    }
58    // Set the mtime explicitly: rewriting the empty marker does not move it on
59    // Windows (truncating an empty file changes nothing), so the marker would
60    // keep its first-use time and a used install would look idle.
61    if let Ok(file) = std::fs::File::options().create(true).write(true).open(&marker) {
62        let _ = file.set_modified(now);
63    }
64}
65
66/// What is running right now, as text to search for install paths in.
67#[derive(Debug, Clone, Default)]
68pub struct InUse {
69    /// The command (program and arguments) each live controller was spawned
70    /// with: the primary guard, and platform independent.
71    pub live_commands: Vec<String>,
72    /// The command line of every running process, or `None` when it could not
73    /// be read. `None` makes [`plan_prune`] keep EVERYTHING: a guard that
74    /// cannot see must not approve.
75    pub process_commands: Option<Vec<String>>,
76}
77
78impl InUse {
79    /// Whether any known command mentions something under `dir`.
80    fn mentions(&self, dir: &Path) -> bool {
81        // Trailing separator, so `…/claude/2.1.28` is not "in use" because of
82        // `…/claude/2.1.285/…`.
83        let needle = format!("{}{}", dir.display(), std::path::MAIN_SEPARATOR);
84        self.live_commands
85            .iter()
86            .chain(self.process_commands.iter().flatten())
87            .any(|c| c.contains(&needle))
88    }
89}
90
91/// The running processes' command lines. Unix: `ps`; Windows is not scanned
92/// (`None`, so nothing is pruned there until it is).
93pub fn scan_process_commands() -> Option<Vec<String>> {
94    if cfg!(windows) {
95        return None;
96    }
97    let mut cmd = std::process::Command::new("ps");
98    cmd.args(["-axww", "-o", "args="]);
99    crate::backend::pane_env::sanitize_external_std_command(&mut cmd);
100    let out = cmd.output().ok()?;
101    if !out.status.success() {
102        return None;
103    }
104    let text = String::from_utf8_lossy(&out.stdout);
105    let lines: Vec<String> = text.lines().map(str::to_owned).collect();
106    // Even a nearly idle machine has dozens of processes: an empty listing
107    // means the scan did not work.
108    (!lines.is_empty()).then_some(lines)
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum Keep {
113    CurrentPin,
114    RecentlyUsed,
115    InUse,
116    /// The process scan failed, so nothing can be shown to be unused.
117    ScanFailed,
118    RunningVersion,
119    /// A symlink, or a name that is not a safe path component: never touched.
120    Skipped,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct Candidate {
125    pub dir: PathBuf,
126    pub provider: String,
127    /// The pin for a shared install; the AgentMux version for a legacy one.
128    pub version: String,
129    pub legacy: bool,
130    pub bytes: u64,
131    pub idle: Duration,
132}
133
134#[derive(Debug, Default)]
135pub struct Plan {
136    pub prunable: Vec<Candidate>,
137    pub kept: Vec<(PathBuf, Keep)>,
138}
139
140impl Plan {
141    pub fn reclaimable_bytes(&self) -> u64 {
142        self.prunable.iter().map(|c| c.bytes).sum()
143    }
144}
145
146/// Total size of the files under `dir`, never following a symlink.
147fn dir_size(dir: &Path) -> u64 {
148    let mut total = 0;
149    let mut stack = vec![dir.to_path_buf()];
150    while let Some(d) = stack.pop() {
151        let Ok(rd) = std::fs::read_dir(&d) else {
152            continue;
153        };
154        for entry in rd.flatten() {
155            let Ok(meta) = std::fs::symlink_metadata(entry.path()) else {
156                continue;
157            };
158            if meta.is_dir() {
159                stack.push(entry.path());
160            } else {
161                total += meta.len();
162            }
163        }
164    }
165    total
166}
167
168/// The latest sign of life: the newest of the last-used marker and the install
169/// marker; the directory's own mtime when neither exists.
170fn last_activity(dir: &Path) -> Option<SystemTime> {
171    [LAST_USED_MARKER, COMPLETE_MARKER]
172        .iter()
173        .filter_map(|m| {
174            std::fs::metadata(dir.join(m))
175                .and_then(|m| m.modified())
176                .ok()
177        })
178        .max()
179        .or_else(|| std::fs::metadata(dir).and_then(|m| m.modified()).ok())
180}
181
182/// Immediate subdirectories of `dir` as `(name, path)`, skipping symlinks and
183/// unsafe names (reported through `kept`).
184fn safe_children(
185    dir: &Path,
186    is_safe: fn(&str) -> bool,
187    kept: &mut Vec<(PathBuf, Keep)>,
188) -> Vec<(String, PathBuf)> {
189    let Ok(rd) = std::fs::read_dir(dir) else {
190        return Vec::new();
191    };
192    let mut out = Vec::new();
193    for entry in rd.flatten() {
194        let path = entry.path();
195        let name = entry.file_name().to_string_lossy().into_owned();
196        let Ok(meta) = std::fs::symlink_metadata(&path) else {
197            continue;
198        };
199        if meta.is_dir() && is_safe(&name) {
200            out.push((name, path));
201        } else if meta.is_dir() || meta.file_type().is_symlink() {
202            kept.push((path, Keep::Skipped));
203        }
204        // Plain files (lock files, `<pin>.lock`) are not installs.
205    }
206    out.sort();
207    out
208}
209
210/// Decide what could be removed. Reads only; nothing is touched.
211///
212/// `current_pins`: `(provider, pin)` for every provider in this build's registry.
213/// `running_version`: this AgentMux's version, whose legacy folder is kept.
214pub fn plan_prune(
215    paths: &DataPaths,
216    now: SystemTime,
217    max_idle: Duration,
218    running_version: &str,
219    current_pins: &[(String, String)],
220    in_use: &InUse,
221) -> Plan {
222    let mut plan = Plan::default();
223    let idle_of = |dir: &Path| {
224        last_activity(dir)
225            .and_then(|t| now.duration_since(t).ok())
226            .unwrap_or(Duration::ZERO) // unreadable or in the future: treat as just used
227    };
228    let decide = |plan: &mut Plan, c: Candidate, current: bool, running: bool| {
229        let keep = if current {
230            Some(Keep::CurrentPin)
231        } else if running {
232            Some(Keep::RunningVersion)
233        } else if in_use.process_commands.is_none() {
234            Some(Keep::ScanFailed)
235        } else if in_use.mentions(&c.dir) {
236            Some(Keep::InUse)
237        } else if c.idle < max_idle {
238            Some(Keep::RecentlyUsed)
239        } else {
240            None
241        };
242        match keep {
243            Some(k) => plan.kept.push((c.dir, k)),
244            None => plan.prunable.push(Candidate {
245                bytes: dir_size(&c.dir),
246                ..c
247            }),
248        }
249    };
250
251    // shared/cli/<provider>/<pin>
252    let cli_root = paths.shared_dir.join("cli");
253    let mut skipped = Vec::new();
254    for (provider, pdir) in safe_children(&cli_root, is_safe_provider_component, &mut skipped) {
255        for (pin, dir) in safe_children(&pdir, is_safe_version_component, &mut skipped) {
256            let current = current_pins
257                .iter()
258                .any(|(p, v)| *p == provider && *v == pin);
259            let idle = idle_of(&dir);
260            let c = Candidate {
261                dir,
262                provider: provider.clone(),
263                version: pin,
264                legacy: false,
265                bytes: 0,
266                idle,
267            };
268            decide(&mut plan, c, current, false);
269        }
270    }
271
272    // instances/v<version>/cli/<provider>
273    let instances = paths.home_dir.join("instances");
274    for (vname, vdir) in safe_children(&instances, is_safe_version_component, &mut skipped) {
275        let Some(version) = vname.strip_prefix('v') else {
276            continue;
277        };
278        for (provider, dir) in
279            safe_children(&vdir.join("cli"), is_safe_provider_component, &mut skipped)
280        {
281            let idle = idle_of(&dir);
282            let c = Candidate {
283                dir,
284                provider,
285                version: version.to_owned(),
286                legacy: true,
287                bytes: 0,
288                idle,
289            };
290            decide(&mut plan, c, false, version == running_version);
291        }
292    }
293    plan.kept.extend(skipped);
294    plan
295}
296
297#[derive(Debug, Default)]
298pub struct Report {
299    pub removed: Vec<(PathBuf, u64)>,
300    /// Candidates left alone at removal time, and why.
301    pub skipped: Vec<(PathBuf, String)>,
302}
303
304/// Remove what [`plan_prune`] found, re-checking each directory at the last
305/// moment: `in_use` is asked again (a CLI may have started since the plan),
306/// and a shared install's lock must be free. The completion marker goes FIRST,
307/// so a directory half removed by a crash is already "not an install" and the
308/// next install into it clears the rest.
309pub fn prune(paths: &DataPaths, plan: &Plan, in_use: &dyn Fn() -> InUse) -> Report {
310    let mut report = Report::default();
311    for c in &plan.prunable {
312        let guard = if c.legacy {
313            None
314        } else {
315            match try_lock_install(paths, &c.provider, &c.version) {
316                Ok(Some(g)) => Some(g),
317                Ok(None) => {
318                    report
319                        .skipped
320                        .push((c.dir.clone(), "install lock is held".into()));
321                    continue;
322                }
323                Err(e) => {
324                    report.skipped.push((
325                        c.dir.clone(),
326                        format!("could not take the install lock: {e}"),
327                    ));
328                    continue;
329                }
330            }
331        };
332        let now = in_use();
333        if now.process_commands.is_none() || now.mentions(&c.dir) {
334            report
335                .skipped
336                .push((c.dir.clone(), "in use, or the process scan failed".into()));
337            continue;
338        }
339        if std::fs::symlink_metadata(&c.dir)
340            .map_or(true, |m| m.file_type().is_symlink() || !m.is_dir())
341        {
342            report
343                .skipped
344                .push((c.dir.clone(), "no longer a plain directory".into()));
345            continue;
346        }
347        let _ = std::fs::remove_file(c.dir.join(COMPLETE_MARKER));
348        match std::fs::remove_dir_all(&c.dir) {
349            Ok(()) => report.removed.push((c.dir.clone(), c.bytes)),
350            Err(e) => report
351                .skipped
352                .push((c.dir.clone(), format!("remove failed: {e}"))),
353        }
354        drop(guard);
355    }
356    report
357}
358
359/// `(provider, pin)` of every provider in this build's registry that has a pin.
360pub fn current_pins() -> Vec<(String, String)> {
361    crate::backend::providers::all_providers()
362        .filter(|p| !p.pinned_version.is_empty())
363        .map(|p| (p.id.to_string(), p.pinned_version.to_string()))
364        .collect()
365}
366
367/// What running things mention, for [`plan_prune`] / [`prune`]: the commands of
368/// every block that records one (`live_commands`: open panes AND panes restored
369/// from a layout, which hold the absolute CLI path until they mount) and every
370/// running process's command line.
371pub fn gather_in_use(live_commands: &[String]) -> InUse {
372    InUse { live_commands: live_commands.to_vec(), process_commands: scan_process_commands() }
373}
374
375/// The outcome of one [`run`].
376#[derive(Debug)]
377pub struct Run {
378    pub plan: Plan,
379    /// `None` for a dry run.
380    pub report: Option<Report>,
381    /// The process scan worked. When it did not, nothing is prunable.
382    pub scan_ok: bool,
383}
384
385/// Plan, and unless `dry_run`, carry out, a prune. `dry_run` is the caller's
386/// explicit choice to delete: nothing in AgentMux calls this with `false`
387/// except a user action.
388///
389/// `only`: remove just these directories (the ones an earlier dry run listed),
390/// and only if they are still removable now. Anything that became removable
391/// since is left alone, so a removal is exactly what the user was shown.
392pub fn run(
393    paths: &DataPaths,
394    live_commands: &[String],
395    dry_run: bool,
396    only: Option<&[String]>,
397    max_idle: Duration,
398    now: SystemTime,
399) -> Run {
400    let in_use = gather_in_use(live_commands);
401    let scan_ok = in_use.process_commands.is_some();
402    let mut plan = plan_prune(paths, now, max_idle, env!("CARGO_PKG_VERSION"), &current_pins(), &in_use);
403    if let Some(only) = only {
404        plan.prunable.retain(|c| only.iter().any(|d| Path::new(d) == c.dir));
405    }
406    let report = (!dry_run).then(|| prune(paths, &plan, &|| gather_in_use(live_commands)));
407    Run { plan, report, scan_ok }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use std::fs::{self, File};
414
415    const DAY: Duration = Duration::from_secs(24 * 60 * 60);
416
417    fn paths_in(root: &Path) -> DataPaths {
418        let version_dir = root
419            .join("channels")
420            .join("c")
421            .join("versions")
422            .join("1.0.0");
423        DataPaths {
424            home_dir: root.to_path_buf(),
425            instance_dir: root.join("channels").join("c"),
426            channel: "c".to_string(),
427            data_dir: version_dir.join("data"),
428            config_dir: root.join("channels").join("c").join("config"),
429            logs_dir: version_dir.join("logs"),
430            cef_cache_dir: version_dir.join("cef-cache"),
431            agents_dir: root.join("channels").join("c").join("agents"),
432            instance_runtime_dir: version_dir.join("runtime"),
433            shared_dir: root.join("shared"),
434            mode: agentmux_common::RuntimeMode::Portable,
435        }
436    }
437
438    fn set_age(path: &Path, now: SystemTime, age: Duration) {
439        let f = File::options().write(true).open(path).unwrap();
440        f.set_modified(now - age).unwrap();
441    }
442
443    /// A finished shared install whose completion marker is `age` old.
444    fn shared_install(
445        p: &DataPaths,
446        provider: &str,
447        pin: &str,
448        now: SystemTime,
449        age: Duration,
450    ) -> PathBuf {
451        let dir = p.shared_dir.join("cli").join(provider).join(pin);
452        fs::create_dir_all(dir.join("node_modules").join(".bin")).unwrap();
453        fs::write(
454            dir.join("node_modules").join(".bin").join(provider),
455            vec![0u8; 100],
456        )
457        .unwrap();
458        fs::write(dir.join(COMPLETE_MARKER), pin).unwrap();
459        set_age(&dir.join(COMPLETE_MARKER), now, age);
460        dir
461    }
462
463    fn legacy_install(
464        p: &DataPaths,
465        version: &str,
466        provider: &str,
467        now: SystemTime,
468        age: Duration,
469    ) -> PathBuf {
470        let dir = p
471            .home_dir
472            .join("instances")
473            .join(format!("v{version}"))
474            .join("cli")
475            .join(provider);
476        fs::create_dir_all(dir.join("node_modules")).unwrap();
477        fs::write(dir.join("node_modules").join("x"), vec![0u8; 50]).unwrap();
478        // Aged through a file inside it: setting a directory's own mtime needs
479        // platform-specific handles (opening a directory fails on Windows).
480        fs::write(dir.join(COMPLETE_MARKER), "").unwrap();
481        set_age(&dir.join(COMPLETE_MARKER), now, age);
482        dir
483    }
484
485    fn scanned() -> InUse {
486        InUse {
487            live_commands: vec![],
488            process_commands: Some(vec!["/usr/bin/vim".into()]),
489        }
490    }
491    fn pins() -> Vec<(String, String)> {
492        vec![("claude".into(), "2.1.285".into())]
493    }
494    fn plan(p: &DataPaths, now: SystemTime, in_use: &InUse) -> Plan {
495        plan_prune(p, now, DEFAULT_MAX_IDLE, "0.59.1", &pins(), in_use)
496    }
497    fn dirs(c: &[Candidate]) -> Vec<PathBuf> {
498        c.iter().map(|c| c.dir.clone()).collect()
499    }
500
501    #[test]
502    fn the_current_pin_is_never_prunable_however_old() {
503        let t = tempfile::tempdir().unwrap();
504        let p = paths_in(t.path());
505        let now = SystemTime::now();
506        let cur = shared_install(&p, "claude", "2.1.285", now, 400 * DAY);
507        let pl = plan(&p, now, &scanned());
508        assert!(pl.prunable.is_empty());
509        assert!(pl.kept.contains(&(cur, Keep::CurrentPin)));
510    }
511
512    #[test]
513    fn an_old_unused_pin_is_prunable_and_a_recently_used_one_is_not() {
514        let t = tempfile::tempdir().unwrap();
515        let p = paths_in(t.path());
516        let now = SystemTime::now();
517        let old = shared_install(&p, "claude", "2.1.200", now, 90 * DAY);
518        let used = shared_install(&p, "claude", "2.1.210", now, 90 * DAY);
519        // Installed long ago, but used yesterday (another channel still runs it).
520        fs::write(used.join(LAST_USED_MARKER), "").unwrap();
521        set_age(&used.join(LAST_USED_MARKER), now, DAY);
522        let recent = shared_install(&p, "claude", "2.1.220", now, 5 * DAY);
523
524        let pl = plan(&p, now, &scanned());
525        assert_eq!(dirs(&pl.prunable), vec![old]);
526        assert!(pl.kept.contains(&(used, Keep::RecentlyUsed)));
527        assert!(pl.kept.contains(&(recent, Keep::RecentlyUsed)));
528        assert!(pl.reclaimable_bytes() > 0);
529    }
530
531    #[test]
532    fn planning_changes_nothing() {
533        let t = tempfile::tempdir().unwrap();
534        let p = paths_in(t.path());
535        let now = SystemTime::now();
536        let old = shared_install(&p, "claude", "2.1.200", now, 90 * DAY);
537        let _ = plan(&p, now, &scanned());
538        assert!(old.join(COMPLETE_MARKER).is_file());
539        assert!(old
540            .join("node_modules")
541            .join(".bin")
542            .join("claude")
543            .is_file());
544    }
545
546    #[test]
547    fn a_live_controller_or_a_running_process_under_the_dir_keeps_it() {
548        let t = tempfile::tempdir().unwrap();
549        let p = paths_in(t.path());
550        let now = SystemTime::now();
551        let a = shared_install(&p, "claude", "2.1.200", now, 90 * DAY);
552        let b = shared_install(&p, "claude", "2.1.201", now, 90 * DAY);
553        let c = shared_install(&p, "claude", "2.1.202", now, 90 * DAY);
554        let sep = std::path::MAIN_SEPARATOR;
555        let in_use = InUse {
556            live_commands: vec![format!(
557                "{}{sep}node_modules{sep}.bin{sep}claude -p",
558                a.display()
559            )],
560            process_commands: Some(vec![format!(
561                "node {}{sep}node_modules{sep}x.js",
562                b.display()
563            )]),
564        };
565        let pl = plan(&p, now, &in_use);
566        assert_eq!(dirs(&pl.prunable), vec![c]);
567        assert!(pl.kept.contains(&(a, Keep::InUse)));
568        assert!(pl.kept.contains(&(b, Keep::InUse)));
569    }
570
571    #[test]
572    fn a_version_that_is_a_prefix_of_a_running_one_is_not_considered_in_use() {
573        let t = tempfile::tempdir().unwrap();
574        let p = paths_in(t.path());
575        let now = SystemTime::now();
576        let short = shared_install(&p, "claude", "2.1.28", now, 90 * DAY);
577        let sep = std::path::MAIN_SEPARATOR;
578        let longer = p.shared_dir.join("cli").join("claude").join("2.1.285");
579        let in_use = InUse {
580            live_commands: vec![format!(
581                "{}{sep}node_modules{sep}.bin{sep}claude",
582                longer.display()
583            )],
584            process_commands: Some(vec!["x".into()]),
585        };
586        assert_eq!(dirs(&plan(&p, now, &in_use).prunable), vec![short]);
587    }
588
589    #[test]
590    fn a_failed_process_scan_keeps_everything() {
591        let t = tempfile::tempdir().unwrap();
592        let p = paths_in(t.path());
593        let now = SystemTime::now();
594        let old = shared_install(&p, "claude", "2.1.200", now, 90 * DAY);
595        let legacy = legacy_install(&p, "0.50.0", "claude", now, 90 * DAY);
596        let pl = plan(
597            &p,
598            now,
599            &InUse {
600                live_commands: vec![],
601                process_commands: None,
602            },
603        );
604        assert!(pl.prunable.is_empty());
605        assert!(pl.kept.contains(&(old, Keep::ScanFailed)));
606        assert!(pl.kept.contains(&(legacy, Keep::ScanFailed)));
607    }
608
609    #[test]
610    fn legacy_folders_go_only_when_old_and_not_the_running_version() {
611        let t = tempfile::tempdir().unwrap();
612        let p = paths_in(t.path());
613        let now = SystemTime::now();
614        let old = legacy_install(&p, "0.50.0", "claude", now, 90 * DAY);
615        let young = legacy_install(&p, "0.58.0", "claude", now, 3 * DAY);
616        let running = legacy_install(&p, "0.59.1", "claude", now, 90 * DAY);
617        let pl = plan(&p, now, &scanned());
618        assert_eq!(dirs(&pl.prunable), vec![old]);
619        assert!(pl.prunable[0].legacy);
620        assert!(pl.kept.contains(&(young, Keep::RecentlyUsed)));
621        assert!(pl.kept.contains(&(running, Keep::RunningVersion)));
622    }
623
624    #[cfg(unix)]
625    #[test]
626    fn symlinks_and_unsafe_names_are_never_touched() {
627        let t = tempfile::tempdir().unwrap();
628        let p = paths_in(t.path());
629        let now = SystemTime::now();
630        let outside = t.path().join("precious");
631        fs::create_dir_all(&outside).unwrap();
632        fs::write(outside.join("keep.txt"), "x").unwrap();
633        let provider_dir = p.shared_dir.join("cli").join("claude");
634        fs::create_dir_all(&provider_dir).unwrap();
635        std::os::unix::fs::symlink(&outside, provider_dir.join("2.1.100")).unwrap();
636        fs::create_dir_all(provider_dir.join("not a version")).unwrap();
637        let pl = plan(&p, now, &scanned());
638        assert!(pl.prunable.is_empty());
639        assert!(pl
640            .kept
641            .iter()
642            .any(|(d, k)| d.ends_with("2.1.100") && *k == Keep::Skipped));
643        assert!(pl
644            .kept
645            .iter()
646            .any(|(d, k)| d.ends_with("not a version") && *k == Keep::Skipped));
647        assert!(outside.join("keep.txt").is_file());
648    }
649
650    #[test]
651    fn prune_removes_the_marker_first_and_leaves_the_lock_file() {
652        let t = tempfile::tempdir().unwrap();
653        let p = paths_in(t.path());
654        let now = SystemTime::now();
655        let old = shared_install(&p, "claude", "2.1.200", now, 90 * DAY);
656        let keep = shared_install(&p, "claude", "2.1.285", now, 90 * DAY);
657        let pl = plan(&p, now, &scanned());
658        let r = prune(&p, &pl, &scanned);
659        assert_eq!(r.removed.len(), 1);
660        assert!(r.removed[0].1 > 0);
661        assert!(!old.exists());
662        assert!(keep.join(COMPLETE_MARKER).is_file());
663    }
664
665    #[test]
666    fn a_held_install_lock_blocks_removal() {
667        let t = tempfile::tempdir().unwrap();
668        let p = paths_in(t.path());
669        let now = SystemTime::now();
670        let old = shared_install(&p, "claude", "2.1.200", now, 90 * DAY);
671        let pl = plan(&p, now, &scanned());
672        let _held = try_lock_install(&p, "claude", "2.1.200").unwrap().unwrap();
673        let r = prune(&p, &pl, &scanned);
674        assert!(r.removed.is_empty());
675        assert!(r.skipped[0].1.contains("lock"));
676        assert!(old.join(COMPLETE_MARKER).is_file());
677    }
678
679    #[test]
680    fn prune_asks_again_at_removal_time() {
681        let t = tempfile::tempdir().unwrap();
682        let p = paths_in(t.path());
683        let now = SystemTime::now();
684        let old = shared_install(&p, "claude", "2.1.200", now, 90 * DAY);
685        let pl = plan(&p, now, &scanned());
686        // A CLI started between the plan and the removal:
687        let sep = std::path::MAIN_SEPARATOR;
688        let started = || InUse {
689            live_commands: vec![format!(
690                "{}{sep}node_modules{sep}.bin{sep}claude",
691                old.display()
692            )],
693            process_commands: Some(vec!["x".into()]),
694        };
695        assert!(prune(&p, &pl, &started).removed.is_empty());
696        // ...or the scan broke:
697        let broken = || InUse {
698            live_commands: vec![],
699            process_commands: None,
700        };
701        assert!(prune(&p, &pl, &broken).removed.is_empty());
702        assert!(old.join(COMPLETE_MARKER).is_file());
703    }
704
705    #[test]
706    fn record_use_writes_once_per_hour() {
707        let t = tempfile::tempdir().unwrap();
708        let dir = t.path().join("d");
709        fs::create_dir_all(&dir).unwrap();
710        let now = SystemTime::now();
711        record_use(&dir, now);
712        let first = fs::metadata(dir.join(LAST_USED_MARKER))
713            .unwrap()
714            .modified()
715            .unwrap();
716        // 10 minutes later: throttled, mtime unchanged (pretend the file is old to prove it).
717        set_age(&dir.join(LAST_USED_MARKER), now, Duration::from_secs(600));
718        record_use(&dir, now);
719        let still = fs::metadata(dir.join(LAST_USED_MARKER))
720            .unwrap()
721            .modified()
722            .unwrap();
723        assert_eq!(still, now - Duration::from_secs(600));
724        // 2 hours later: written.
725        set_age(
726            &dir.join(LAST_USED_MARKER),
727            now,
728            Duration::from_secs(2 * 3600),
729        );
730        record_use(&dir, now);
731        let after = fs::metadata(dir.join(LAST_USED_MARKER))
732            .unwrap()
733            .modified()
734            .unwrap();
735        assert!(after > now - Duration::from_secs(3600));
736        let _ = first;
737    }
738
739    #[cfg(unix)]
740    #[test]
741    fn run_is_a_dry_run_unless_told_otherwise_and_removes_only_on_an_explicit_request() {
742        let t = tempfile::tempdir().unwrap();
743        let p = paths_in(t.path());
744        let now = SystemTime::now();
745        let old = shared_install(&p, "claude", "0.0.1", now, 90 * DAY);
746        let recent = shared_install(&p, "claude", "0.0.2", now, DAY);
747
748        let dry = run(&p, &[], true, None, DEFAULT_MAX_IDLE, now);
749        assert!(dry.scan_ok, "ps works here");
750        assert_eq!(dirs(&dry.plan.prunable), vec![old.clone()]);
751        assert!(dry.report.is_none());
752        assert!(old.join(COMPLETE_MARKER).is_file(), "a dry run removes nothing");
753
754        // a pane restored from a layout names the old install: kept
755        let sep = std::path::MAIN_SEPARATOR;
756        let named = format!("{}{sep}node_modules{sep}.bin{sep}claude -p", old.display());
757        let kept = run(&p, &[named], false, None, DEFAULT_MAX_IDLE, now);
758        assert!(kept.plan.prunable.is_empty());
759        assert!(old.join(COMPLETE_MARKER).is_file());
760
761        let real = run(&p, &[], false, None, DEFAULT_MAX_IDLE, now);
762        let report = real.report.expect("a real run reports");
763        assert_eq!(report.removed.len(), 1);
764        assert!(!old.exists());
765        assert!(recent.join(COMPLETE_MARKER).is_file(), "recently used stays");
766    }
767
768    #[cfg(unix)]
769    #[test]
770    fn a_removal_is_exactly_the_list_the_user_was_shown() {
771        let t = tempfile::tempdir().unwrap();
772        let p = paths_in(t.path());
773        let now = SystemTime::now();
774        let shown = shared_install(&p, "claude", "0.0.1", now, 90 * DAY);
775        // became removable after the user's Check (e.g. a pane closed meanwhile)
776        let later = shared_install(&p, "claude", "0.0.2", now, 95 * DAY);
777
778        let only = vec![shown.to_string_lossy().into_owned()];
779        let r = run(&p, &[], false, Some(&only), DEFAULT_MAX_IDLE, now);
780        assert_eq!(r.report.unwrap().removed.len(), 1);
781        assert!(!shown.exists());
782        assert!(later.join(COMPLETE_MARKER).is_file(), "not shown, so not touched");
783
784        // a listed dir that is no longer removable (now in use) is not removed either
785        let sep = std::path::MAIN_SEPARATOR;
786        let named = format!("{}{sep}node_modules{sep}.bin{sep}claude", later.display());
787        let only = vec![later.to_string_lossy().into_owned()];
788        let r = run(&p, &[named], false, Some(&only), DEFAULT_MAX_IDLE, now);
789        assert!(r.report.unwrap().removed.is_empty());
790        assert!(later.join(COMPLETE_MARKER).is_file());
791    }
792
793    #[test]
794    fn every_registry_pin_is_a_current_pin() {
795        let pins = current_pins();
796        assert!(pins.iter().any(|(p, v)| p == "claude" && !v.is_empty()));
797        assert!(pins.iter().all(|(p, v)| !p.is_empty() && !v.is_empty()));
798    }
799
800    #[cfg(unix)]
801    #[test]
802    fn the_process_scan_sees_this_process() {
803        let seen = scan_process_commands().expect("ps works here");
804        assert!(!seen.is_empty());
805    }
806}