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}