1use 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
35pub const LAST_USED_MARKER: &str = ".agentmux-last-used";
37
38pub const RECORD_USE_EVERY: Duration = Duration::from_secs(60 * 60);
41
42pub const DEFAULT_MAX_IDLE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
44
45pub 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 if let Ok(file) = std::fs::File::options().create(true).write(true).open(&marker) {
62 let _ = file.set_modified(now);
63 }
64}
65
66#[derive(Debug, Clone, Default)]
68pub struct InUse {
69 pub live_commands: Vec<String>,
72 pub process_commands: Option<Vec<String>>,
76}
77
78impl InUse {
79 fn mentions(&self, dir: &Path) -> bool {
81 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
91pub 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 (!lines.is_empty()).then_some(lines)
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum Keep {
113 CurrentPin,
114 RecentlyUsed,
115 InUse,
116 ScanFailed,
118 RunningVersion,
119 Skipped,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct Candidate {
125 pub dir: PathBuf,
126 pub provider: String,
127 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
146fn 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
168fn 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
182fn 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 }
206 out.sort();
207 out
208}
209
210pub 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) };
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 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 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 pub skipped: Vec<(PathBuf, String)>,
302}
303
304pub 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
359pub 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
367pub fn gather_in_use(live_commands: &[String]) -> InUse {
372 InUse { live_commands: live_commands.to_vec(), process_commands: scan_process_commands() }
373}
374
375#[derive(Debug)]
377pub struct Run {
378 pub plan: Plan,
379 pub report: Option<Report>,
381 pub scan_ok: bool,
383}
384
385pub 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"), ¤t_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 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 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 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 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 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 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 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 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 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 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}