Skip to main content

agent_runtime/
live_surface.rs

1//! Shared live runtime-home surface helpers.
2//!
3//! `audit-drift extra` and `prune-stale` must agree on which install-map
4//! destinations are expected and which runtime-home roots are safe to scan.
5
6use crate::install::link_map::{EntryKind, LinkMap};
7use std::collections::BTreeSet;
8use std::fs;
9use std::path::{Component, Path, PathBuf};
10
11pub fn expected_live_paths(source_root: &Path, link_map: &LinkMap) -> BTreeSet<PathBuf> {
12    let mut out = BTreeSet::new();
13    for entry in &link_map.entries {
14        let Some(dest) = clean_rel_path(&entry.destination) else {
15            continue;
16        };
17        match entry.kind {
18            EntryKind::SymlinkedFile if entry.recursive => {
19                let Some(source) = entry.source.as_deref().and_then(clean_rel_path) else {
20                    continue;
21                };
22                let source_abs = source_root.join(source);
23                if source_abs.is_dir() {
24                    for rel in source_files(&source_abs) {
25                        out.insert(dest.join(rel));
26                    }
27                } else if source_abs.exists() {
28                    out.insert(dest);
29                }
30            }
31            EntryKind::SymlinkedFile
32            | EntryKind::PluginManifestCopy
33            | EntryKind::BackedUpOnReplace
34            | EntryKind::ManagedBlock => {
35                out.insert(dest);
36            }
37        }
38    }
39    out
40}
41
42pub fn scan_roots(link_map: &LinkMap) -> BTreeSet<PathBuf> {
43    let mut out = BTreeSet::new();
44    for entry in &link_map.entries {
45        let Some(dest) = clean_rel_path(&entry.destination) else {
46            continue;
47        };
48        match entry.kind {
49            EntryKind::SymlinkedFile if entry.recursive => {
50                out.insert(dest);
51            }
52            EntryKind::SymlinkedFile
53            | EntryKind::PluginManifestCopy
54            | EntryKind::BackedUpOnReplace => {
55                if let Some(parent) = dest.parent()
56                    && !parent.as_os_str().is_empty()
57                {
58                    out.insert(parent.to_path_buf());
59                }
60            }
61            EntryKind::ManagedBlock => {}
62        }
63    }
64    out
65}
66
67pub fn live_files_under_roots(
68    live_home: &Path,
69    roots: &BTreeSet<PathBuf>,
70) -> Result<BTreeSet<PathBuf>, std::io::Error> {
71    let mut out = BTreeSet::new();
72    for rel_root in roots {
73        collect_live_files(live_home, rel_root, &mut out)?;
74    }
75    Ok(out)
76}
77
78pub fn clean_rel_path(path: impl AsRef<str>) -> Option<PathBuf> {
79    let path = Path::new(path.as_ref());
80    if path.as_os_str().is_empty() || path.is_absolute() {
81        return None;
82    }
83    if path
84        .components()
85        .any(|component| !matches!(component, Component::Normal(_)))
86    {
87        return None;
88    }
89    Some(path.to_path_buf())
90}
91
92pub fn ignored_live_file(path: &Path) -> bool {
93    path.file_name().and_then(|name| name.to_str()) == Some(".DS_Store")
94}
95
96fn source_files(root: &Path) -> BTreeSet<PathBuf> {
97    let mut out = BTreeSet::new();
98    collect_source_files(root, root, &mut out);
99    out
100}
101
102fn collect_source_files(root: &Path, dir: &Path, out: &mut BTreeSet<PathBuf>) {
103    let Ok(entries) = fs::read_dir(dir) else {
104        return;
105    };
106    for entry in entries.flatten() {
107        let path = entry.path();
108        let Ok(kind) = entry.file_type() else {
109            continue;
110        };
111        if kind.is_dir() {
112            collect_source_files(root, &path, out);
113        } else if let Ok(rel) = path.strip_prefix(root) {
114            out.insert(rel.to_path_buf());
115        }
116    }
117}
118
119fn collect_live_files(
120    live_home: &Path,
121    rel: &Path,
122    out: &mut BTreeSet<PathBuf>,
123) -> Result<(), std::io::Error> {
124    let path = live_home.join(rel);
125    let Ok(meta) = fs::symlink_metadata(&path) else {
126        return Ok(());
127    };
128    if meta.file_type().is_symlink() || meta.is_file() {
129        out.insert(rel.to_path_buf());
130        return Ok(());
131    }
132    if !meta.is_dir() {
133        return Ok(());
134    }
135
136    let entries = fs::read_dir(&path)?;
137    for entry in entries {
138        let entry = entry?;
139        collect_live_files(live_home, &rel.join(entry.file_name()), out)?;
140    }
141    Ok(())
142}