//! Ledger rules, client planning, merge and naming. use std::collections::BTreeMap; use notescraft_sync::{ Action, Base, Changes, Error, FileRecord, Ledger, Merge, Outcome, Write, check_path, check_syncable, conflict_path, hash_hex, merge3, plan, }; fn put(path: &str, body: &str, base_seq: u64) -> Write { Write { path: path.into(), hash: Some(hash_hex(body.as_bytes())), size: body.len() as u64, mtime_ms: 1, base_seq } } fn del(path: &str, base_seq: u64) -> Write { Write { path: path.into(), hash: None, size: 0, mtime_ms: 1, base_seq } } fn rec(path: &str, body: &str, seq: u64, deleted: bool) -> FileRecord { FileRecord { path: path.into(), hash: hash_hex(body.as_bytes()), size: body.len() as u64, mtime_ms: 1, seq, deleted } } #[test] fn hash_matches_the_standard_vector() { assert_eq!(hash_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); } #[test] fn hostile_paths_are_refused() { for bad in ["", "/a.md", "../x.md", "a/../b.md", "a//b.md", "a\\b.md", "./a.md", "a/\u{0}b", "a/"] { assert!(matches!(check_path(bad), Err(Error::InvalidPath(_))), "{bad:?}"); } assert!(check_path(&"a".repeat(1025)).is_err()); assert!(check_path("notes/a b.md").is_ok()); } #[test] fn private_folders_never_sync() { for path in [".notescraft/history/x.md", ".trash/a.md", ".git/config", ".obsidian/app.json"] { assert!(matches!(check_syncable(path), Err(Error::Excluded(_))), "{path}"); } assert!(check_syncable("obsidian/a.md").is_ok()); } #[test] fn ledger_hands_out_increasing_revisions() { let mut ledger = Ledger::new(); assert!(matches!(ledger.apply(put("a.md", "one", 0)), Ok(Outcome::Applied(r)) if r.seq == 1)); assert!(matches!(ledger.apply(put("b.md", "two", 0)), Ok(Outcome::Applied(r)) if r.seq == 2)); assert!(matches!(ledger.apply(put("a.md", "three", 1)), Ok(Outcome::Applied(r)) if r.seq == 3)); assert_eq!(ledger.head(), 3); } #[test] fn identical_write_spends_no_revision() { let mut ledger = Ledger::new(); ledger.apply(put("a.md", "one", 0)).ok(); assert!(matches!(ledger.apply(put("a.md", "one", 0)), Ok(Outcome::Unchanged(_)))); assert_eq!(ledger.head(), 1); } #[test] fn stale_writer_conflicts_and_changes_nothing() { let mut ledger = Ledger::new(); ledger.apply(put("a.md", "one", 0)).ok(); ledger.apply(put("a.md", "two", 1)).ok(); let outcome = ledger.apply(put("a.md", "mine", 1)); assert!(matches!(&outcome, Ok(Outcome::Conflict(r)) if r.seq == 2 && r.hash == hash_hex(b"two"))); assert_eq!(ledger.head(), 2); } #[test] fn edit_beats_delete_both_ways() { let mut ledger = Ledger::new(); ledger.apply(put("a.md", "one", 0)).ok(); ledger.apply(del("a.md", 1)).ok(); // a stale edit over a newer delete wins assert!(matches!(ledger.apply(put("a.md", "edited", 1)), Ok(Outcome::Applied(r)) if !r.deleted && r.seq == 3)); // a stale delete over a newer edit conflicts assert!(matches!(ledger.apply(del("a.md", 1)), Ok(Outcome::Conflict(r)) if !r.deleted)); } #[test] fn delete_rules() { let mut ledger = Ledger::new(); assert!(matches!(ledger.apply(del("none.md", 0)), Err(Error::NotFound(_)))); ledger.apply(put("a.md", "one", 0)).ok(); assert!(matches!(ledger.apply(del("a.md", 1)), Ok(Outcome::Applied(r)) if r.deleted && r.size == 0)); assert!(matches!(ledger.apply(del("a.md", 2)), Ok(Outcome::Unchanged(_)))); } #[test] fn ledger_refuses_bad_input() { let mut ledger = Ledger::new(); assert!(matches!(ledger.apply(put("../a.md", "x", 0)), Err(Error::InvalidPath(_)))); assert!(matches!(ledger.apply(put(".obsidian/a.md", "x", 0)), Err(Error::Excluded(_)))); let mut write = put("a.md", "x", 0); write.hash = Some("XYZ".into()); assert!(matches!(ledger.apply(write), Err(Error::InvalidHash(_)))); } #[test] fn changes_page_and_cursor() { let mut ledger = Ledger::new(); for n in 0..5 { ledger.apply(put(&format!("n{n}.md"), "x", 0)).ok(); } let page: Changes = ledger.changes_since(1, 2); assert_eq!(page.records.iter().map(|r| r.seq).collect::>(), vec![2, 3]); assert!(page.more); assert_eq!(page.cursor(1), 3); let rest = ledger.changes_since(3, 10); assert!(!rest.more); assert_eq!(rest.cursor(3), 5); assert_eq!(ledger.changes_since(9, 10).cursor(9), 9); } #[test] fn ledger_rebuilds_from_records() { let ledger = Ledger::from_records(vec![rec("a.md", "old", 1, false), rec("a.md", "new", 4, false), rec("b.md", "b", 2, true)]); assert_eq!(ledger.head(), 4); assert_eq!(ledger.get("a.md").map(|r| r.seq), Some(4)); assert_eq!(ledger.len(), 2); } fn bases(items: &[(&str, &str, u64)]) -> BTreeMap { items.iter().map(|(p, b, s)| ((*p).to_owned(), Base { hash: hash_hex(b.as_bytes()), seq: *s })).collect() } fn local(items: &[(&str, &str)]) -> BTreeMap { items.iter().map(|(p, b)| ((*p).to_owned(), hash_hex(b.as_bytes()))).collect() } #[test] fn plan_quiet_when_nothing_changed() { assert_eq!(plan(&local(&[("a.md", "x")]), &bases(&[("a.md", "x", 3)]), &[]), Vec::::new()); } #[test] fn plan_uploads_new_and_edited_and_deletes() { let actions = plan(&local(&[("new.md", "n"), ("edit.md", "e2")]), &bases(&[("edit.md", "e1", 4), ("gone.md", "g", 5)]), &[]); assert_eq!( actions, vec![ Action::Upload { path: "edit.md".into(), base_seq: 4 }, Action::DeleteRemote { path: "gone.md".into(), base_seq: 5 }, Action::Upload { path: "new.md".into(), base_seq: 0 }, ] ); } #[test] fn plan_applies_remote_changes_to_untouched_files() { let remote = [rec("a.md", "A2", 7, false), rec("b.md", "b", 8, true), rec("c.md", "C", 9, false)]; let actions = plan(&local(&[("a.md", "A1"), ("b.md", "b")]), &bases(&[("a.md", "A1", 3), ("b.md", "b", 3)]), &remote); assert_eq!(actions, vec![Action::Download(remote[0].clone()), Action::DeleteLocal(remote[1].clone()), Action::Download(remote[2].clone())]); } #[test] fn plan_takes_the_newest_record_per_path() { let remote = [rec("a.md", "mid", 5, false), rec("a.md", "new", 6, false)]; let actions = plan(&local(&[("a.md", "old")]), &bases(&[("a.md", "old", 1)]), &remote); assert_eq!(actions, vec![Action::Download(remote[1].clone())]); } #[test] fn plan_ignores_records_already_seen() { let remote = [rec("a.md", "A", 3, false)]; assert_eq!(plan(&local(&[("a.md", "A")]), &bases(&[("a.md", "A", 3)]), &remote), Vec::::new()); } #[test] fn plan_both_changed_reconciles_and_converges() { let remote = [rec("a.md", "theirs", 7, false), rec("same.md", "S", 8, false)]; let actions = plan(&local(&[("a.md", "mine"), ("same.md", "S")]), &bases(&[("a.md", "base", 3), ("same.md", "old", 3)]), &remote); assert_eq!(actions, vec![Action::Reconcile { remote: remote[0].clone(), base_hash: Some(hash_hex(b"base")) }, Action::Adopt(remote[1].clone())]); } #[test] fn plan_edit_wins_over_delete() { let remote = [rec("a.md", "A", 7, true), rec("b.md", "B2", 8, false)]; let actions = plan(&local(&[("a.md", "A edited")]), &bases(&[("a.md", "A", 3), ("b.md", "B", 3)]), &remote); assert_eq!(actions, vec![Action::Upload { path: "a.md".into(), base_seq: 7 }, Action::Download(remote[1].clone())]); } #[test] fn plan_both_deleted_adopts() { let remote = [rec("a.md", "A", 7, true)]; assert_eq!(plan(&local(&[]), &bases(&[("a.md", "A", 3)]), &remote), vec![Action::Adopt(remote[0].clone())]); } #[test] fn merge_combines_separate_regions() { let base = "one\ntwo\nthree\nfour\nfive\n"; let ours = "ONE\ntwo\nthree\nfour\nfive\n"; let theirs = "one\ntwo\nthree\nfour\nFIVE\n"; assert_eq!(merge3(base, ours, theirs), Merge::Clean("ONE\ntwo\nthree\nfour\nFIVE\n".into())); } #[test] fn merge_handles_inserts_and_deletes_apart() { let base = "a\nb\nc\nd\ne\n"; assert_eq!(merge3(base, "a\nNEW\nb\nc\nd\ne\n", "a\nb\nc\ne\n"), Merge::Clean("a\nNEW\nb\nc\ne\n".into())); } #[test] fn merge_same_change_both_sides_is_clean() { let base = "a\nb\nc\n"; assert_eq!(merge3(base, "a\nX\nc\n", "a\nX\nc\n"), Merge::Clean("a\nX\nc\n".into())); assert_eq!(merge3("a\nb\nc\nd\ne\n", "a\nX\nc\nd\ne\nE\n", "a\nX\nc\nd\ne\n"), Merge::Clean("a\nX\nc\nd\ne\nE\n".into())); } #[test] fn merge_same_line_differently_conflicts() { assert_eq!(merge3("a\nb\nc\n", "a\nX\nc\n", "a\nY\nc\n"), Merge::Conflict); assert_eq!(merge3("a\nb\nc\n", "a\nc\n", "a\nY\nc\n"), Merge::Conflict); } #[test] fn merge_keeps_missing_trailing_newline() { assert_eq!(merge3("a\nb", "A\nb", "a\nB"), Merge::Clean("A\nB".into())); assert_eq!(merge3("a\nb\nc", "A\nb\nc", "a\nb\nC"), Merge::Clean("A\nb\nC".into())); } #[test] fn merge_gives_up_on_huge_input() { let lines = |first: &str, mid: &str, last: &str| -> String { (0..3000) .map(|n| match n { 0 => format!("{first}\n"), 1500 => format!("{mid}\n"), 2999 => format!("{last}\n"), _ => format!("line {n}\n"), }) .collect() }; let base = lines("first", "mid", "last"); // Separate regions would merge, but trimming leaves a ~9M-cell table: refuse. assert_eq!(merge3(&base, &lines("FIRST", "mid", "LAST"), &lines("first", "MID", "last")), Merge::Conflict); // Same shape, small: clean. assert!(matches!(merge3("a\nb\nc\nd\ne\n", "A\nb\nc\nd\ne\n", "a\nb\nC\nd\ne\n"), Merge::Clean(_))); } #[test] fn conflict_names() { assert_eq!(conflict_path("notes/a.md", "iPhone 15!", "2026-10-19"), "notes/a (conflict iPhone15 2026-10-19).md"); assert_eq!(conflict_path("README", "pc", "d"), "README (conflict pc d)"); assert_eq!(conflict_path(".hidden", "pc", "d"), ".hidden (conflict pc d)"); }