Skip to main content

agent_runtime/
prune_stale.rs

1//! `agent-runtime prune-stale` body.
2//!
3//! The command scans only install-map-owned runtime-home roots, then removes
4//! stale symlinks whose targets are lexically under the selected source root.
5//! Ambiguous user-owned files, non-empty directories, and foreign symlinks are
6//! reported but never deleted.
7
8use crate::install::link_map::{LinkMap, LinkMapError};
9use crate::install::overlay::{self, LinkMapOverlay, OverlaySummary};
10use crate::install::plan::{InstallPlan, PlanAction, PlanError};
11use crate::live_surface;
12use serde::Serialize;
13use std::collections::BTreeSet;
14use std::fs;
15use std::path::{Component, Path, PathBuf};
16use thiserror::Error;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum Mode {
21    DryRun,
22    Apply,
23}
24
25impl Mode {
26    pub fn label(self) -> &'static str {
27        match self {
28            Mode::DryRun => "dry-run",
29            Mode::Apply => "apply",
30        }
31    }
32}
33
34#[derive(Debug, Clone)]
35pub struct PruneOptions {
36    pub overlay_enabled: bool,
37    pub overlay_path: Option<PathBuf>,
38}
39
40impl Default for PruneOptions {
41    fn default() -> Self {
42        Self {
43            overlay_enabled: true,
44            overlay_path: None,
45        }
46    }
47}
48
49#[derive(Debug, Error)]
50pub enum PruneError {
51    #[error("link-map: {0}")]
52    LinkMap(#[from] LinkMapError),
53    #[error("plan: {0}")]
54    Plan(#[from] PlanError),
55    #[error("io error at `{path}`: {source}")]
56    Io {
57        path: PathBuf,
58        #[source]
59        source: std::io::Error,
60    },
61}
62
63#[derive(Debug, Serialize)]
64pub struct PruneOutcome {
65    pub product: String,
66    pub source_root: PathBuf,
67    pub live_home: PathBuf,
68    pub mode: Mode,
69    pub changes: Vec<PruneChange>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub overlay: Option<OverlaySummary>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
75#[serde(tag = "kind", rename_all = "kebab-case")]
76pub enum PruneChange {
77    WouldRemoveSymlink {
78        rel_path: PathBuf,
79        path: PathBuf,
80        target: PathBuf,
81    },
82    RemovedSymlink {
83        rel_path: PathBuf,
84        path: PathBuf,
85        target: PathBuf,
86    },
87    NoOpSymlink {
88        rel_path: PathBuf,
89        path: PathBuf,
90        target: PathBuf,
91    },
92    WouldRemoveEmptyDirectory {
93        rel_path: PathBuf,
94        path: PathBuf,
95    },
96    RemovedEmptyDirectory {
97        rel_path: PathBuf,
98        path: PathBuf,
99    },
100    NoOpEmptyDirectory {
101        rel_path: PathBuf,
102        path: PathBuf,
103    },
104    SkippedForeignSymlink {
105        rel_path: PathBuf,
106        path: PathBuf,
107        target: PathBuf,
108    },
109    SkippedRegularFile {
110        rel_path: PathBuf,
111        path: PathBuf,
112    },
113    SkippedNonEmptyDirectory {
114        rel_path: PathBuf,
115        path: PathBuf,
116    },
117}
118
119impl PruneChange {
120    pub fn is_change(&self) -> bool {
121        matches!(
122            self,
123            PruneChange::WouldRemoveSymlink { .. }
124                | PruneChange::RemovedSymlink { .. }
125                | PruneChange::WouldRemoveEmptyDirectory { .. }
126                | PruneChange::RemovedEmptyDirectory { .. }
127        )
128    }
129
130    pub fn is_skip(&self) -> bool {
131        matches!(
132            self,
133            PruneChange::SkippedForeignSymlink { .. }
134                | PruneChange::SkippedRegularFile { .. }
135                | PruneChange::SkippedNonEmptyDirectory { .. }
136        )
137    }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
141enum Candidate {
142    Symlink {
143        rel_path: PathBuf,
144        path: PathBuf,
145        target: PathBuf,
146    },
147    EmptyDirectory {
148        rel_path: PathBuf,
149        path: PathBuf,
150    },
151    SkippedForeignSymlink {
152        rel_path: PathBuf,
153        path: PathBuf,
154        target: PathBuf,
155    },
156    SkippedRegularFile {
157        rel_path: PathBuf,
158        path: PathBuf,
159    },
160    SkippedNonEmptyDirectory {
161        rel_path: PathBuf,
162        path: PathBuf,
163    },
164}
165
166pub fn run(
167    product: &str,
168    source_root: &Path,
169    live_home: &Path,
170    mode: Mode,
171    options: &PruneOptions,
172) -> Result<PruneOutcome, PruneError> {
173    let mut link_map = LinkMap::load(source_root, product)?;
174    let overlay = merge_overlay(&mut link_map, source_root, options)?;
175    let plan = InstallPlan::build(product, source_root, live_home, Path::new(""), &link_map)?;
176    let expected = expected_paths_from_plan(live_home, &plan);
177    let scan_roots = live_surface::scan_roots(&link_map);
178    let candidates = collect_candidates(live_home, source_root, &expected, &scan_roots)?;
179    let changes = match mode {
180        Mode::DryRun => candidates.into_iter().map(dry_run_change).collect(),
181        Mode::Apply => apply_candidates(candidates)?,
182    };
183
184    Ok(PruneOutcome {
185        product: product.to_string(),
186        source_root: source_root.to_path_buf(),
187        live_home: live_home.to_path_buf(),
188        mode,
189        changes,
190        overlay,
191    })
192}
193
194fn merge_overlay(
195    link_map: &mut LinkMap,
196    source_root: &Path,
197    options: &PruneOptions,
198) -> Result<Option<OverlaySummary>, PruneError> {
199    if !options.overlay_enabled {
200        return Ok(None);
201    }
202    let overlay_opt = match options.overlay_path.as_deref() {
203        Some(path) => LinkMapOverlay::load_from(path)?,
204        None => LinkMapOverlay::load_optional(source_root)?,
205    };
206    match overlay_opt {
207        Some(overlay) => {
208            let summary = overlay::apply(link_map, &overlay)?;
209            Ok(Some(summary))
210        }
211        None => Ok(None),
212    }
213}
214
215fn expected_paths_from_plan(live_home: &Path, plan: &InstallPlan) -> BTreeSet<PathBuf> {
216    let mut out = BTreeSet::new();
217    for action in &plan.actions {
218        let PlanAction::Symlink { dest, .. } = action else {
219            continue;
220        };
221        if let Ok(rel) = dest.strip_prefix(live_home) {
222            out.insert(rel.to_path_buf());
223        }
224    }
225    out
226}
227
228fn collect_candidates(
229    live_home: &Path,
230    source_root: &Path,
231    expected: &BTreeSet<PathBuf>,
232    scan_roots: &BTreeSet<PathBuf>,
233) -> Result<Vec<Candidate>, PruneError> {
234    let mut candidates = Vec::new();
235    for rel_root in scan_roots {
236        collect_dir(
237            live_home,
238            source_root,
239            expected,
240            rel_root,
241            rel_root,
242            true,
243            &mut candidates,
244        )?;
245    }
246    candidates.sort_by(candidate_order);
247    candidates.dedup();
248    Ok(candidates)
249}
250
251fn collect_dir(
252    live_home: &Path,
253    source_root: &Path,
254    expected: &BTreeSet<PathBuf>,
255    scan_root: &Path,
256    rel_dir: &Path,
257    is_scan_root: bool,
258    candidates: &mut Vec<Candidate>,
259) -> Result<bool, PruneError> {
260    let dir = live_home.join(rel_dir);
261    let Ok(meta) = fs::symlink_metadata(&dir) else {
262        return Ok(false);
263    };
264    if !meta.is_dir() || meta.file_type().is_symlink() {
265        return Ok(false);
266    }
267
268    let mut entries = Vec::new();
269    for entry in fs::read_dir(&dir).map_err(|source| PruneError::Io {
270        path: dir.clone(),
271        source,
272    })? {
273        let entry = entry.map_err(|source| PruneError::Io {
274            path: dir.clone(),
275            source,
276        })?;
277        entries.push(entry.file_name());
278    }
279    entries.sort();
280
281    let mut all_children_removable = true;
282    let mut saw_removable_child = false;
283    for name in entries {
284        let child_rel = rel_dir.join(&name);
285        let child_abs = live_home.join(&child_rel);
286        if expected.contains(&child_rel) || live_surface::ignored_live_file(&child_rel) {
287            all_children_removable = false;
288            continue;
289        }
290        let Ok(child_meta) = fs::symlink_metadata(&child_abs) else {
291            continue;
292        };
293        if child_meta.file_type().is_symlink() {
294            let target = fs::read_link(&child_abs).map_err(|source| PruneError::Io {
295                path: child_abs.clone(),
296                source,
297            })?;
298            if symlink_target_is_owned(source_root, &target) {
299                candidates.push(Candidate::Symlink {
300                    rel_path: child_rel,
301                    path: child_abs,
302                    target,
303                });
304                saw_removable_child = true;
305            } else {
306                candidates.push(Candidate::SkippedForeignSymlink {
307                    rel_path: child_rel,
308                    path: child_abs,
309                    target,
310                });
311                all_children_removable = false;
312            }
313        } else if child_meta.is_file() {
314            candidates.push(Candidate::SkippedRegularFile {
315                rel_path: child_rel,
316                path: child_abs,
317            });
318            all_children_removable = false;
319        } else if child_meta.is_dir() {
320            let child_removable = collect_dir(
321                live_home,
322                source_root,
323                expected,
324                scan_root,
325                &child_rel,
326                false,
327                candidates,
328            )?;
329            if !child_removable {
330                all_children_removable = false;
331            } else {
332                saw_removable_child = true;
333            }
334        } else {
335            all_children_removable = false;
336        }
337    }
338
339    if is_scan_root {
340        return Ok(false);
341    }
342
343    if all_children_removable && saw_removable_child {
344        candidates.push(Candidate::EmptyDirectory {
345            rel_path: rel_dir.to_path_buf(),
346            path: dir,
347        });
348        return Ok(true);
349    }
350
351    if !has_expected_descendant(expected, rel_dir) && rel_dir.starts_with(scan_root) {
352        candidates.push(Candidate::SkippedNonEmptyDirectory {
353            rel_path: rel_dir.to_path_buf(),
354            path: dir,
355        });
356    }
357    Ok(false)
358}
359
360fn has_expected_descendant(expected: &BTreeSet<PathBuf>, rel_dir: &Path) -> bool {
361    expected.iter().any(|path| path.starts_with(rel_dir))
362}
363
364fn dry_run_change(candidate: Candidate) -> PruneChange {
365    match candidate {
366        Candidate::Symlink {
367            rel_path,
368            path,
369            target,
370        } => PruneChange::WouldRemoveSymlink {
371            rel_path,
372            path,
373            target,
374        },
375        Candidate::EmptyDirectory { rel_path, path } => {
376            PruneChange::WouldRemoveEmptyDirectory { rel_path, path }
377        }
378        Candidate::SkippedForeignSymlink {
379            rel_path,
380            path,
381            target,
382        } => PruneChange::SkippedForeignSymlink {
383            rel_path,
384            path,
385            target,
386        },
387        Candidate::SkippedRegularFile { rel_path, path } => {
388            PruneChange::SkippedRegularFile { rel_path, path }
389        }
390        Candidate::SkippedNonEmptyDirectory { rel_path, path } => {
391            PruneChange::SkippedNonEmptyDirectory { rel_path, path }
392        }
393    }
394}
395
396fn apply_candidates(candidates: Vec<Candidate>) -> Result<Vec<PruneChange>, PruneError> {
397    let mut symlinks = Vec::new();
398    let mut dirs = Vec::new();
399    let mut skips = Vec::new();
400    for candidate in candidates {
401        match candidate {
402            Candidate::Symlink { .. } => symlinks.push(candidate),
403            Candidate::EmptyDirectory { .. } => dirs.push(candidate),
404            Candidate::SkippedForeignSymlink { .. }
405            | Candidate::SkippedRegularFile { .. }
406            | Candidate::SkippedNonEmptyDirectory { .. } => skips.push(dry_run_change(candidate)),
407        }
408    }
409    dirs.sort_by(|a, b| {
410        candidate_depth(b)
411            .cmp(&candidate_depth(a))
412            .then_with(|| candidate_order(a, b))
413    });
414
415    let mut changes = Vec::new();
416    for candidate in symlinks {
417        let Candidate::Symlink {
418            rel_path,
419            path,
420            target,
421        } = candidate
422        else {
423            unreachable!();
424        };
425        match fs::remove_file(&path) {
426            Ok(()) => changes.push(PruneChange::RemovedSymlink {
427                rel_path,
428                path,
429                target,
430            }),
431            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
432                changes.push(PruneChange::NoOpSymlink {
433                    rel_path,
434                    path,
435                    target,
436                });
437            }
438            Err(source) => return Err(PruneError::Io { path, source }),
439        }
440    }
441    for candidate in dirs {
442        let Candidate::EmptyDirectory { rel_path, path } = candidate else {
443            unreachable!();
444        };
445        match fs::remove_dir(&path) {
446            Ok(()) => changes.push(PruneChange::RemovedEmptyDirectory { rel_path, path }),
447            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
448                changes.push(PruneChange::NoOpEmptyDirectory { rel_path, path });
449            }
450            Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => {
451                changes.push(PruneChange::SkippedNonEmptyDirectory { rel_path, path });
452            }
453            Err(source) => return Err(PruneError::Io { path, source }),
454        }
455    }
456    changes.extend(skips);
457    changes.sort_by(change_order);
458    Ok(changes)
459}
460
461fn candidate_depth(candidate: &Candidate) -> usize {
462    candidate_rel_path(candidate).components().count()
463}
464
465fn candidate_order(a: &Candidate, b: &Candidate) -> std::cmp::Ordering {
466    candidate_rel_path(a)
467        .cmp(candidate_rel_path(b))
468        .then_with(|| candidate_rank(a).cmp(&candidate_rank(b)))
469}
470
471fn candidate_rank(candidate: &Candidate) -> u8 {
472    match candidate {
473        Candidate::Symlink { .. } => 0,
474        Candidate::EmptyDirectory { .. } => 1,
475        Candidate::SkippedForeignSymlink { .. } => 2,
476        Candidate::SkippedRegularFile { .. } => 3,
477        Candidate::SkippedNonEmptyDirectory { .. } => 4,
478    }
479}
480
481fn candidate_rel_path(candidate: &Candidate) -> &Path {
482    match candidate {
483        Candidate::Symlink { rel_path, .. }
484        | Candidate::EmptyDirectory { rel_path, .. }
485        | Candidate::SkippedForeignSymlink { rel_path, .. }
486        | Candidate::SkippedRegularFile { rel_path, .. }
487        | Candidate::SkippedNonEmptyDirectory { rel_path, .. } => rel_path,
488    }
489}
490
491fn change_order(a: &PruneChange, b: &PruneChange) -> std::cmp::Ordering {
492    change_rel_path(a)
493        .cmp(change_rel_path(b))
494        .then_with(|| change_rank(a).cmp(&change_rank(b)))
495}
496
497fn change_rank(change: &PruneChange) -> u8 {
498    match change {
499        PruneChange::WouldRemoveSymlink { .. } | PruneChange::RemovedSymlink { .. } => 0,
500        PruneChange::NoOpSymlink { .. } => 1,
501        PruneChange::WouldRemoveEmptyDirectory { .. }
502        | PruneChange::RemovedEmptyDirectory { .. } => 2,
503        PruneChange::NoOpEmptyDirectory { .. } => 3,
504        PruneChange::SkippedForeignSymlink { .. } => 4,
505        PruneChange::SkippedRegularFile { .. } => 5,
506        PruneChange::SkippedNonEmptyDirectory { .. } => 6,
507    }
508}
509
510fn change_rel_path(change: &PruneChange) -> &Path {
511    match change {
512        PruneChange::WouldRemoveSymlink { rel_path, .. }
513        | PruneChange::RemovedSymlink { rel_path, .. }
514        | PruneChange::NoOpSymlink { rel_path, .. }
515        | PruneChange::WouldRemoveEmptyDirectory { rel_path, .. }
516        | PruneChange::RemovedEmptyDirectory { rel_path, .. }
517        | PruneChange::NoOpEmptyDirectory { rel_path, .. }
518        | PruneChange::SkippedForeignSymlink { rel_path, .. }
519        | PruneChange::SkippedRegularFile { rel_path, .. }
520        | PruneChange::SkippedNonEmptyDirectory { rel_path, .. } => rel_path,
521    }
522}
523
524fn symlink_target_is_owned(source_root: &Path, target: &Path) -> bool {
525    if !target.is_absolute() {
526        return false;
527    }
528    let source_root = normalize_absolute_path(source_root);
529    let target = normalize_absolute_path(target);
530    target.starts_with(source_root)
531}
532
533fn normalize_absolute_path(path: &Path) -> PathBuf {
534    let mut out = PathBuf::new();
535    for component in path.components() {
536        match component {
537            Component::Prefix(prefix) => out.push(prefix.as_os_str()),
538            Component::RootDir => out.push(Path::new("/")),
539            Component::CurDir => {}
540            Component::ParentDir => {
541                out.pop();
542            }
543            Component::Normal(part) => out.push(part),
544        }
545    }
546    out
547}