ripvec_core/encoder/ripvec/manifest.rs
1//! In-memory manifest tracking indexed files for online reconciliation.
2//!
3//! Each entry stores cheap stat data — `(mtime, size, inode)` on Unix
4//! (`inode = 0` on Windows / unavailable filesystems) — plus a blake3
5//! content hash. Reconciliation runs on every search via
6//! [`RipvecIndex::diff_against`](super::index::RipvecIndex::diff_against):
7//!
8//! 1. Walk the corpus with the same [`WalkOptions`] used at index
9//! construction.
10//! 2. For each walked file: compare the stat tuple to the manifest
11//! entry. Match → guaranteed-unchanged, skip.
12//! 3. For mismatches: read the file, blake3-hash, compare against the
13//! stored hash. Match → metadata-only change (vim save-no-edit,
14//! build-tool touch), update the manifest's stat tuple in place to
15//! short-circuit future diffs. Mismatch → record as `dirty`.
16//! 4. Manifest entries not seen during the walk → `deleted`.
17//! 5. Walked paths not in the manifest → `new`.
18//!
19//! If the resulting [`Diff`] is empty, the existing index is up-to-date
20//! and no work is needed. Otherwise the caller rebuilds.
21//!
22//! # Why blake3 + the stat tuple
23//!
24//! The stat tuple is the cheap pre-filter: warm `stat()` is ~1 µs per
25//! file, so the whole tuple check on a 200-file repo is sub-millisecond.
26//! Most files won't have a stat change between queries; the cheap path
27//! skips them entirely.
28//!
29//! When the stat tuple *does* mismatch, the question is whether content
30//! actually changed. Reading + blake3'ing a typical 1-30 KB source file
31//! costs ~1-20 µs warm — two orders of magnitude cheaper than the
32//! ~1-5 ms cost of re-chunking and re-embedding it. The break-even is
33//! "blake3 is worth it when more than 0.7% of stat changes are touches
34//! rather than real edits"; real-world workflows have 5-50% touch rates
35//! (vim `:w` with no edits, autoformatters that hash-equal their input,
36//! build tools that touch source for dependency tracking).
37//!
38//! # Inode as a third dimension
39//!
40//! `(mtime, size)` alone has a rare blind spot: same-byte-count
41//! content swaps. Atomic-rename saves (the modern editor default) bump
42//! the inode, so adding `inode` to the tuple catches those without a
43//! blake3 round-trip. Inode is best-effort: 0 on Windows, where we
44//! fall back to `(mtime, size)`. The blake3 verification path still
45//! guarantees correctness even when the inode signal is unavailable.
46
47use std::collections::{HashMap, HashSet};
48use std::path::{Path, PathBuf};
49use std::time::SystemTime;
50
51/// One file's tracked state in the manifest.
52///
53/// Constructed via [`FileEntry::from_bytes`] when the caller already has
54/// the file bytes in hand (avoids a redundant read), or via
55/// [`FileEntry::from_path`] when only the path is known.
56#[derive(Debug, Clone)]
57pub struct FileEntry {
58 /// Last modification time, or `UNIX_EPOCH` if the platform doesn't
59 /// expose it. Used as the first part of the cheap stat-tuple check.
60 pub mtime: SystemTime,
61 /// File size in bytes, second part of the stat tuple.
62 pub size: u64,
63 /// File inode number on Unix (`0` on Windows / unavailable). Third
64 /// part of the stat tuple; catches atomic-rename saves where mtime
65 /// and size could coincide with the previous entry.
66 pub ino: u64,
67 /// Blake3 content hash. Authoritative — when the stat tuple changes,
68 /// this confirms whether content actually changed vs. a touch.
69 pub blake3: [u8; 32],
70}
71
72impl FileEntry {
73 /// Build an entry from filesystem metadata and the file's bytes.
74 ///
75 /// Use this when the caller has already read the file (e.g., during
76 /// chunking) to avoid the redundant read for blake3 hashing.
77 #[must_use]
78 pub fn from_bytes(metadata: &std::fs::Metadata, bytes: &[u8]) -> Self {
79 Self {
80 mtime: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
81 size: metadata.len(),
82 ino: inode(metadata),
83 blake3: *blake3::hash(bytes).as_bytes(),
84 }
85 }
86
87 /// Build an entry by reading the file from disk.
88 ///
89 /// Reads the file once for blake3. Use [`Self::from_bytes`] if the
90 /// caller already has the bytes.
91 ///
92 /// # Errors
93 ///
94 /// Returns the I/O error if stat or read fails.
95 pub fn from_path(path: &Path) -> std::io::Result<Self> {
96 let metadata = std::fs::metadata(path)?;
97 let bytes = std::fs::read(path)?;
98 Ok(Self::from_bytes(&metadata, &bytes))
99 }
100}
101
102/// Per-root manifest of indexed files.
103///
104/// Keys are absolute, canonical paths (matching the paths returned by
105/// [`crate::walk::collect_files_with_options`]).
106#[derive(Debug, Clone, Default)]
107pub struct Manifest {
108 pub files: HashMap<PathBuf, FileEntry>,
109}
110
111impl Manifest {
112 /// Construct an empty manifest.
113 #[must_use]
114 pub fn new() -> Self {
115 Self {
116 files: HashMap::new(),
117 }
118 }
119
120 /// Number of tracked files.
121 #[must_use]
122 pub fn len(&self) -> usize {
123 self.files.len()
124 }
125
126 /// Whether the manifest tracks zero files.
127 #[must_use]
128 pub fn is_empty(&self) -> bool {
129 self.files.is_empty()
130 }
131
132 /// Insert or replace an entry.
133 pub fn insert(&mut self, path: PathBuf, entry: FileEntry) {
134 self.files.insert(path, entry);
135 }
136
137 /// Look up an entry by path.
138 #[must_use]
139 pub fn get(&self, path: &Path) -> Option<&FileEntry> {
140 self.files.get(path)
141 }
142}
143
144/// Categorized filesystem changes detected by [`diff_against_walk`].
145///
146/// All three vectors hold absolute paths (matching the walk's output).
147/// A [`Diff`] is "empty" only when every list is empty; the
148/// [`Self::is_empty`] helper exists to make this the canonical
149/// "no-work-needed" check.
150///
151/// The `touched_clean` field carries files whose stat tuple changed but
152/// whose blake3 hash still matches — "touched without content change".
153/// These are NOT structural changes (the diff is otherwise empty for
154/// them) but the new `(path, FileEntry)` pairs need to be propagated
155/// back into the owning index's manifest so that future diffs hit the
156/// cheap stat-tuple path rather than re-blake3'ing every time.
157///
158/// `is_empty()` deliberately ignores `touched_clean`: a diff that
159/// contains only touched-clean entries requires no index rebuild.
160/// [`crate::encoder::ripvec::index::RipvecIndex::apply_diff`] handles
161/// the manifest refresh separately.
162#[derive(Debug, Default)]
163pub struct Diff {
164 /// Files present in both manifest and walk whose content changed.
165 pub dirty: Vec<PathBuf>,
166 /// Files present in the walk but not in the manifest.
167 pub new: Vec<PathBuf>,
168 /// Files present in the manifest but not in the walk.
169 pub deleted: Vec<PathBuf>,
170 /// Files whose stat tuple changed but whose blake3 hash matches
171 /// (touch-without-content-change). Each entry carries the refreshed
172 /// [`FileEntry`] (new mtime/size/ino, same blake3) so the caller can
173 /// update the owning manifest without re-reading the file.
174 ///
175 /// This field is NOT included in [`Self::is_empty`] or
176 /// [`Self::total`] — these files are not structural changes.
177 pub touched_clean: Vec<(PathBuf, FileEntry)>,
178}
179
180impl Diff {
181 /// Whether all *structural* change lists are empty.
182 ///
183 /// Returns `true` even when `touched_clean` is non-empty — those
184 /// files require manifest stat-tuple refreshes but no index rebuild.
185 #[must_use]
186 pub fn is_empty(&self) -> bool {
187 self.dirty.is_empty() && self.new.is_empty() && self.deleted.is_empty()
188 }
189
190 /// Total number of structurally changed files (dirty + new + deleted).
191 ///
192 /// Does not count `touched_clean` entries; they are not structural
193 /// changes.
194 #[must_use]
195 pub fn total(&self) -> usize {
196 self.dirty.len() + self.new.len() + self.deleted.len()
197 }
198}
199
200/// Compare the manifest to the current filesystem state and produce a
201/// [`Diff`].
202///
203/// The walked file set is supplied by the caller (typically via
204/// [`crate::walk::collect_files_with_options`]) so this function does no
205/// I/O for path discovery — only per-file stat and (on stat mismatch)
206/// content read for blake3 verification.
207///
208/// # Mutation of the manifest
209///
210/// When a file's stat tuple changes but its blake3 hash still matches
211/// the manifest entry (the touch-without-content-change case), this
212/// function updates the entry's `(mtime, size, ino)` in place. This is
213/// not a correctness step — the diff is the same with or without the
214/// update — but it short-circuits future diffs on the same touched
215/// file: the next call sees the new stat tuple, hits the cheap-path
216/// match, and skips the blake3 read.
217///
218/// # Robustness
219///
220/// Files that vanish between the walk and the per-file stat (rare race)
221/// are silently skipped; they will appear in `deleted` on the next
222/// diff. Permission errors are treated similarly. The function never
223/// fails — every call returns a [`Diff`].
224pub fn diff_against_walk(manifest: &mut Manifest, current_files: &[PathBuf]) -> Diff {
225 let mut diff = Diff::default();
226 let mut seen: HashSet<&Path> = HashSet::with_capacity(current_files.len());
227
228 for path in current_files {
229 seen.insert(path.as_path());
230 let Ok(metadata) = std::fs::metadata(path) else {
231 // Vanished between walk and stat; let the next diff catch
232 // it via the deleted-files pass.
233 continue;
234 };
235 let mtime = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
236 let size = metadata.len();
237 let ino = inode(&metadata);
238
239 match manifest.files.get(path) {
240 None => {
241 diff.new.push(path.clone());
242 }
243 Some(entry) => {
244 if entry.mtime == mtime && entry.size == size && entry.ino == ino {
245 // Stat tuple unchanged → content guaranteed
246 // unchanged. The cheap path.
247 continue;
248 }
249 // Stat changed; blake3 to distinguish real edits from
250 // metadata-only touches.
251 let Ok(bytes) = std::fs::read(path) else {
252 // Treat permission/read errors conservatively as
253 // dirty so the rebuild path notices.
254 diff.dirty.push(path.clone());
255 continue;
256 };
257 let new_hash = *blake3::hash(&bytes).as_bytes();
258 if new_hash == entry.blake3 {
259 // Touch without content change. Build the refreshed
260 // entry (same blake3, new stat tuple) and record it
261 // in `touched_clean` so callers that work on a
262 // cloned manifest (e.g. `diff_against_filesystem`)
263 // can propagate the refresh even though the in-place
264 // update below affects only the passed-in manifest.
265 let refreshed = FileEntry {
266 mtime,
267 size,
268 ino,
269 blake3: new_hash,
270 };
271 diff.touched_clean.push((path.clone(), refreshed));
272 // Also update the passed-in manifest in place so
273 // direct callers (non-cloned path) hit the cheap
274 // stat-tuple path on the next call. Preserves the
275 // existing behavior for callers that own the manifest.
276 if let Some(entry_mut) = manifest.files.get_mut(path) {
277 entry_mut.mtime = mtime;
278 entry_mut.size = size;
279 entry_mut.ino = ino;
280 }
281 } else {
282 diff.dirty.push(path.clone());
283 }
284 }
285 }
286 }
287
288 // Manifest entries we didn't visit during the walk → deleted (or
289 // filtered out of the walk by changed `WalkOptions`, which the
290 // caller treats identically: drop the chunks).
291 for path in manifest.files.keys() {
292 if !seen.contains(path.as_path()) {
293 diff.deleted.push(path.clone());
294 }
295 }
296
297 diff
298}
299
300#[cfg(unix)]
301fn inode(metadata: &std::fs::Metadata) -> u64 {
302 use std::os::unix::fs::MetadataExt;
303 metadata.ino()
304}
305
306#[cfg(not(unix))]
307fn inode(_metadata: &std::fs::Metadata) -> u64 {
308 0
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use std::io::Write;
315 use tempfile::TempDir;
316
317 fn write_file(dir: &Path, name: &str, content: &[u8]) -> PathBuf {
318 let path = dir.join(name);
319 let mut f = std::fs::File::create(&path).unwrap();
320 f.write_all(content).unwrap();
321 path
322 }
323
324 fn manifest_with(path: PathBuf, content: &[u8]) -> Manifest {
325 let metadata = std::fs::metadata(&path).unwrap();
326 let entry = FileEntry::from_bytes(&metadata, content);
327 let mut m = Manifest::new();
328 m.insert(path, entry);
329 m
330 }
331
332 #[test]
333 fn empty_diff_against_empty_walk() {
334 let mut m = Manifest::new();
335 let diff = diff_against_walk(&mut m, &[]);
336 assert!(diff.is_empty());
337 assert_eq!(diff.total(), 0);
338 }
339
340 #[test]
341 fn detects_new_file() {
342 let dir = TempDir::new().unwrap();
343 let p1 = write_file(dir.path(), "a.txt", b"hello");
344 let mut m = Manifest::new();
345 let diff = diff_against_walk(&mut m, std::slice::from_ref(&p1));
346 assert_eq!(diff.new, vec![p1]);
347 assert!(diff.dirty.is_empty());
348 assert!(diff.deleted.is_empty());
349 }
350
351 #[test]
352 fn detects_deleted_file_via_missing_from_walk() {
353 let dir = TempDir::new().unwrap();
354 let p1 = write_file(dir.path(), "gone.txt", b"hello");
355 let mut m = manifest_with(p1.clone(), b"hello");
356 std::fs::remove_file(&p1).unwrap();
357 // Caller walked the dir — empty since gone.txt is gone
358 let diff = diff_against_walk(&mut m, &[]);
359 assert_eq!(diff.deleted, vec![p1]);
360 assert!(diff.dirty.is_empty());
361 assert!(diff.new.is_empty());
362 }
363
364 #[test]
365 fn unchanged_file_skipped_via_stat_tuple() {
366 let dir = TempDir::new().unwrap();
367 let p1 = write_file(dir.path(), "stable.txt", b"hello");
368 let mut m = manifest_with(p1.clone(), b"hello");
369 let diff = diff_against_walk(&mut m, &[p1]);
370 assert!(diff.is_empty(), "stat tuple match must skip blake3");
371 }
372
373 #[test]
374 fn detects_content_change_when_size_changes() {
375 let dir = TempDir::new().unwrap();
376 let p1 = write_file(dir.path(), "edit.txt", b"hello");
377 let mut m = manifest_with(p1.clone(), b"hello");
378 std::thread::sleep(std::time::Duration::from_millis(20));
379 write_file(dir.path(), "edit.txt", b"hello world"); // size change
380 let diff = diff_against_walk(&mut m, std::slice::from_ref(&p1));
381 assert_eq!(diff.dirty, vec![p1]);
382 }
383
384 #[test]
385 fn detects_content_change_when_size_unchanged() {
386 let dir = TempDir::new().unwrap();
387 // Same byte count, different content
388 let p1 = write_file(dir.path(), "rename-vars.rs", b"let foo = 1;");
389 let mut m = manifest_with(p1.clone(), b"let foo = 1;");
390 std::thread::sleep(std::time::Duration::from_millis(20));
391 write_file(dir.path(), "rename-vars.rs", b"let bar = 1;"); // same size
392 let diff = diff_against_walk(&mut m, std::slice::from_ref(&p1));
393 assert_eq!(diff.dirty, vec![p1], "blake3 must catch same-size change");
394 }
395
396 #[test]
397 fn touched_but_unchanged_does_not_appear_in_diff() {
398 let dir = TempDir::new().unwrap();
399 let p1 = write_file(dir.path(), "touched.txt", b"identical");
400 let mut m = manifest_with(p1.clone(), b"identical");
401 let original_mtime = m.get(&p1).unwrap().mtime;
402 std::thread::sleep(std::time::Duration::from_millis(20));
403 // Rewrite same content → mtime updates, blake3 same
404 write_file(dir.path(), "touched.txt", b"identical");
405 let new_mtime_on_disk = std::fs::metadata(&p1).unwrap().modified().unwrap();
406 assert_ne!(
407 original_mtime, new_mtime_on_disk,
408 "setup: mtime must differ for this test to mean anything"
409 );
410
411 let diff = diff_against_walk(&mut m, std::slice::from_ref(&p1));
412 assert!(
413 diff.is_empty(),
414 "touch-without-content-change must not appear in diff"
415 );
416
417 // Manifest's mtime must be refreshed so the next diff hits the
418 // cheap stat-tuple path instead of re-blake3'ing.
419 let refreshed = m.get(&p1).unwrap();
420 assert_eq!(
421 refreshed.mtime, new_mtime_on_disk,
422 "manifest mtime must be refreshed on touch-without-change"
423 );
424 }
425
426 // ── R4.2 tests ────────────────────────────────────────────────────────────
427
428 /// R4.2: `diff_against_walk` must populate `touched_clean` when a file
429 /// is rewritten with identical content (stat tuple changes, blake3 same).
430 #[test]
431 fn diff_against_walk_records_touched_clean() {
432 let dir = TempDir::new().unwrap();
433 let p1 = write_file(dir.path(), "touched_clean.txt", b"same content");
434 let mut m = manifest_with(p1.clone(), b"same content");
435
436 std::thread::sleep(std::time::Duration::from_millis(20));
437 // Rewrite with identical bytes → mtime changes, blake3 unchanged.
438 write_file(dir.path(), "touched_clean.txt", b"same content");
439
440 let diff = diff_against_walk(&mut m, std::slice::from_ref(&p1));
441
442 assert!(
443 diff.is_empty(),
444 "touched_clean file must not appear in dirty/new/deleted"
445 );
446 assert_eq!(
447 diff.touched_clean.len(),
448 1,
449 "touched_clean must have exactly one entry; got {:?}",
450 diff.touched_clean
451 .iter()
452 .map(|(p, _)| p)
453 .collect::<Vec<_>>()
454 );
455 let (tc_path, tc_entry) = &diff.touched_clean[0];
456 assert_eq!(
457 tc_path, &p1,
458 "touched_clean path must match the touched file"
459 );
460 // Blake3 must be the hash of "same content".
461 let expected_hash = *blake3::hash(b"same content").as_bytes();
462 assert_eq!(
463 tc_entry.blake3, expected_hash,
464 "touched_clean entry must carry the correct (unchanged) blake3"
465 );
466 }
467
468 /// R4.2: A second `diff_against_walk` call on the same manifest (after
469 /// the in-place refresh from the first call) must produce an empty
470 /// `touched_clean` — the stat tuple now matches, so no blake3 is needed.
471 #[test]
472 fn repeated_touch_without_edit_pays_one_blake3_then_zero() {
473 let dir = TempDir::new().unwrap();
474 let p1 = write_file(dir.path(), "repeated_touch.txt", b"constant");
475 let mut m = manifest_with(p1.clone(), b"constant");
476
477 std::thread::sleep(std::time::Duration::from_millis(20));
478 write_file(dir.path(), "repeated_touch.txt", b"constant"); // touch only
479
480 // First pass: stat tuple mismatches → blake3 read → touched_clean populated.
481 let diff1 = diff_against_walk(&mut m, std::slice::from_ref(&p1));
482 assert!(diff1.is_empty(), "first pass: no structural changes");
483 assert_eq!(
484 diff1.touched_clean.len(),
485 1,
486 "first pass: touched_clean must have one entry"
487 );
488
489 // Manifest was updated in-place by diff_against_walk. Second pass
490 // should hit the cheap stat-tuple path: no blake3 read, empty
491 // touched_clean.
492 let diff2 = diff_against_walk(&mut m, std::slice::from_ref(&p1));
493 assert!(diff2.is_empty(), "second pass: no structural changes");
494 assert!(
495 diff2.touched_clean.is_empty(),
496 "second pass: touched_clean must be empty after in-place refresh; \
497 got {} entries",
498 diff2.touched_clean.len()
499 );
500 }
501
502 /// R4.2: A touch-without-content-change followed by a real edit must
503 /// still be detected as dirty. The stat-tuple refresh on the first pass
504 /// must not mask the subsequent genuine content change.
505 #[test]
506 fn touched_then_real_edit_still_detected() {
507 let dir = TempDir::new().unwrap();
508 let p1 = write_file(dir.path(), "touch_then_edit.txt", b"v1");
509 let mut m = manifest_with(p1.clone(), b"v1");
510
511 // Pass 1: touch only (same content).
512 std::thread::sleep(std::time::Duration::from_millis(20));
513 write_file(dir.path(), "touch_then_edit.txt", b"v1");
514 let diff1 = diff_against_walk(&mut m, std::slice::from_ref(&p1));
515 assert!(diff1.is_empty(), "pass 1: touch only, no structural diff");
516 assert_eq!(diff1.touched_clean.len(), 1, "pass 1: one touched_clean");
517
518 // Pass 2: real edit.
519 std::thread::sleep(std::time::Duration::from_millis(20));
520 write_file(dir.path(), "touch_then_edit.txt", b"v2 changed");
521 let diff2 = diff_against_walk(&mut m, std::slice::from_ref(&p1));
522 assert_eq!(
523 diff2.dirty,
524 vec![p1.clone()],
525 "pass 2: real edit must appear in dirty"
526 );
527 assert!(
528 diff2.touched_clean.is_empty(),
529 "pass 2: touched_clean must be empty when content changed"
530 );
531 }
532
533 #[test]
534 fn touched_unchanged_then_real_change_still_detected() {
535 // Regression guard: the manifest update on touch-without-change
536 // must not mask a subsequent real edit.
537 let dir = TempDir::new().unwrap();
538 let p1 = write_file(dir.path(), "twice.txt", b"original");
539 let mut m = manifest_with(p1.clone(), b"original");
540
541 std::thread::sleep(std::time::Duration::from_millis(20));
542 write_file(dir.path(), "twice.txt", b"original"); // touch only
543 let diff1 = diff_against_walk(&mut m, std::slice::from_ref(&p1));
544 assert!(diff1.is_empty(), "first pass: touch only");
545
546 std::thread::sleep(std::time::Duration::from_millis(20));
547 write_file(dir.path(), "twice.txt", b"modified"); // real change
548 let diff2 = diff_against_walk(&mut m, std::slice::from_ref(&p1));
549 assert_eq!(diff2.dirty, vec![p1], "second pass: real edit detected");
550 }
551
552 #[test]
553 fn new_plus_deleted_plus_dirty_simultaneously() {
554 let dir = TempDir::new().unwrap();
555 let keep = write_file(dir.path(), "keep.txt", b"keep");
556 let edit = write_file(dir.path(), "edit.txt", b"orig");
557 let gone = write_file(dir.path(), "gone.txt", b"gone");
558 let added_path = dir.path().join("added.txt"); // new file we'll write below
559
560 let mut m = Manifest::new();
561 let keep_meta = std::fs::metadata(&keep).unwrap();
562 let edit_meta = std::fs::metadata(&edit).unwrap();
563 let gone_meta = std::fs::metadata(&gone).unwrap();
564 m.insert(keep.clone(), FileEntry::from_bytes(&keep_meta, b"keep"));
565 m.insert(edit.clone(), FileEntry::from_bytes(&edit_meta, b"orig"));
566 m.insert(gone.clone(), FileEntry::from_bytes(&gone_meta, b"gone"));
567
568 std::thread::sleep(std::time::Duration::from_millis(20));
569 write_file(dir.path(), "edit.txt", b"changed");
570 std::fs::remove_file(&gone).unwrap();
571 write_file(dir.path(), "added.txt", b"added");
572
573 let walk = vec![keep.clone(), edit.clone(), added_path.clone()];
574 let diff = diff_against_walk(&mut m, &walk);
575 assert_eq!(diff.dirty, vec![edit]);
576 assert_eq!(diff.new, vec![added_path]);
577 assert_eq!(diff.deleted, vec![gone]);
578 assert!(!diff.is_empty());
579 assert_eq!(diff.total(), 3);
580 }
581
582 #[test]
583 fn file_entry_from_path_round_trips_from_bytes() {
584 let dir = TempDir::new().unwrap();
585 let p = write_file(dir.path(), "x.txt", b"some content");
586 let from_path = FileEntry::from_path(&p).unwrap();
587 let metadata = std::fs::metadata(&p).unwrap();
588 let from_bytes = FileEntry::from_bytes(&metadata, b"some content");
589 assert_eq!(from_path.blake3, from_bytes.blake3);
590 assert_eq!(from_path.size, from_bytes.size);
591 // mtime may differ by stat-resolution if the OS updated atime
592 // between calls; size + hash are the load-bearing invariants.
593 }
594
595 #[test]
596 fn manifest_default_is_empty() {
597 let m = Manifest::default();
598 assert!(m.is_empty());
599 assert_eq!(m.len(), 0);
600 }
601
602 #[cfg(unix)]
603 #[test]
604 fn inode_is_non_zero_on_unix() {
605 let dir = TempDir::new().unwrap();
606 let p = write_file(dir.path(), "x", b"data");
607 let entry = FileEntry::from_path(&p).unwrap();
608 assert!(entry.ino > 0, "Unix metadata must produce a non-zero inode");
609 }
610}