Skip to main content

memstead_base/ingest/
change_detection.rs

1//! Source-change targeting primitives for the ingest loop's filesystem
2//! (`mtime`) strategy.
3//!
4//! The ingest loop steers a fresh, memoryless iteration at the *changed*
5//! slice of a source rather than re-roaming the whole thing. For sources
6//! without a git work tree, "what changed" is computed from a per-file
7//! `{mtime, size}` stat map: a file is **added** (new key), **modified**
8//! (mtime or size differs), or **deleted** (key gone). Deletions are the
9//! cheapest, highest-signal drift — a watermark (`max(mtime)`) is blind to
10//! them, so a per-file map is used instead.
11//!
12//! Two artefacts come out of a stat map:
13//!
14//!   - a small [`Digest`] `{count, watermark, aggregate}` — the durable
15//!     token the engine persists per `(ingest, facet)`. Byte-comparing two
16//!     digests answers "did anything change since the last sync"; it
17//!     survives a skill-cache wipe because it lives in engine mem config.
18//!   - the **full map** ([`StatMap`]) — a rebuildable memo keyed by digest,
19//!     used to compute *which* files changed. On memo miss the caller
20//!     degrades to a one-tick full scan (detection from the digest still
21//!     fires; only the precise slice is lost).
22//!
23//! Everything here is pure (no I/O except [`compute_stat_map`]'s `stat()`),
24//! so the digest/diff logic is unit-testable without a workspace.
25//!
26//! The digest token is opaque to the engine — it stores and returns the
27//! string verbatim. [`parse_digest_token`] is deliberately tolerant: an
28//! unrecognized shape returns `None` ("no reliable signal"), never panics,
29//! so a token produced by a different medium-type strategy (a git commit
30//! id, say) degrades gracefully instead of aborting the run.
31//!
32//! **Port note.** This is a faithful port of the Claude-Code plugin's
33//! `skills/ingest/scripts/change-detection.mjs`. The one deliberate change:
34//! the `aggregate` hash uses SHA-256 (truncated to 16 hex chars), matching
35//! the engine's existing [`crate::entity::parser::compute_hash`] convention,
36//! where the plugin used SHA-1. The aggregate is an opaque content digest
37//! only ever compared for equality against a token produced by the same
38//! producer, so the algorithm is an internal detail — the preserved
39//! behaviour is "same files with the same `(mtime, size)` ⇒ same digest;
40//! any change ⇒ a different digest". Using SHA-256 keeps the port within
41//! the crate's existing dependency closure (no new `sha1` dependency).
42
43use std::collections::BTreeMap;
44use std::path::Path;
45use std::time::{SystemTime, UNIX_EPOCH};
46
47use serde::{Deserialize, Serialize};
48use sha2::{Digest as _, Sha256};
49
50/// Digest token schema version. Tags the serialized token so a future
51/// digest shape (or a foreign token — a git commit id, a graph snapshot
52/// token) can be told apart by [`parse_digest_token`].
53const DIGEST_VERSION: u32 = 1;
54
55/// One file's stat signature: integer-millisecond mtime and byte size.
56///
57/// `mtime` is rounded to whole milliseconds so the value is stable across
58/// JSON round-trips (mirroring the plugin's `Math.round(st.mtimeMs)`), and
59/// `size` guards against mtime-preserving content writes (`cp -p`, tar
60/// extraction) that leave the timestamp untouched.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62pub struct StatEntry {
63    /// Modification time in integer milliseconds since the Unix epoch.
64    pub mtime: i64,
65    /// File size in bytes.
66    pub size: u64,
67}
68
69/// A stat map: workspace-relative path → [`StatEntry`]. A [`BTreeMap`] keeps
70/// the keys sorted, which both the digest (stable hash over sorted tuples)
71/// and the diff (sorted output classes) rely on.
72pub type StatMap = BTreeMap<String, StatEntry>;
73
74/// The durable digest of a [`StatMap`]. `count` is the entry count,
75/// `watermark` the maximum mtime, `aggregate` a short content hash over the
76/// sorted `(path, mtime, size)` tuples. Two maps produce the same digest
77/// iff they hold the same files with the same `(mtime, size)`, so a digest
78/// change is a reliable "something moved" trigger; `count`/`watermark`
79/// shifts make additions and deletions visible even without the full map.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Digest {
82    /// Number of files in the map.
83    pub count: u64,
84    /// Maximum mtime across the map (integer ms), `0` for an empty map.
85    pub watermark: i64,
86    /// SHA-256 over the sorted `(path, mtime, size)` tuples, first 16 hex.
87    pub aggregate: String,
88}
89
90/// The classified difference between two stat maps.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct StatDiff {
93    /// Paths present only in the newer map.
94    pub added: Vec<String>,
95    /// Paths in both maps whose `mtime` *or* `size` differs.
96    pub modified: Vec<String>,
97    /// Paths present only in the older map (vanished files).
98    pub deleted: Vec<String>,
99}
100
101/// The on-the-wire shape of a serialized digest token. Field order matches
102/// the plugin's `serializeDigestToken` output (`v`, `count`, `watermark`,
103/// `aggregate`) so a token round-trips through both producers identically.
104#[derive(Debug, Serialize, Deserialize)]
105struct TokenWire {
106    v: u32,
107    count: u64,
108    watermark: i64,
109    aggregate: String,
110}
111
112/// Convert a [`SystemTime`] to integer milliseconds since the Unix epoch,
113/// rounded to the nearest whole millisecond (matching JS `Math.round`).
114/// Times before the epoch are represented as negative milliseconds.
115fn system_time_to_millis(t: SystemTime) -> i64 {
116    match t.duration_since(UNIX_EPOCH) {
117        Ok(d) => (d.as_nanos() as f64 / 1_000_000.0).round() as i64,
118        Err(e) => -((e.duration().as_nanos() as f64 / 1_000_000.0).round() as i64),
119    }
120}
121
122/// Stat every relative path under `root` into a [`StatMap`]. Unreadable or
123/// vanished paths, and non-file entries (directories, symlinks to nothing),
124/// are skipped — a file that disappears between enumeration and stat simply
125/// isn't in the map, which is the correct "deleted" signal on the next diff.
126pub fn compute_stat_map<S: AsRef<str>>(rel_paths: &[S], root: &Path) -> StatMap {
127    let mut map = StatMap::new();
128    for rel in rel_paths {
129        let rel = rel.as_ref();
130        let md = match std::fs::metadata(root.join(rel)) {
131            Ok(md) => md,
132            // vanished or unreadable — omit; surfaces as a deletion next diff.
133            Err(_) => continue,
134        };
135        if !md.is_file() {
136            continue;
137        }
138        let mtime = md.modified().ok().map(system_time_to_millis).unwrap_or(0);
139        map.insert(
140            rel.to_string(),
141            StatEntry {
142                mtime,
143                size: md.len(),
144            },
145        );
146    }
147    map
148}
149
150/// Reduce a [`StatMap`] to its durable [`Digest`].
151pub fn digest_stat_map(map: &StatMap) -> Digest {
152    let mut hasher = Sha256::new();
153    let mut watermark: i64 = 0;
154    // BTreeMap iterates in sorted key order, so the hash is order-stable.
155    for (path, entry) in map {
156        if entry.mtime > watermark {
157            watermark = entry.mtime;
158        }
159        hasher.update(format!("{path}\0{}\0{}\n", entry.mtime, entry.size).as_bytes());
160    }
161    let aggregate = crate::hex_lower(&hasher.finalize())[..16].to_string();
162    Digest {
163        count: map.len() as u64,
164        watermark,
165        aggregate,
166    }
167}
168
169/// Serialize a [`Digest`] into the opaque token string the engine persists.
170pub fn serialize_digest_token(digest: &Digest) -> String {
171    serde_json::to_string(&TokenWire {
172        v: DIGEST_VERSION,
173        count: digest.count,
174        watermark: digest.watermark,
175        aggregate: digest.aggregate.clone(),
176    })
177    .expect("digest token always serializes")
178}
179
180/// Parse a token back into a [`Digest`], or `None` if it isn't a recognized
181/// mtime-digest token. Tolerant by contract: a malformed string, a git
182/// commit id, a graph snapshot token, or a future-version digest all return
183/// `None`, so the caller treats the source as having no usable baseline
184/// (degrade, don't abort). The `&str` type already excludes the plugin's
185/// `null`/non-string case at the boundary.
186pub fn parse_digest_token(token: &str) -> Option<Digest> {
187    if token.is_empty() {
188        return None;
189    }
190    let value: serde_json::Value = serde_json::from_str(token).ok()?;
191    let obj = value.as_object()?;
192    if obj.get("v").and_then(serde_json::Value::as_u64) != Some(u64::from(DIGEST_VERSION)) {
193        return None;
194    }
195    let count = obj.get("count")?.as_u64()?;
196    let watermark = obj.get("watermark")?.as_i64()?;
197    let aggregate = obj.get("aggregate")?.as_str()?.to_string();
198    Some(Digest {
199        count,
200        watermark,
201        aggregate,
202    })
203}
204
205/// Two digests are equal iff every field matches. A missing digest on
206/// either side (no baseline yet) is never equal — mirroring the plugin's
207/// `digestsEqual(a, null) === false`.
208pub fn digests_equal(a: Option<&Digest>, b: Option<&Digest>) -> bool {
209    matches!((a, b), (Some(x), Some(y)) if x == y)
210}
211
212/// Diff two stat maps into added / modified / deleted (each a sorted path
213/// list). A key only in `now` is added; only in `prev` is deleted; in both
214/// with a differing `mtime` *or* `size` is modified.
215pub fn diff_stat_maps(prev: &StatMap, now: &StatMap) -> StatDiff {
216    let mut added = Vec::new();
217    let mut modified = Vec::new();
218    let mut deleted = Vec::new();
219    for (path, b) in now {
220        match prev.get(path) {
221            None => added.push(path.clone()),
222            Some(a) => {
223                if a.mtime != b.mtime || a.size != b.size {
224                    modified.push(path.clone());
225                }
226            }
227        }
228    }
229    for path in prev.keys() {
230        if !now.contains_key(path) {
231            deleted.push(path.clone());
232        }
233    }
234    // BTreeMap iteration already yields sorted order; the explicit sorts
235    // make the contract independent of the map type and mirror the plugin.
236    added.sort();
237    modified.sort();
238    deleted.sort();
239    StatDiff {
240        added,
241        modified,
242        deleted,
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use std::fs;
250
251    fn entry(mtime: i64, size: u64) -> StatEntry {
252        StatEntry { mtime, size }
253    }
254
255    fn map(pairs: &[(&str, i64, u64)]) -> StatMap {
256        pairs
257            .iter()
258            .map(|(k, m, s)| ((*k).to_string(), entry(*m, *s)))
259            .collect()
260    }
261
262    /// A digest round-trips through serialize/parse unchanged.
263    #[test]
264    fn digest_token_round_trips() {
265        let d = Digest {
266            count: 3,
267            watermark: 1_700_000_000_000,
268            aggregate: "abc123def456abcd".to_string(),
269        };
270        let back = parse_digest_token(&serialize_digest_token(&d));
271        assert_eq!(back, Some(d));
272    }
273
274    /// Unrecognized token shapes parse to `None` (no reliable signal): a git
275    /// commit id, a future-version digest, junk, empty, and wrong types.
276    #[test]
277    fn unrecognized_tokens_parse_to_none() {
278        assert_eq!(parse_digest_token("a1b2c3d4e5f6"), None); // git-oid-ish
279        assert_eq!(parse_digest_token(r#"{"v":2,"count":1}"#), None); // future v
280        assert_eq!(parse_digest_token("not json"), None);
281        assert_eq!(parse_digest_token(""), None);
282        assert_eq!(parse_digest_token(r#"{"v":1,"count":"x"}"#), None); // wrong type
283    }
284
285    /// `digests_equal` is true only when every field matches, and false when
286    /// either side is absent (no baseline).
287    #[test]
288    fn digests_equal_requires_every_field() {
289        let a = Digest {
290            count: 1,
291            watermark: 10,
292            aggregate: "x".to_string(),
293        };
294        assert!(digests_equal(Some(&a), Some(&a.clone())));
295        assert!(!digests_equal(
296            Some(&a),
297            Some(&Digest {
298                count: 2,
299                ..a.clone()
300            })
301        ));
302        assert!(!digests_equal(
303            Some(&a),
304            Some(&Digest {
305                watermark: 11,
306                ..a.clone()
307            })
308        ));
309        assert!(!digests_equal(
310            Some(&a),
311            Some(&Digest {
312                aggregate: "y".to_string(),
313                ..a.clone()
314            })
315        ));
316        assert!(!digests_equal(Some(&a), None));
317    }
318
319    /// Identical maps digest identically regardless of insertion order; a
320    /// size change or an mtime change each move the digest.
321    #[test]
322    fn digest_is_stable_and_change_sensitive() {
323        let m1 = map(&[("a.rs", 100, 10), ("b.rs", 200, 20)]);
324        // A BTreeMap normalizes order, so build the "reordered" map from the
325        // reversed slice to prove insertion order is irrelevant.
326        let m2 = map(&[("b.rs", 200, 20), ("a.rs", 100, 10)]);
327        assert_eq!(
328            digest_stat_map(&m1),
329            digest_stat_map(&m2),
330            "key order must not matter"
331        );
332
333        let mut m3 = m1.clone();
334        m3.insert("a.rs".to_string(), entry(100, 11));
335        assert_ne!(
336            digest_stat_map(&m1),
337            digest_stat_map(&m3),
338            "a size change moves the digest"
339        );
340
341        let mut m4 = m1.clone();
342        m4.insert("a.rs".to_string(), entry(101, 10));
343        assert_ne!(
344            digest_stat_map(&m1),
345            digest_stat_map(&m4),
346            "an mtime change moves the digest"
347        );
348    }
349
350    /// `watermark` is the max mtime; `count` is the entry count.
351    #[test]
352    fn digest_watermark_and_count() {
353        let d = digest_stat_map(&map(&[("a", 5, 1), ("b", 99, 1)]));
354        assert_eq!(d.count, 2);
355        assert_eq!(d.watermark, 99);
356    }
357
358    /// The diff classifies added / modified / deleted and treats an
359    /// mtime-only touch and a size-only growth both as modified.
360    #[test]
361    fn diff_classifies_added_modified_deleted() {
362        let prev = map(&[
363            ("keep.rs", 100, 10),
364            ("touch.rs", 100, 10),
365            ("grow.rs", 100, 10),
366            ("gone.rs", 100, 10),
367        ]);
368        let now = map(&[
369            ("keep.rs", 100, 10),  // unchanged
370            ("touch.rs", 200, 10), // mtime touched
371            ("grow.rs", 100, 99),  // size grew (mtime preserved)
372            ("new.rs", 300, 5),    // added
373                                   // gone.rs deleted
374        ]);
375        let StatDiff {
376            added,
377            modified,
378            deleted,
379        } = diff_stat_maps(&prev, &now);
380        assert_eq!(added, ["new.rs"]);
381        assert_eq!(modified, ["grow.rs", "touch.rs"]);
382        assert_eq!(deleted, ["gone.rs"]);
383        assert!(
384            !modified.contains(&"keep.rs".to_string()),
385            "identical (mtime,size) is absent from the slice"
386        );
387    }
388
389    /// Empty-vs-empty yields no changes.
390    #[test]
391    fn diff_empty_vs_empty() {
392        assert_eq!(
393            diff_stat_maps(&StatMap::new(), &StatMap::new()),
394            StatDiff {
395                added: vec![],
396                modified: vec![],
397                deleted: vec![],
398            }
399        );
400    }
401
402    /// `compute_stat_map` stats listed files, reflects their size, skips a
403    /// path that does not exist, and skips a directory (non-file). A freshly
404    /// written file carries a populated integer-ms mtime.
405    #[test]
406    fn compute_stat_map_over_a_real_directory() {
407        let root = tempfile::tempdir().unwrap();
408        let base = root.path();
409        fs::create_dir_all(base.join("sub")).unwrap();
410        fs::write(base.join("a.txt"), "hello").unwrap();
411        fs::write(base.join("sub/b.txt"), "worldworld").unwrap();
412
413        let paths = ["a.txt", "sub/b.txt", "missing.txt", "sub"];
414        let m = compute_stat_map(&paths, base);
415
416        assert!(
417            !m.contains_key("missing.txt"),
418            "a path that does not exist is omitted"
419        );
420        assert!(
421            !m.contains_key("sub"),
422            "a directory is not a file — skipped"
423        );
424        assert_eq!(m["a.txt"].size, 5);
425        assert_eq!(m["sub/b.txt"].size, 10);
426        assert!(
427            m["a.txt"].mtime > 0,
428            "a freshly written file has a populated integer-ms mtime"
429        );
430    }
431}