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#[cfg(test)]
70mod tests {
71 use super::*;
72 use crate::content_hash;
73 use proptest::prelude::*;
74
75 fn file(path: &str, content: &str, t: i64) -> SyncFile {
76 SyncFile {
77 path: path.into(),
78 content_hash: content_hash(content),
79 modified: Timestamp(t),
80 }
81 }
82
83 fn known(entries: &[(&str, &[&str])]) -> KnownNotes {
84 entries
85 .iter()
86 .map(|(path, contents)| {
87 (
88 (*path).to_string(),
89 contents.iter().map(|c| content_hash(c)).collect(),
90 )
91 })
92 .collect()
93 }
94
95 #[test]
96 fn unknown_path_is_new() {
97 let actions = classify(vec![file("a.md", "hello", 1)], &known(&[]));
98 assert_eq!(actions, vec![SyncAction::New(file("a.md", "hello", 1))]);
99 }
100
101 #[test]
102 fn matching_hash_is_unchanged() {
103 let actions = classify(
104 vec![file("a.md", "hello", 2)],
105 &known(&[("a.md", &["hello"])]),
106 );
107 assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
108 }
109
110 #[test]
111 fn mismatched_hash_is_modified() {
112 let actions = classify(
113 vec![file("a.md", "hello v2", 2)],
114 &known(&[("a.md", &["hello"])]),
115 );
116 assert_eq!(
117 actions,
118 vec![SyncAction::Modified(file("a.md", "hello v2", 2))]
119 );
120 }
121
122 #[test]
123 fn any_prior_version_hash_counts_as_unchanged() {
124 let actions = classify(
127 vec![file("a.md", "hello", 3)],
128 &known(&[("a.md", &["hello", "hello v2"])]),
129 );
130 assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
131 }
132
133 #[test]
134 fn empty_known_entry_is_new() {
135 let mut map = KnownNotes::new();
137 map.insert("a.md".into(), HashSet::new());
138 let actions = classify(vec![file("a.md", "hello", 1)], &map);
139 assert_eq!(actions, vec![SyncAction::New(file("a.md", "hello", 1))]);
140 }
141
142 #[test]
143 fn output_preserves_input_order() {
144 let files = vec![
145 file("b.md", "b", 1),
146 file("a.md", "a", 1),
147 file("c.md", "c", 1),
148 ];
149 let actions = classify(files, &known(&[]));
150 let paths: Vec<&str> = actions
151 .iter()
152 .map(|a| match a {
153 SyncAction::New(f) | SyncAction::Modified(f) => f.path.as_str(),
154 SyncAction::Unchanged(p) => p.as_str(),
155 })
156 .collect();
157 assert_eq!(paths, vec!["b.md", "a.md", "c.md"]);
158 }
159
160 proptest! {
161 #![proptest_config(ProptestConfig::with_cases(64))]
162
163 #[test]
167 fn classify_is_total_and_correct(
168 files in proptest::collection::vec(
169 (".*a?b?c?[0-9]{0,3}\\.md", ".*", 0i64..1000),
170 0..16
171 ),
172 known in proptest::collection::vec(
173 (".*a?b?c?[0-9]{0,3}\\.md", proptest::collection::vec(".*", 0..3)),
174 0..8
175 ),
176 ) {
177 let sync_files: Vec<SyncFile> = files
178 .iter()
179 .map(|(p, c, t)| file(p, c, *t))
180 .collect();
181 let mut map = KnownNotes::new();
182 for (p, cs) in &known {
183 let set: HashSet<ContentHash> = cs.iter().map(|c| content_hash(c)).collect();
184 map.insert(p.clone(), set);
185 }
186 let actions = classify(sync_files.clone(), &map);
187
188 assert_eq!(actions.len(), sync_files.len());
190 for (f, a) in sync_files.iter().zip(&actions) {
191 let got_path = match a {
192 SyncAction::New(sf) | SyncAction::Modified(sf) => &sf.path,
193 SyncAction::Unchanged(p) => p,
194 };
195 assert_eq!(got_path, &f.path);
196 let entry = map.get(&f.path);
198 let expect_unchanged = entry.is_some_and(|s| s.contains(&f.content_hash));
199 let expect_new = entry.is_none_or(|s| s.is_empty());
200 match a {
201 SyncAction::Unchanged(_) => assert!(expect_unchanged),
202 SyncAction::New(_) => assert!(!expect_unchanged && expect_new),
203 SyncAction::Modified(_) => assert!(!expect_unchanged && !expect_new),
204 }
205 }
206 }
207 }
208}