use crate::types::ContentHash;
use oxibrain_ports::Timestamp;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyncFile {
pub path: String,
pub content_hash: ContentHash,
pub modified: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncAction {
New(SyncFile),
Unchanged(String),
Modified(SyncFile),
}
pub type KnownNotes = HashMap<String, HashSet<ContentHash>>;
pub fn classify(files: Vec<SyncFile>, known: &KnownNotes) -> Vec<SyncAction> {
files
.into_iter()
.map(|f| match known.get(&f.path) {
Some(hashes) if !hashes.is_empty() => {
if hashes.contains(&f.content_hash) {
SyncAction::Unchanged(f.path)
} else {
SyncAction::Modified(f)
}
}
_ => SyncAction::New(f),
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocatorState {
pub latest_occurrence_id: String,
pub latest_content_hash: ContentHash,
}
pub fn classify_event(
files: Vec<SyncFile>,
legacy: &KnownNotes,
event_states: &HashMap<String, LocatorState>,
) -> Vec<SyncAction> {
files
.into_iter()
.map(|f| {
if let Some(state) = event_states.get(&f.path) {
if state.latest_content_hash == f.content_hash {
SyncAction::Unchanged(f.path)
} else {
SyncAction::Modified(f)
}
} else if let Some(hashes) = legacy.get(&f.path) {
if !hashes.is_empty() && hashes.contains(&f.content_hash) {
SyncAction::Unchanged(f.path)
} else {
SyncAction::Modified(f)
}
} else {
SyncAction::New(f)
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::content_hash;
use proptest::prelude::*;
fn file(path: &str, content: &str, t: i64) -> SyncFile {
SyncFile {
path: path.into(),
content_hash: content_hash(content),
modified: Timestamp(t),
}
}
fn known(entries: &[(&str, &[&str])]) -> KnownNotes {
entries
.iter()
.map(|(path, contents)| {
(
(*path).to_string(),
contents.iter().map(|c| content_hash(c)).collect(),
)
})
.collect()
}
#[test]
fn unknown_path_is_new() {
let actions = classify(vec![file("a.md", "hello", 1)], &known(&[]));
assert_eq!(actions, vec![SyncAction::New(file("a.md", "hello", 1))]);
}
#[test]
fn matching_hash_is_unchanged() {
let actions = classify(
vec![file("a.md", "hello", 2)],
&known(&[("a.md", &["hello"])]),
);
assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
}
#[test]
fn mismatched_hash_is_modified() {
let actions = classify(
vec![file("a.md", "hello v2", 2)],
&known(&[("a.md", &["hello"])]),
);
assert_eq!(
actions,
vec![SyncAction::Modified(file("a.md", "hello v2", 2))]
);
}
#[test]
fn any_prior_version_hash_counts_as_unchanged() {
let actions = classify(
vec![file("a.md", "hello", 3)],
&known(&[("a.md", &["hello", "hello v2"])]),
);
assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
}
#[test]
fn empty_known_entry_is_new() {
let mut map = KnownNotes::new();
map.insert("a.md".into(), HashSet::new());
let actions = classify(vec![file("a.md", "hello", 1)], &map);
assert_eq!(actions, vec![SyncAction::New(file("a.md", "hello", 1))]);
}
#[test]
fn output_preserves_input_order() {
let files = vec![
file("b.md", "b", 1),
file("a.md", "a", 1),
file("c.md", "c", 1),
];
let actions = classify(files, &known(&[]));
let paths: Vec<&str> = actions
.iter()
.map(|a| match a {
SyncAction::New(f) | SyncAction::Modified(f) => f.path.as_str(),
SyncAction::Unchanged(p) => p.as_str(),
})
.collect();
assert_eq!(paths, vec!["b.md", "a.md", "c.md"]);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn classify_is_total_and_correct(
files in proptest::collection::vec(
(".*a?b?c?[0-9]{0,3}\\.md", ".*", 0i64..1000),
0..16
),
known in proptest::collection::vec(
(".*a?b?c?[0-9]{0,3}\\.md", proptest::collection::vec(".*", 0..3)),
0..8
),
) {
let sync_files: Vec<SyncFile> = files
.iter()
.map(|(p, c, t)| file(p, c, *t))
.collect();
let mut map = KnownNotes::new();
for (p, cs) in &known {
let set: HashSet<ContentHash> = cs.iter().map(|c| content_hash(c)).collect();
map.insert(p.clone(), set);
}
let actions = classify(sync_files.clone(), &map);
assert_eq!(actions.len(), sync_files.len());
for (f, a) in sync_files.iter().zip(&actions) {
let got_path = match a {
SyncAction::New(sf) | SyncAction::Modified(sf) => &sf.path,
SyncAction::Unchanged(p) => p,
};
assert_eq!(got_path, &f.path);
let entry = map.get(&f.path);
let expect_unchanged = entry.is_some_and(|s| s.contains(&f.content_hash));
let expect_new = entry.is_none_or(|s| s.is_empty());
match a {
SyncAction::Unchanged(_) => assert!(expect_unchanged),
SyncAction::New(_) => assert!(!expect_unchanged && expect_new),
SyncAction::Modified(_) => assert!(!expect_unchanged && !expect_new),
}
}
}
}
use super::LocatorState;
fn locator_state(occ: &str, content: &str) -> LocatorState {
LocatorState {
latest_occurrence_id: occ.into(),
latest_content_hash: content_hash(content),
}
}
#[test]
fn classify_event_new_when_no_state() {
let files = vec![file("a.md", "hello", 1)];
let actions = classify_event(files, &KnownNotes::new(), &HashMap::new());
assert_eq!(actions, vec![SyncAction::New(file("a.md", "hello", 1))]);
}
#[test]
fn classify_event_unchanged_when_event_hash_matches() {
let states = HashMap::from([("a.md".to_string(), locator_state("occ1", "hello"))]);
let files = vec![file("a.md", "hello", 1)];
let actions = classify_event(files, &KnownNotes::new(), &states);
assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
}
#[test]
fn classify_event_modified_when_event_hash_differs() {
let states = HashMap::from([("a.md".to_string(), locator_state("occ1", "old"))]);
let files = vec![file("a.md", "new", 2)];
let actions = classify_event(files, &KnownNotes::new(), &states);
assert_eq!(actions, vec![SyncAction::Modified(file("a.md", "new", 2))]);
}
#[test]
fn classify_event_unchanged_via_legacy_hash() {
let mut legacy = KnownNotes::new();
legacy.insert("a.md".into(), HashSet::from([content_hash("hello")]));
let files = vec![file("a.md", "hello", 1)];
let actions = classify_event(files, &legacy, &HashMap::new());
assert_eq!(actions, vec![SyncAction::Unchanged("a.md".into())]);
}
#[test]
fn classify_event_modified_via_legacy_mismatch() {
let mut legacy = KnownNotes::new();
legacy.insert("a.md".into(), HashSet::from([content_hash("old")]));
let files = vec![file("a.md", "new", 2)];
let actions = classify_event(files, &legacy, &HashMap::new());
assert_eq!(actions, vec![SyncAction::Modified(file("a.md", "new", 2))]);
}
#[test]
fn classify_event_event_state_takes_precedence_over_legacy() {
let states = HashMap::from([("a.md".to_string(), locator_state("occ1", "old"))]);
let mut legacy = KnownNotes::new();
legacy.insert("a.md".into(), HashSet::from([content_hash("hello")]));
let files = vec![file("a.md", "hello", 1)];
let actions = classify_event(files, &legacy, &states);
assert_eq!(
actions,
vec![SyncAction::Modified(file("a.md", "hello", 1))]
);
}
}