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 = format!("{:x}", 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}