//! Bookmark commands share the registry and atomic private vault state. use notescraft_engine::{Error, Registry, Session, register_core}; use serde_json::{Value, json}; mod common; fn registry() -> Registry { let mut registry = Registry::new(); register_core(&mut registry).expect("register commands"); registry } #[test] fn add_list_remove_and_dedupe_by_path_and_heading() { let vault = common::temp_vault("bookmark-commands"); common::seed(vault.path(), &[("folder/note.md", "# Heading\n"), ("map.canvas", "{}")]); let mut session = Session::open(vault.path()).expect("session"); let registry = registry(); let list = |session: &mut Session| registry.run("bookmarks.list", session, &Value::Null).expect("list"); assert_eq!(list(&mut session), json!({"bookmarks": []})); let note = json!({"path": "folder/note.md", "title": "My note"}); assert_eq!(registry.run("bookmarks.add", &mut session, ¬e).expect("add")["added"], true); assert_eq!(registry.run("bookmarks.add", &mut session, ¬e).expect("dedupe")["added"], false); let heading = json!({"path": "folder/note.md", "heading": "Heading", "title": "Jump here"}); registry.run("bookmarks.add", &mut session, &heading).expect("heading"); registry.run("bookmarks.add", &mut session, &json!({"path":"map.canvas"})).expect("canvas"); assert_eq!( list(&mut session), json!({"bookmarks": [ {"path":"folder/note.md", "heading":null, "title":"My note"}, {"path":"folder/note.md", "heading":"Heading", "title":"Jump here"}, {"path":"map.canvas", "heading":null, "title":null} ]}) ); let remove = json!({"path":"folder/note.md", "heading":"Heading"}); assert_eq!(registry.run("bookmarks.remove", &mut session, &remove).expect("remove")["removed"], true); assert_eq!(registry.run("bookmarks.remove", &mut session, &remove).expect("absent")["removed"], false); assert_eq!(list(&mut session)["bookmarks"].as_array().expect("array").len(), 2); } #[test] fn bookmarks_persist_across_session_reopen_and_removal() { let vault = common::temp_vault("bookmark-reopen"); common::seed(vault.path(), &[("note.md", "# Heading\n")]); let registry = registry(); let params = json!({"path":"note.md", "heading":"Heading", "title":"Saved"}); let expected; { let mut session = Session::open(vault.path()).expect("session"); registry.run("bookmarks.add", &mut session, ¶ms).expect("add"); expected = registry.run("bookmarks.list", &mut session, &Value::Null).expect("list"); assert!(vault.path().join(".notescraft/bookmarks.json").is_file()); assert!(!vault.path().join(".obsidian").exists()); } { let mut session = Session::open(vault.path()).expect("reopen"); assert_eq!(registry.run("bookmarks.list", &mut session, &Value::Null).expect("list"), expected); registry.run("bookmarks.remove", &mut session, &json!({"path":"note.md", "heading":"Heading"})).expect("remove"); } let mut session = Session::open(vault.path()).expect("reopen removal"); assert_eq!(registry.run("bookmarks.list", &mut session, &Value::Null).expect("list"), json!({"bookmarks":[]})); } #[test] fn read_only_sessions_list_but_cannot_change_bookmarks() { let vault = common::temp_vault("bookmark-read-only"); common::seed(vault.path(), &[("note.md", "# Note\n")]); let registry = registry(); { let mut session = Session::open(vault.path()).expect("session"); registry.run("bookmarks.add", &mut session, &json!({"path":"note.md"})).expect("add"); } let mut session = Session::open_read_only(vault.path()).expect("read only"); assert_eq!(registry.run("bookmarks.list", &mut session, &Value::Null).expect("list")["bookmarks"].as_array().expect("array").len(), 1); for id in ["bookmarks.add", "bookmarks.remove"] { assert!(matches!(registry.run(id, &mut session, &json!({"path":"note.md"})), Err(Error::Disabled(_)))); } } #[test] fn invalid_targets_and_fields_never_write_state() { let vault = common::temp_vault("bookmark-invalid"); common::seed(vault.path(), &[("note.md", "# Note\n")]); let mut session = Session::open(vault.path()).expect("session"); let registry = registry(); for params in [ json!({"path":"../note.md"}), json!({"path":"/note.md"}), json!({"path":".obsidian/note.md"}), json!({"path":".notescraft/note.md"}), json!({"path":"missing.md"}), json!({"path":"./note.md"}), json!({"path":"note.md", "heading":""}), json!({"path":"note.md", "title":""}), json!({"path":"note.md", "heading":42}), json!({"path":"note.md", "title":"x".repeat(1025)}), ] { assert!(registry.run("bookmarks.add", &mut session, ¶ms).is_err(), "{params}"); } assert!(!vault.path().join(".notescraft/bookmarks.json").exists()); } #[test] fn damaged_private_state_is_reported_and_preserved() { let vault = common::temp_vault("bookmark-corrupt"); common::seed(vault.path(), &[("note.md", "# Note\n")]); let mut session = Session::open(vault.path()).expect("session"); session.vault().write_private(".notescraft/bookmarks.json", b"broken json").expect("seed corrupt state"); let registry = registry(); for id in ["bookmarks.list", "bookmarks.add", "bookmarks.remove"] { let params = if id == "bookmarks.list" { Value::Null } else { json!({"path":"note.md"}) }; assert!(registry.run(id, &mut session, ¶ms).is_err()); } assert_eq!(session.vault().read(".notescraft/bookmarks.json").expect("preserved"), b"broken json"); } #[test] fn note_rename_moves_bookmark_targets_and_preserves_heading_title() { let vault = common::temp_vault("bookmark-rename"); common::seed(vault.path(), &[("note.md", "# Heading\n"), ("taken.md", "taken")]); let mut session = Session::open(vault.path()).expect("session"); let registry = registry(); registry.run("bookmarks.add", &mut session, &json!({"path":"note.md", "heading":"Heading", "title":"Saved"})).expect("add"); assert!(registry.run("note.rename", &mut session, &json!({"from":"note.md", "to":"taken.md"})).is_err()); assert_eq!(registry.run("bookmarks.list", &mut session, &Value::Null).expect("list")["bookmarks"][0]["path"], "note.md"); registry.run("note.rename", &mut session, &json!({"from":"note.md", "to":"folder/moved.md"})).expect("move"); assert_eq!( registry.run("bookmarks.list", &mut session, &Value::Null).expect("list"), json!({"bookmarks":[ {"path":"folder/moved.md", "heading":"Heading", "title":"Saved"} ]}) ); drop(session); let mut reopened = Session::open(vault.path()).expect("reopen"); assert_eq!(registry.run("bookmarks.list", &mut reopened, &Value::Null).expect("list")["bookmarks"][0]["path"], "folder/moved.md"); } #[test] fn deleted_file_bookmark_can_still_be_removed() { let vault = common::temp_vault("bookmark-deleted"); common::seed(vault.path(), &[("note.md", "# Note\n")]); let mut session = Session::open(vault.path()).expect("session"); let registry = registry(); registry.run("bookmarks.add", &mut session, &json!({"path":"note.md"})).expect("add"); session.delete_note("note.md").expect("delete note"); assert_eq!(registry.run("bookmarks.remove", &mut session, &json!({"path":"note.md"})).expect("remove")["removed"], true); } #[test] fn invalid_stored_bookmarks_never_get_repaired_or_overwritten() { let vault = common::temp_vault("bookmark-state-invalid"); common::seed(vault.path(), &[("note.md", "# Note\n")]); let mut session = Session::open(vault.path()).expect("session"); let registry = registry(); for state in [ json!([{"path":"../note.md"}]), json!([{"path":"note.md"}, {"path":"note.md"}]), json!([{"path":"note.md", "title":" "}]), json!(vec![json!({"path":"note.md"}); 1025]), ] { let bytes = serde_json::to_vec(&state).expect("json"); session.vault().write_private(".notescraft/bookmarks.json", &bytes).expect("seed state"); assert!(registry.run("bookmarks.list", &mut session, &Value::Null).is_err(), "{state}"); assert!(registry.run("bookmarks.add", &mut session, &json!({"path":"note.md"})).is_err()); assert_eq!(session.vault().read(".notescraft/bookmarks.json").expect("preserved"), bytes); } }