Skip to main content

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}