1use crate::types::ContentHash;
14use oxibrain_ports::Timestamp;
15use std::collections::{HashMap, HashSet};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct SyncFile {
25 pub path: String,
26 pub content_hash: ContentHash,
27 pub modified: Timestamp,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum SyncAction {
33 New(SyncFile),
35 Unchanged(String),
37 Modified(SyncFile),
40}
41
42pub type KnownNotes = HashMap<String, HashSet<ContentHash>>;
45
46pub fn classify(files: Vec<SyncFile>, known: &KnownNotes) -> Vec<SyncAction> {
52 files
53 .into_iter()
54 .map(|f| match known.get(&f.path) {
55 Some(hashes) if !hashes.is_empty() => {
58 if hashes.contains(&f.content_hash) {
59 SyncAction::Unchanged(f.path)
60 } else {
61 SyncAction::Modified(f)
62 }
63 }
64 _ => SyncAction::New(f),
65 })
66 .collect()
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct LocatorState {
72 pub latest_occurrence_id: String,
74 pub latest_content_hash: ContentHash,
76}
77
78pub fn classify_event(
84 files: Vec<SyncFile>,
85 legacy: &KnownNotes,
86 event_states: &HashMap<String, LocatorState>,
87) -> Vec<SyncAction> {
88 files
89 .into_iter()
90 .map(|f| {
91 if let Some(state) = event_states.get(&f.path) {
92 if state.latest_content_hash == f.content_hash {
93 SyncAction::Unchanged(f.path)
94 } else {
95 SyncAction::Modified(f)
96 }
97 } else if let Some(hashes) = legacy.get(&f.path) {
98 if !hashes.is_empty() && hashes.contains(&f.content_hash) {
99 SyncAction::Unchanged(f.path)
100 } else {
101 SyncAction::Modified(f)
102 }
103 } else {
104 SyncAction::New(f)
105 }
106 })
107 .collect()
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use crate::content_hash;
114 use proptest::prelude::*;
115
116 fn file(path: &str, content: &str, t: i64) -> SyncFile {
117 SyncFile {
118 path: path.into(),
119 content_hash: content_hash(content),
120 modified: Timestamp(t),
121 }
122 }
123
124 fn known(entries: &[(&str, &[&str])]) -> KnownNotes {
125 entries
126 .iter()
127 .map(|(path, contents)| {
128 (
129 (*path).to_string(),
130 contents.iter().map(|c| content_hash(c)).collect(),
131 )
132 })
133 .collect()
134 }
135
136 #[test]
137 fn unknown_path_is_new() {
138 let actions = classify(vec![file("a.md", "hello", 1)], &known(&[]));
139 assert_eq!(actions, vec![SyncAction::New(file("a.md", "hello", 1))]);
140 }
141
142 #[test]
143 fn matching_hash_is_unchanged() {
144 let actions = classify(
145 vec![file("a.md", "hello", 2)],
146 &known(&[("a.md", &["hello"])]),
147 );
148 assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
149 }
150
151 #[test]
152 fn mismatched_hash_is_modified() {
153 let actions = classify(
154 vec![file("a.md", "hello v2", 2)],
155 &known(&[("a.md", &["hello"])]),
156 );
157 assert_eq!(
158 actions,
159 vec![SyncAction::Modified(file("a.md", "hello v2", 2))]
160 );
161 }
162
163 #[test]
164 fn any_prior_version_hash_counts_as_unchanged() {
165 let actions = classify(
168 vec![file("a.md", "hello", 3)],
169 &known(&[("a.md", &["hello", "hello v2"])]),
170 );
171 assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
172 }
173
174 #[test]
175 fn empty_known_entry_is_new() {
176 let mut map = KnownNotes::new();
178 map.insert("a.md".into(), HashSet::new());
179 let actions = classify(vec![file("a.md", "hello", 1)], &map);
180 assert_eq!(actions, vec![SyncAction::New(file("a.md", "hello", 1))]);
181 }
182
183 #[test]
184 fn output_preserves_input_order() {
185 let files = vec![
186 file("b.md", "b", 1),
187 file("a.md", "a", 1),
188 file("c.md", "c", 1),
189 ];
190 let actions = classify(files, &known(&[]));
191 let paths: Vec<&str> = actions
192 .iter()
193 .map(|a| match a {
194 SyncAction::New(f) | SyncAction::Modified(f) => f.path.as_str(),
195 SyncAction::Unchanged(p) => p.as_str(),
196 })
197 .collect();
198 assert_eq!(paths, vec!["b.md", "a.md", "c.md"]);
199 }
200
201 proptest! {
202 #![proptest_config(ProptestConfig::with_cases(64))]
203
204 #[test]
208 fn classify_is_total_and_correct(
209 files in proptest::collection::vec(
210 (".*a?b?c?[0-9]{0,3}\\.md", ".*", 0i64..1000),
211 0..16
212 ),
213 known in proptest::collection::vec(
214 (".*a?b?c?[0-9]{0,3}\\.md", proptest::collection::vec(".*", 0..3)),
215 0..8
216 ),
217 ) {
218 let sync_files: Vec<SyncFile> = files
219 .iter()
220 .map(|(p, c, t)| file(p, c, *t))
221 .collect();
222 let mut map = KnownNotes::new();
223 for (p, cs) in &known {
224 let set: HashSet<ContentHash> = cs.iter().map(|c| content_hash(c)).collect();
225 map.insert(p.clone(), set);
226 }
227 let actions = classify(sync_files.clone(), &map);
228
229 assert_eq!(actions.len(), sync_files.len());
231 for (f, a) in sync_files.iter().zip(&actions) {
232 let got_path = match a {
233 SyncAction::New(sf) | SyncAction::Modified(sf) => &sf.path,
234 SyncAction::Unchanged(p) => p,
235 };
236 assert_eq!(got_path, &f.path);
237 let entry = map.get(&f.path);
239 let expect_unchanged = entry.is_some_and(|s| s.contains(&f.content_hash));
240 let expect_new = entry.is_none_or(|s| s.is_empty());
241 match a {
242 SyncAction::Unchanged(_) => assert!(expect_unchanged),
243 SyncAction::New(_) => assert!(!expect_unchanged && expect_new),
244 SyncAction::Modified(_) => assert!(!expect_unchanged && !expect_new),
245 }
246 }
247 }
248 }
249
250 use super::LocatorState;
251
252 fn locator_state(occ: &str, content: &str) -> LocatorState {
253 LocatorState {
254 latest_occurrence_id: occ.into(),
255 latest_content_hash: content_hash(content),
256 }
257 }
258
259 #[test]
260 fn classify_event_new_when_no_state() {
261 let files = vec![file("a.md", "hello", 1)];
262 let actions = classify_event(files, &KnownNotes::new(), &HashMap::new());
263 assert_eq!(actions, vec![SyncAction::New(file("a.md", "hello", 1))]);
264 }
265
266 #[test]
267 fn classify_event_unchanged_when_event_hash_matches() {
268 let states = HashMap::from([("a.md".to_string(), locator_state("occ1", "hello"))]);
269 let files = vec![file("a.md", "hello", 1)];
270 let actions = classify_event(files, &KnownNotes::new(), &states);
271 assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
272 }
273
274 #[test]
275 fn classify_event_modified_when_event_hash_differs() {
276 let states = HashMap::from([("a.md".to_string(), locator_state("occ1", "old"))]);
277 let files = vec![file("a.md", "new", 2)];
278 let actions = classify_event(files, &KnownNotes::new(), &states);
279 assert_eq!(actions, vec![SyncAction::Modified(file("a.md", "new", 2))]);
280 }
281
282 #[test]
283 fn classify_event_unchanged_via_legacy_hash() {
284 let mut legacy = KnownNotes::new();
286 legacy.insert("a.md".into(), HashSet::from([content_hash("hello")]));
287 let files = vec![file("a.md", "hello", 1)];
288 let actions = classify_event(files, &legacy, &HashMap::new());
289 assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
290 }
291
292 #[test]
293 fn classify_event_modified_via_legacy_mismatch() {
294 let mut legacy = KnownNotes::new();
296 legacy.insert("a.md".into(), HashSet::from([content_hash("old")]));
297 let files = vec![file("a.md", "new", 2)];
298 let actions = classify_event(files, &legacy, &HashMap::new());
299 assert_eq!(actions, vec![SyncAction::Modified(file("a.md", "new", 2))]);
300 }
301
302 #[test]
303 fn classify_event_event_state_takes_precedence_over_legacy() {
304 let states = HashMap::from([("a.md".to_string(), locator_state("occ1", "old"))]);
306 let mut legacy = KnownNotes::new();
307 legacy.insert("a.md".into(), HashSet::from([content_hash("hello")]));
308 let files = vec![file("a.md", "hello", 1)];
309 let actions = classify_event(files, &legacy, &states);
310 assert_eq!(
312 actions,
313 vec![SyncAction::Modified(file("a.md", "hello", 1))]
314 );
315 }
316}