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    /// The sub-second part of mtime. Compared only against a baseline
21    /// that recorded one (review 2026-09-12, atomicity-4: a same-size
22    /// rewrite inside the recorded second was invisible forever).
23    pub mtime_nanos: u32,
24    pub mode: u32,
25}
26
27/// The consume's temp sibling (`safefs`): a crash between its create and
28/// its rename leaves it as a regular file in the tree, and the scan used
29/// to publish it as data (review 2026-09-12, atomicity-7).
30pub const TMP_SUFFIX: &str = ".flint-sync-tmp";
31
32/// Whether a file's stat differs from its baseline stamp. Size and
33/// seconds always; nanoseconds only when the baseline recorded them — a
34/// baseline written before nanoseconds were stamped has `None`, and
35/// comparing against a missing value would re-upload every file once.
36pub fn stat_changed(b_size: u64, b_mtime: i64, b_nanos: Option<u32>, size: u64, mtime: i64, nanos: u32) -> bool {
37    b_size != size || b_mtime != mtime || b_nanos.map(|n| n != nanos).unwrap_or(false)
38}
39
40/// Walk the workspace. Skips the state dir, symlinks (v1 non-goal, as
41/// the tier manifest's), and anything unreadable (reported upstream by
42/// the barrier as a warning, not a wedge).
43pub fn scan(root: &Path) -> LeanResult<BTreeMap<String, ScanEntry>> {
44    let mut out = BTreeMap::new();
45    walk(root, root, &mut out)?;
46    Ok(out)
47}
48
49fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, ScanEntry>) -> LeanResult<()> {
50    for entry in std::fs::read_dir(dir)? {
51        let entry = entry?;
52        let path = entry.path();
53        let name = entry.file_name();
54        if dir == root {
55            let n = name.to_string_lossy();
56            // The state dir and the control namespace (boundary-verbs
57            // plan D0). Without the second exclusion `.flint/publish`
58            // is live ammunition: an ordinary regular file, scanned and
59            // PUBLISHED to `<prefix>/files/.flint/publish`.
60            if n == STATE_DIR || n == CONTROL_DIR {
61                continue;
62            }
63        }
64        if name.to_string_lossy().ends_with(TMP_SUFFIX) {
65            continue;
66        }
67        let meta = std::fs::symlink_metadata(&path)?;
68        if meta.file_type().is_symlink() {
69            continue;
70        }
71        if meta.is_dir() {
72            walk(root, &path, out)?;
73        } else if meta.is_file() {
74            let rel = path
75                .strip_prefix(root)
76                .expect("walk stays under root")
77                .to_string_lossy()
78                .replace('\\', "/");
79            #[cfg(unix)]
80            let mode = {
81                use std::os::unix::fs::MetadataExt;
82                meta.mode()
83            };
84            #[cfg(not(unix))]
85            let mode = 0o644;
86            let (mtime_unix, mtime_nanos) = meta
87                .modified()
88                .ok()
89                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
90                .map(|d| (d.as_secs() as i64, d.subsec_nanos()))
91                .unwrap_or((0, 0));
92            out.insert(rel, ScanEntry { size: meta.len(), mtime_unix, mtime_nanos, mode });
93        }
94    }
95    Ok(())
96}
97
98#[derive(Debug, Default)]
99pub struct Classified {
100    /// Changed or new vs the baseline (by size/mtime; mtime-granularity
101    /// evasion is a stated v1 residual).
102    pub uploads: BTreeSet<String>,
103    /// Absent this scan AND the previous scan, present in the baseline.
104    pub deletes: BTreeSet<String>,
105    /// Absent this scan but present in the previous one: NOT yet
106    /// delete-eligible (first absence).
107    pub first_absence: BTreeSet<String>,
108}
109
110/// Legacy citations under the reserved control namespace (boundary-verbs
111/// plan D0.2). A workspace that legally published `files/.flint/...`
112/// under a pre-D0 syncer has those paths in its baseline; the new scan
113/// skips them, so two consecutive scans would classify them absent and
114/// publish their DELETION. An upgrade must never delete data: they are
115/// carried forward frozen — never re-uploaded, never deleted by us.
116pub fn is_control_path(path: &str) -> bool {
117    // Both reserved names (review 2026-09-12, inbox-8: only `.flint/`
118    // was refused, so a citation naming `.flint-sync/scope-intent.json`
119    // was materialised INTO the state directory and replayed).
120    [CONTROL_DIR, STATE_DIR].iter().any(|d| {
121        path == *d || path.strip_prefix(d).map(|r| r.starts_with('/')).unwrap_or(false)
122    })
123}
124
125pub fn classify(scan: &BTreeMap<String, ScanEntry>, baseline: &Baseline) -> Classified {
126    let mut c = Classified::default();
127    for (path, s) in scan {
128        match baseline.entries.get(path) {
129            None => {
130                c.uploads.insert(path.clone());
131            }
132            Some(b) => {
133                if stat_changed(b.size, b.mtime_unix, b.mtime_nanos, s.size, s.mtime_unix, s.mtime_nanos) {
134                    c.uploads.insert(path.clone());
135                }
136            }
137        }
138    }
139    for path in baseline.entries.keys() {
140        if scan.contains_key(path) {
141            continue;
142        }
143        if is_control_path(path) {
144            // Frozen legacy citation (D0.2): absent from every scan by
145            // construction now, and NEVER delete-eligible.
146            continue;
147        }
148        if baseline.prev_scan.contains(path) {
149            // Present a scan ago: first observed absence.
150            c.first_absence.insert(path.clone());
151        } else {
152            c.deletes.insert(path.clone());
153        }
154    }
155    c
156}