//! The pending queue (`sync.pending`): the local changes the server does not //! have yet, from a scan of the vault against `SyncState.bases`. mod common; use common::{TempVault, seed, temp_vault}; use notescraft_engine::sync::queue::{ChangeKind, MAX_QUEUE, PendingChange, pending_changes}; use notescraft_engine::{Registry, Session, SyncState, register_core}; use notescraft_sync::Base; use notescraft_vault::Vault; use serde_json::{Value, json}; /// A vault in a fresh folder. fn vault(name: &str) -> (Vault, TempVault) { let kept = temp_vault(name); let opened = Vault::open(kept.path()).expect("open the temp vault"); (opened, kept) } /// Records the state the last sync would have left: every pair of /// `(path, content)` becomes a base at the hash of its content. fn recorded(vault: &Vault, files: &[(&str, &str)]) { let mut state = SyncState::default(); for (path, content) in files { state.bases.insert((*path).to_owned(), Base { hash: notescraft_sync::hash_hex(content.as_bytes()), seq: 1 }); } state.save(vault).expect("save the state"); } /// A vault where a note is new, an other edited, a third deleted (its base /// stays recorded) and a fourth untouched. fn mixed(name: &str) -> (Vault, TempVault) { let (vault, kept) = vault(name); seed(kept.path(), &[("new.md", "added here"), ("edited.md", "changed now"), ("same.md", "as synced")]); recorded(&vault, &[("edited.md", "was like this"), ("gone.md", "deleted"), ("same.md", "as synced")]); let _ = std::fs::remove_file(kept.path().join("gone.md")); (vault, kept) } /// The paths of the answer, as the tests read them. fn paths(changes: &[PendingChange]) -> Vec<(&str, ChangeKind)> { changes.iter().map(|change| (change.path.as_str(), change.kind)).collect() } #[test] fn the_kinds_follow_the_bases() { let (vault, _kept) = mixed("kinds"); let changes = pending_changes(&vault).expect("pending"); assert_eq!(paths(&changes), [("edited.md", ChangeKind::Edited), ("gone.md", ChangeKind::Deleted), ("new.md", ChangeKind::Added)], "sorted by path"); assert_eq!(changes.iter().find(|change| change.path == "new.md").map(|change| change.size), Some("added here".len() as u64), "the size of the file now"); assert_eq!(changes.iter().find(|change| change.path == "gone.md").map(|change| change.size), Some(0), "a deleted file has no size"); } #[test] fn excluded_folders_are_never_listed() { let (vault, kept) = vault("excluded"); seed(kept.path(), &[("note.md", "real")]); std::fs::create_dir_all(kept.path().join(".obsidian")).expect("obsidian"); std::fs::write(kept.path().join(".obsidian/workspace.json"), "state").expect("obsidian"); std::fs::create_dir_all(kept.path().join(".notescraft/sync")).expect("notescraft"); std::fs::write(kept.path().join(".notescraft/sync/kept.json"), "state").expect("notescraft"); let changes = pending_changes(&vault).expect("pending"); assert_eq!(paths(&changes), [("note.md", ChangeKind::Added)], "only the note travels"); } #[test] fn the_queue_caps_at_500_entries_in_path_order() { let (vault, kept) = vault("cap"); let names: Vec<(String, String)> = (0..600).map(|index| (format!("note-{index:03}.md"), format!("#{index}\n"))).collect(); seed(kept.path(), &names.iter().map(|(path, text)| (path.as_str(), text.as_str())).collect::>()[..]); let changes = pending_changes(&vault).expect("pending"); assert_eq!(changes.len(), MAX_QUEUE); assert_eq!(changes.first().map(|change| change.path.as_str()), Some("note-000.md")); assert_eq!(changes.last().map(|change| change.path.as_str()), Some("note-499.md")); let mut sorted = changes.clone(); sorted.sort_by(|left, right| left.path.cmp(&right.path)); assert_eq!(changes, sorted, "sorted by path"); } #[test] #[cfg(unix)] fn an_unreadable_file_is_skipped() { use std::os::unix::fs::PermissionsExt; let (vault, kept) = vault("unreadable"); seed(kept.path(), &[("good.md", "readable"), ("locked.md", "not")]); let locked = kept.path().join("locked.md"); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("lock the file"); let changes = pending_changes(&vault).expect("the queue survives the unreadable file"); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644)).expect("unlock"); assert_eq!(paths(&changes), [("good.md", ChangeKind::Added)], "the unreadable file is left out"); } #[test] fn the_command_answers_the_same_list() { let (vault, kept) = vault("command"); seed(kept.path(), &[("new.md", "added here"), ("edited.md", "changed now")]); recorded(&vault, &[("edited.md", "was like this")]); drop(vault); // the lock: the session needs it let mut registry = Registry::new(); register_core(&mut registry).expect("register the core commands"); let mut session = Session::open(kept.path()).expect("open the vault"); let answer = registry.run("sync.pending", &mut session, &Value::Null).expect("the command runs"); assert_eq!( answer, json!({ "changes": [ { "path": "edited.md", "kind": "edited", "size": "changed now".len() as u64 }, { "path": "new.md", "kind": "added", "size": "added here".len() as u64 }, ] }), "the same list the function returns, as JSON" ); }