Skip to main content

flint_lean/
scan.rs

1//! The barrier's scan-diff (plan ยง2.1 step 2).
2//!
3//! A full walk against the PERSISTED baseline โ€” never a re-seeded
4//! bucket manifest. Deletion basis: a path is delete-eligible only if
5//! it is absent in THIS scan AND was absent in the PREVIOUS scan AND is
6//! present in our own baseline (two-consecutive-scans: the
7//! rename-vs-walk race guard โ€” a directory renamed mid-walk can appear
8//! in neither pass of one readdir, and that must never read as mass
9//! deletion).
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::Path;
13
14use super::{state::Baseline, LeanResult, CONTROL_DIR, STATE_DIR};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ScanEntry {
18    pub size: u64,
19    pub mtime_unix: i64,
20    pub mode: u32,
21}
22
23/// Walk the workspace. Skips the state dir, symlinks (v1 non-goal, as
24/// the tier manifest's), and anything unreadable (reported upstream by
25/// the barrier as a warning, not a wedge).
26pub fn scan(root: &Path) -> LeanResult<BTreeMap<String, ScanEntry>> {
27    let mut out = BTreeMap::new();
28    walk(root, root, &mut out)?;
29    Ok(out)
30}
31
32fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, ScanEntry>) -> LeanResult<()> {
33    for entry in std::fs::read_dir(dir)? {
34        let entry = entry?;
35        let path = entry.path();
36        let name = entry.file_name();
37        if dir == root {
38            let n = name.to_string_lossy();
39            // The state dir and the control namespace (boundary-verbs
40            // plan D0). Without the second exclusion `.flint/publish`
41            // is live ammunition: an ordinary regular file, scanned and
42            // PUBLISHED to `<prefix>/files/.flint/publish`.
43            if n == STATE_DIR || n == CONTROL_DIR {
44                continue;
45            }
46        }
47        let meta = std::fs::symlink_metadata(&path)?;
48        if meta.file_type().is_symlink() {
49            continue;
50        }
51        if meta.is_dir() {
52            walk(root, &path, out)?;
53        } else if meta.is_file() {
54            let rel = path
55                .strip_prefix(root)
56                .expect("walk stays under root")
57                .to_string_lossy()
58                .replace('\\', "/");
59            #[cfg(unix)]
60            let mode = {
61                use std::os::unix::fs::MetadataExt;
62                meta.mode()
63            };
64            #[cfg(not(unix))]
65            let mode = 0o644;
66            let mtime_unix = meta
67                .modified()
68                .ok()
69                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
70                .map(|d| d.as_secs() as i64)
71                .unwrap_or(0);
72            out.insert(rel, ScanEntry { size: meta.len(), mtime_unix, mode });
73        }
74    }
75    Ok(())
76}
77
78#[derive(Debug, Default)]
79pub struct Classified {
80    /// Changed or new vs the baseline (by size/mtime; mtime-granularity
81    /// evasion is a stated v1 residual).
82    pub uploads: BTreeSet<String>,
83    /// Absent this scan AND the previous scan, present in the baseline.
84    pub deletes: BTreeSet<String>,
85    /// Absent this scan but present in the previous one: NOT yet
86    /// delete-eligible (first absence).
87    pub first_absence: BTreeSet<String>,
88}
89
90/// Legacy citations under the reserved control namespace (boundary-verbs
91/// plan D0.2). A workspace that legally published `files/.flint/...`
92/// under a pre-D0 syncer has those paths in its baseline; the new scan
93/// skips them, so two consecutive scans would classify them absent and
94/// publish their DELETION. An upgrade must never delete data: they are
95/// carried forward frozen โ€” never re-uploaded, never deleted by us.
96pub fn is_control_path(path: &str) -> bool {
97    path == CONTROL_DIR || path.strip_prefix(CONTROL_DIR).map(|r| r.starts_with('/')).unwrap_or(false)
98}
99
100pub fn classify(scan: &BTreeMap<String, ScanEntry>, baseline: &Baseline) -> Classified {
101    let mut c = Classified::default();
102    for (path, s) in scan {
103        match baseline.entries.get(path) {
104            None => {
105                c.uploads.insert(path.clone());
106            }
107            Some(b) => {
108                if b.size != s.size || b.mtime_unix != s.mtime_unix {
109                    c.uploads.insert(path.clone());
110                }
111            }
112        }
113    }
114    for path in baseline.entries.keys() {
115        if scan.contains_key(path) {
116            continue;
117        }
118        if is_control_path(path) {
119            // Frozen legacy citation (D0.2): absent from every scan by
120            // construction now, and NEVER delete-eligible.
121            continue;
122        }
123        if baseline.prev_scan.contains(path) {
124            // Present a scan ago: first observed absence.
125            c.first_absence.insert(path.clone());
126        } else {
127            c.deletes.insert(path.clone());
128        }
129    }
130    c
131}