Skip to main content

lattice_core/
on_disk.rs

1//! On-disk identity of a file-backed buffer — the shared "did this file
2//! change underneath us" primitive.
3//!
4//! SS.1 (2026-08-11): lowered here from `lattice-host::autoread` so
5//! `lattice-multibuffer` can reach it. A multibuffer's sources are
6//! snapshots read at view-creation time, and its `Document::save`
7//! writes every dirty source back to disk — without a baseline to
8//! compare against, that silently overwrites whatever changed the file
9//! externally. See
10//! `docs/dev/architecture/multibuffer-stale-sources.md`.
11//!
12//! Deliberately ONE mechanism, not two. `autoread` had already worked
13//! out the distinction that matters — content hash authoritative so a
14//! bare `touch` is not a change, `(mtime, size)` only as a cheap
15//! pre-gate — and a second copy would drift from it.
16
17use std::hash::{Hash, Hasher};
18use std::path::Path;
19use std::time::SystemTime;
20
21/// A fast, non-cryptographic hash of a buffer's text. Not stable across
22/// process runs (that's fine — fingerprints are session-scoped) and not
23/// collision-proof against an adversary (irrelevant — the input is the
24/// user's own file, and a collision at worst suppresses one real reload).
25pub(crate) fn hash_text(text: &str) -> u64 {
26    let mut h = std::collections::hash_map::DefaultHasher::new();
27    text.hash(&mut h);
28    h.finish()
29}
30
31/// The on-disk identity of a file-backed buffer at the moment the editor
32/// last synced with disk — a load, or its own `:w`.
33///
34/// Two comparison surfaces, deliberately distinct:
35///
36/// - [`Self::same_content`] (content hash) is the **authoritative** "is this
37///   the same file we already have" test. A `touch` that bumps mtime without
38///   changing bytes must compare equal, so mtime/size are *not* part of it.
39/// - [`Self::stat_unchanged`] is the cheap `(mtime, size)` **pre-gate** the
40///   watcher uses to decide whether it even needs to read + hash the file.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct OnDiskFingerprint {
43    /// Last-modified time from `stat`, or `None` on platforms / filesystems
44    /// where it's unavailable (then detection leans on `content_hash` alone).
45    pub mtime: Option<SystemTime>,
46    /// Byte length from `stat` (`0` when metadata is unavailable).
47    pub size: u64,
48    /// Hash of the text the editor holds for this file — the precise check
49    /// that survives mtime-only touches and identifies self-writes.
50    pub content_hash: u64,
51}
52
53impl OnDiskFingerprint {
54    /// Build a fingerprint from `path`'s current metadata plus the `text`
55    /// the editor holds for it. `stat` failure degrades to
56    /// `mtime = None` / `size = 0` rather than erroring — a missing stat
57    /// must never break a load or a save (paramount: never panic on the
58    /// hot path; recover + lean on the content hash).
59    pub fn from_path_and_text(path: &Path, text: &str) -> Self {
60        let meta = std::fs::metadata(path).ok();
61        let mtime = meta.as_ref().and_then(|m| m.modified().ok());
62        let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
63        Self {
64            mtime,
65            size,
66            content_hash: hash_text(text),
67        }
68    }
69
70    /// True when `self` and `other` denote the same on-disk *content*.
71    /// Content hash is authoritative; mtime/size are ignored so a bare
72    /// `touch` is correctly treated as "no change".
73    pub fn same_content(&self, other: &Self) -> bool {
74        self.content_hash == other.content_hash
75    }
76
77    /// Cheap pre-gate: `true` when `path`'s current `(mtime, size)` still
78    /// match this fingerprint, i.e. the file almost certainly hasn't
79    /// changed and the watcher can skip the read + hash entirely. A `stat`
80    /// failure returns `false` (fall through to the authoritative read),
81    /// as does a `None` stored mtime (we never had a baseline to gate on).
82    pub fn stat_unchanged(&self, path: &Path) -> bool {
83        let Some(stored_mtime) = self.mtime else {
84            return false;
85        };
86        let Ok(meta) = std::fs::metadata(path) else {
87            return false;
88        };
89        meta.len() == self.size && meta.modified().ok() == Some(stored_mtime)
90    }
91}
92#[cfg(test)]
93mod tests {
94    #![allow(clippy::unwrap_used, clippy::panic)]
95    use super::*;
96    use std::path::PathBuf;
97
98    /// SS.1: moved here with the type. A unique-per-test path so a
99    /// parallel `cargo test` cannot have two of these racing on one
100    /// file (the counter, not just the pid — same process, many tests).
101    fn temp_path(tag: &str) -> PathBuf {
102        use std::sync::atomic::{AtomicU64, Ordering};
103        static N: AtomicU64 = AtomicU64::new(0);
104        std::env::temp_dir().join(format!(
105            "lattice-ondisk-{tag}-{}-{}",
106            std::process::id(),
107            N.fetch_add(1, Ordering::Relaxed)
108        ))
109    }
110    #[test]
111    fn same_content_ignores_mtime_and_size() {
112        // Two fingerprints with identical content hash but different
113        // mtime/size compare equal-by-content — a `touch` is not a change.
114        let a = OnDiskFingerprint {
115            mtime: Some(SystemTime::UNIX_EPOCH),
116            size: 10,
117            content_hash: hash_text("hello"),
118        };
119        let b = OnDiskFingerprint {
120            mtime: Some(SystemTime::now()),
121            size: 999,
122            content_hash: hash_text("hello"),
123        };
124        assert!(a.same_content(&b), "same bytes ⇒ same content");
125    }
126
127    #[test]
128    fn same_content_differs_on_real_edit() {
129        let a = OnDiskFingerprint::from_path_and_text(Path::new("/nonexistent"), "one");
130        let b = OnDiskFingerprint::from_path_and_text(Path::new("/nonexistent"), "two");
131        assert!(!a.same_content(&b), "different bytes ⇒ different content");
132    }
133
134    #[test]
135    fn self_write_is_suppressible_by_content_hash() {
136        // Simulate: we save text T (stamp F), then read disk back (F').
137        // Even though the on-disk mtime moved, F'.same_content(&F) holds,
138        // so the watcher can recognise its own write.
139        let path = temp_path("selfwrite");
140        std::fs::write(&path, "saved text\n").unwrap();
141        let stamped = OnDiskFingerprint::from_path_and_text(&path, "saved text\n");
142        // A later read of the unchanged file yields the same content hash.
143        let reread = OnDiskFingerprint::from_path_and_text(&path, "saved text\n");
144        assert!(stamped.same_content(&reread));
145        std::fs::remove_file(&path).ok();
146    }
147
148    #[test]
149    fn stat_unchanged_true_when_untouched_then_false_after_write() {
150        let path = temp_path("stat");
151        std::fs::write(&path, "v1\n").unwrap();
152        let fp = OnDiskFingerprint::from_path_and_text(&path, "v1\n");
153        assert!(fp.stat_unchanged(&path), "freshly stamped ⇒ stat unchanged");
154
155        // Rewrite with different length + (almost certainly) newer mtime.
156        std::fs::write(&path, "v2-longer\n").unwrap();
157        assert!(
158            !fp.stat_unchanged(&path),
159            "size/mtime moved ⇒ stat gate opens"
160        );
161        std::fs::remove_file(&path).ok();
162    }
163
164    #[test]
165    fn stat_unchanged_false_when_no_baseline_mtime_or_missing_file() {
166        let no_mtime = OnDiskFingerprint {
167            mtime: None,
168            size: 0,
169            content_hash: 0,
170        };
171        assert!(!no_mtime.stat_unchanged(Path::new("/nonexistent")));
172
173        let fp = OnDiskFingerprint::from_path_and_text(Path::new("/definitely/missing"), "x");
174        assert!(!fp.stat_unchanged(Path::new("/definitely/missing")));
175    }
176
177    // ---- AR.2: watcher decision logic + one fs integration test ----
178}