Skip to main content

dbmd_core/
watch.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Local change detection — the snapshot/diff engine behind `dbmd watch`.
4//!
5//! Poll-based and dependency-free by design: a snapshot stats the store's
6//! emit membership (every content file plus `DB.md` — [`emit::walk_rels`],
7//! so `watch` and `emit` can never disagree about what is observable), and a
8//! diff of two snapshots yields the created / modified / removed set in
9//! deterministic path order. No OS file-event API is used — kernel watchers
10//! differ per platform, mis-report on network filesystems, and would be the
11//! toolkit's first such dependency; a bounded stat sweep per tick is simple,
12//! portable, and honest about cost (the caller narrows big stores with a
13//! prefix). Modification is detected by `(byte length, mtime)` — the
14//! standard watcher tradeoff: a same-length rewrite inside one mtime
15//! granule is invisible.
16//!
17//! Everything here is pure observation: no locks are taken and nothing is
18//! written, so a watcher never blocks a writer.
19
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use std::time::SystemTime;
23
24use crate::emit;
25use crate::store::Store;
26
27/// One observed file's cheap identity: byte length plus mtime (absent where
28/// the filesystem reports none).
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct FileSig {
31    /// File size in bytes.
32    pub len: u64,
33    /// Last-modification time, when the filesystem reports one.
34    pub modified: Option<SystemTime>,
35}
36
37/// A point-in-time view of the watched membership: store-relative path →
38/// signature, path-ordered.
39pub type Snapshot = BTreeMap<PathBuf, FileSig>;
40
41/// What happened to one path between two snapshots.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ChangeKind {
44    /// Present now, absent before.
45    Created,
46    /// Present in both with a different signature.
47    Modified,
48    /// Absent now, present before. A rename appears as removed + created.
49    Removed,
50}
51
52impl ChangeKind {
53    /// The event word used on the wire and in human output.
54    pub fn word(self) -> &'static str {
55        match self {
56            ChangeKind::Created => "created",
57            ChangeKind::Modified => "modified",
58            ChangeKind::Removed => "removed",
59        }
60    }
61}
62
63/// One observed change.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Change {
66    /// What happened.
67    pub kind: ChangeKind,
68    /// The store-relative path it happened to.
69    pub path: PathBuf,
70}
71
72/// Snapshot the watched membership, optionally narrowed to a store-relative
73/// `prefix`. A file that vanishes between the walk and its stat is simply
74/// absent from this snapshot (the next diff reports it removed) — a benign
75/// race, not an error.
76pub fn snapshot(store: &Store, prefix: Option<&Path>) -> crate::Result<Snapshot> {
77    let mut snap = Snapshot::new();
78    for rel in emit::walk_rels(store)? {
79        if let Some(p) = prefix {
80            if !rel.starts_with(p) {
81                continue;
82            }
83        }
84        if let Ok(metadata) = store.regular_metadata(&rel) {
85            snap.insert(
86                rel,
87                FileSig {
88                    len: metadata.len(),
89                    modified: metadata.modified().ok(),
90                },
91            );
92        }
93    }
94    Ok(snap)
95}
96
97/// Diff two snapshots into the created / modified / removed set, in
98/// deterministic store-path order.
99pub fn diff(prev: &Snapshot, next: &Snapshot) -> Vec<Change> {
100    let mut changes = Vec::new();
101    for (path, sig) in next {
102        match prev.get(path) {
103            None => changes.push(Change {
104                kind: ChangeKind::Created,
105                path: path.clone(),
106            }),
107            Some(before) if before != sig => changes.push(Change {
108                kind: ChangeKind::Modified,
109                path: path.clone(),
110            }),
111            Some(_) => {}
112        }
113    }
114    for path in prev.keys() {
115        if !next.contains_key(path) {
116            changes.push(Change {
117                kind: ChangeKind::Removed,
118                path: path.clone(),
119            });
120        }
121    }
122    changes.sort_by(|a, b| a.path.cmp(&b.path));
123    changes
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn scratch_store() -> (tempfile::TempDir, Store) {
131        let dir = tempfile::TempDir::new().unwrap();
132        std::fs::write(
133            dir.path().join("DB.md"),
134            "---\ntype: db-md\nscope: test\nowner: T\n---\n# T\n",
135        )
136        .unwrap();
137        let notes = dir.path().join("records").join("notes");
138        std::fs::create_dir_all(&notes).unwrap();
139        std::fs::write(
140            notes.join("a.md"),
141            "---\ntype: note\nsummary: A\n---\nalpha\n",
142        )
143        .unwrap();
144        let store = Store::open_strict(dir.path()).unwrap();
145        (dir, store)
146    }
147
148    #[test]
149    fn snapshot_covers_content_plus_db_md() {
150        let (_tmp, store) = scratch_store();
151        let snap = snapshot(&store, None).unwrap();
152        let paths: Vec<String> = snap
153            .keys()
154            .map(|p| p.to_string_lossy().into_owned())
155            .collect();
156        assert_eq!(paths, vec!["DB.md", "records/notes/a.md"]);
157    }
158
159    #[test]
160    fn diff_reports_created_modified_removed_in_path_order() {
161        let (tmp, store) = scratch_store();
162        let before = snapshot(&store, None).unwrap();
163
164        let notes = tmp.path().join("records").join("notes");
165        // Modified: a different byte length is visible regardless of mtime
166        // granularity. Created + removed round out the set.
167        std::fs::write(
168            notes.join("a.md"),
169            "---\ntype: note\nsummary: A\n---\nalpha extended\n",
170        )
171        .unwrap();
172        std::fs::write(
173            notes.join("b.md"),
174            "---\ntype: note\nsummary: B\n---\nbeta\n",
175        )
176        .unwrap();
177        std::fs::remove_file(tmp.path().join("DB.md")).unwrap();
178
179        let after = snapshot(&store, None).unwrap();
180        let changes = diff(&before, &after);
181        let rendered: Vec<String> = changes
182            .iter()
183            .map(|c| format!("{} {}", c.kind.word(), c.path.to_string_lossy()))
184            .collect();
185        assert_eq!(
186            rendered,
187            vec![
188                "removed DB.md",
189                "modified records/notes/a.md",
190                "created records/notes/b.md",
191            ]
192        );
193    }
194
195    #[test]
196    fn prefix_scopes_the_membership() {
197        let (tmp, store) = scratch_store();
198        let widgets = tmp.path().join("records").join("widgets");
199        std::fs::create_dir_all(&widgets).unwrap();
200        std::fs::write(widgets.join("w.md"), "---\ntype: widget\nsummary: W\n---\n").unwrap();
201
202        let scoped = snapshot(&store, Some(Path::new("records/widgets"))).unwrap();
203        let paths: Vec<String> = scoped
204            .keys()
205            .map(|p| p.to_string_lossy().into_owned())
206            .collect();
207        assert_eq!(paths, vec!["records/widgets/w.md"]);
208    }
209
210    #[test]
211    fn identical_snapshots_diff_empty() {
212        let (_tmp, store) = scratch_store();
213        let a = snapshot(&store, None).unwrap();
214        let b = snapshot(&store, None).unwrap();
215        assert!(diff(&a, &b).is_empty());
216    }
217}