1use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use std::time::SystemTime;
23
24use crate::emit;
25use crate::store::Store;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct FileSig {
31 pub len: u64,
33 pub modified: Option<SystemTime>,
35}
36
37pub type Snapshot = BTreeMap<PathBuf, FileSig>;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ChangeKind {
44 Created,
46 Modified,
48 Removed,
50}
51
52impl ChangeKind {
53 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#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Change {
66 pub kind: ChangeKind,
68 pub path: PathBuf,
70}
71
72pub 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
97pub 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(¬es).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 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}