//! Integration tests of the property commands (SPEC 6 item 20): one key per //! edit, values validated against their type, buffer-aware writes and the //! read-only refusal. mod common; use common::{on_disk, seed, temp_vault}; use notescraft_engine::{Error, Registry, Session, register_core}; use serde_json::{Value, json}; fn registry() -> Registry { let mut reg = Registry::new(); register_core(&mut reg).expect("registry"); reg } fn properties_of(reg: &Registry, session: &mut Session, path: &str) -> Value { reg.run("note.properties", session, &json!({ "path": path })).unwrap_or_else(|error| panic!("properties of {path}: {error}")) } /// The `properties` array of a report, empty when it is not there. fn rows(report: &Value) -> &[Value] { match report.get("properties").and_then(Value::as_array) { Some(rows) => rows, None => &[], } } fn property_of(reg: &Registry, session: &mut Session, path: &str, key: &str) -> Value { let report = properties_of(reg, session, path); let found = rows(&report).iter().find(|row| row.get("key").and_then(Value::as_str) == Some(key)); found.cloned().unwrap_or(Value::Null) } /// The code of a command failure, when `outcome` is one. fn failure_code(outcome: &Result) -> Option { match outcome { Err(Error::Command { source, .. }) => Some(source.code.clone()), _ => None, } } /// Rows appear in file order, `raw` keeps the text after the colon, and the /// type comes from `.obsidian/types.json` or is inferred from the value. #[test] fn note_properties_lists_rows_in_file_order() { let vault = temp_vault("properties-list"); seed( vault.path(), &[ ("a.md", "---\ntitle: A title\ndone: true\ncount: -2\nat: 2026-01-02T03:04:05Z\ntags:\n - x\n - y\nweird: a: b\n---\nbody\n"), ("b.md", "no frontmatter\n"), ], ); let mut session = Session::open(vault.path()).expect("session"); let reg = registry(); let report = properties_of(®, &mut session, "a.md"); assert_eq!(report.get("has_frontmatter").and_then(Value::as_bool), Some(true), "{report}"); let keys: Vec<&str> = rows(&report).iter().filter_map(|row| row.get("key").and_then(Value::as_str)).collect(); assert_eq!(keys, vec!["title", "done", "count", "at", "tags", "weird"], "{report}"); let title = property_of(®, &mut session, "a.md", "title"); assert_eq!(title.get("name").and_then(Value::as_str), Some("text"), "{}", title.get("name").and_then(Value::as_str).unwrap_or("-")); assert_eq!(title.get("value").and_then(Value::as_str), Some("A title")); assert_eq!(title.get("raw").and_then(Value::as_str), Some("A title")); assert_eq!(property_of(®, &mut session, "a.md", "done").get("name").and_then(Value::as_str), Some("checkbox")); assert_eq!(property_of(®, &mut session, "a.md", "count").get("value").and_then(Value::as_f64), Some(-2.0)); assert_eq!(property_of(®, &mut session, "a.md", "at").get("name").and_then(Value::as_str), Some("datetime")); let tags = property_of(®, &mut session, "a.md", "tags"); assert_eq!(tags.get("name").and_then(Value::as_str), Some("tags")); assert_eq!(tags.get("raw").and_then(Value::as_str), Some("- x\n- y")); assert_eq!(property_of(®, &mut session, "a.md", "weird").get("raw").and_then(Value::as_str), Some("a: b")); let empty = properties_of(®, &mut session, "b.md"); assert_eq!(empty.get("has_frontmatter").and_then(Value::as_bool), Some(false), "{empty}"); assert_eq!(rows(&empty).len(), 0); assert!(reg.run("note.properties", &mut session, &json!({ "path": "no such.md" })).is_err()); } /// An explicit `type` beats `.obsidian/types.json`, which beats inference; /// a type from Obsidian is imported read-only and survives a restart. #[test] fn types_json_import_overrides_the_inference() { let vault = temp_vault("properties-types"); seed( vault.path(), &[ ("a.md", "---\ndone: yes\nscore: 7\n---\n"), (".obsidian/types.json", r#"{"types": {"score": "number", "done": "checkbox", "extra": "multitext"}, "junk": 1}"#), ], ); let mut session = Session::open(vault.path()).expect("session"); let reg = registry(); assert_eq!(property_of(®, &mut session, "a.md", "done").get("name").and_then(Value::as_str), Some("checkbox")); assert_eq!(property_of(®, &mut session, "a.md", "score").get("name").and_then(Value::as_str), Some("number")); // The imported type also applies to a value set through the command. let set = reg.run("note.property_set", &mut session, &json!({ "path": "a.md", "key": "score", "value": "9" })); assert!(set.is_ok(), "{set:?}"); let score = property_of(®, &mut session, "a.md", "score"); assert_eq!(score.get("value").and_then(Value::as_f64), Some(9.0)); assert!(on_disk(vault.path(), "a.md").contains("score: 9"), "an unquoted number was written"); } /// A scalar edit rewrites one line; a list is an array of strings; a plain /// string for a tags property becomes a single item. #[test] fn property_set_rewrites_one_entry() { let vault = temp_vault("properties-set"); seed(vault.path(), &[("a.md", "---\ntitle: Old\n# keep\ntags:\n - x\n - y\n---\nbody [[Link]]\n")]); let mut session = Session::open(vault.path()).expect("session"); let reg = registry(); let set = reg.run("note.property_set", &mut session, &json!({ "path": " a.md ", "key": "title", "value": "New, é" })); assert!(set.is_ok(), "{set:?}"); assert_eq!(on_disk(vault.path(), "a.md"), "---\ntitle: New, é\n# keep\ntags:\n - x\n - y\n---\nbody [[Link]]\n"); assert!(session.index().tags().roots().iter().any(|node| node.tag == "x"), "the index was refreshed"); let listed = reg.run("note.property_set", &mut session, &json!({ "path": "a.md", "key": "tags", "value": ["x", "z"] })); assert!(listed.is_ok(), "{listed:?}"); assert_eq!(on_disk(vault.path(), "a.md"), "---\ntitle: New, é\n# keep\ntags: [x, z]\n---\nbody [[Link]]\n"); let single = reg.run("note.property_set", &mut session, &json!({ "path": "a.md", "key": "tags", "type": "tags", "value": "solo" })); assert!(single.is_ok(), "{single:?}"); assert!(on_disk(vault.path(), "a.md").contains("tags: [solo]"), "a single tags string becomes a one-item list"); // Setting a note without frontmatter creates the block at byte 0. seed(vault.path(), &[("b.md", "body\n")]); let created = reg.run("note.property_set", &mut session, &json!({ "path": "b.md", "key": "k", "value": "v" })); assert!(created.is_ok(), "{created:?}"); assert_eq!(on_disk(vault.path(), "b.md"), "---\nk: v\n---\nbody\n"); // Writing the same value again changes nothing. let same = reg.run("note.property_set", &mut session, &json!({ "path": "b.md", "key": "k", "value": "v" })); let changed = same.as_ref().ok().and_then(|value| value.get("changed").and_then(Value::as_bool)); assert_eq!(changed, Some(false), "{same:?}"); } /// Every kind of invalid value is refused with `invalid_value` (or /// `invalid_params` for an unknown type) and nothing is written. #[test] fn property_set_validates_values() { let vault = temp_vault("properties-validate"); seed(vault.path(), &[("a.md", "---\nnum: 1\ndate: 2026-01-02\ndone: false\nlist: [a]\n---\nbody\n")]); let mut session = Session::open(vault.path()).expect("session"); let reg = registry(); let before = on_disk(vault.path(), "a.md"); let bad: Vec = vec![ json!({ "path": "a.md", "key": "num", "type": "number", "value": "9x" }), json!({ "path": "a.md", "key": "num", "type": "number", "value": true }), json!({ "path": "a.md", "key": "date", "type": "date", "value": "2026-02-30" }), json!({ "path": "a.md", "key": "date", "type": "date", "value": "junk" }), json!({ "path": "a.md", "key": "at", "type": "datetime", "value": "2026-01-02" }), json!({ "path": "a.md", "key": "done", "type": "checkbox", "value": "yes" }), json!({ "path": "a.md", "key": "list", "type": "list", "value": [1] }), json!({ "path": "a.md", "key": "list", "type": "list", "value": 3 }), json!({ "path": "a.md", "key": "k", "type": "banana", "value": "v" }), json!({ "path": "a.md", "key": "k", "value": null }), json!({ "path": "a.md", "key": "k\n", "value": "v" }), ]; for (index, params) in bad.iter().enumerate() { let outcome = reg.run("note.property_set", &mut session, params); let Err(Error::Command { source: failure, .. }) = outcome else { panic!("row {index} of {params} should fail: {outcome:?}") }; assert!(failure.code == "invalid_value" || failure.code == "invalid_params", "row {index}: {failure}"); assert_eq!(on_disk(vault.path(), "a.md"), before, "row {index}: nothing was written"); } let long = "x".repeat(notescraft_markdown::MAX_ENTRY_BYTES + 1); assert!(reg.run("note.property_set", &mut session, &json!({ "path": "a.md", "key": "big", "value": long })).is_err()); assert!(!on_disk(vault.path(), "a.md").contains("big"), "the oversized value was refused"); } /// Remove and rename touch only their entry; an absent key is `changed: false`, /// an unusable new name is refused. #[test] fn property_remove_and_rename_edit_one_entry() { let vault = temp_vault("properties-remove-rename"); seed(vault.path(), &[("a.md", "---\na: 1\nlist:\n - x\n - y\nb: 2\n# kept\n---\nbody\n")]); let mut session = Session::open(vault.path()).expect("session"); let reg = registry(); let removed = reg.run("note.property_remove", &mut session, &json!({ "path": "a.md", "key": "list" })); assert!(removed.is_ok(), "{removed:?}"); assert_eq!(on_disk(vault.path(), "a.md"), "---\na: 1\nb: 2\n# kept\n---\nbody\n"); let renamed = reg.run("note.property_rename", &mut session, &json!({ "path": "a.md", "from": "a", "to": "first" })); assert!(renamed.is_ok(), "{renamed:?}"); assert_eq!(on_disk(vault.path(), "a.md"), "---\nfirst: 1\nb: 2\n# kept\n---\nbody\n"); let removed = reg.run("note.property_remove", &mut session, &json!({ "path": "a.md", "key": "absent" })); let changed = removed.as_ref().ok().and_then(|value| value.get("changed").and_then(Value::as_bool)); assert_eq!(changed, Some(false), "{removed:?}"); let renamed = reg.run("note.property_rename", &mut session, &json!({ "path": "a.md", "from": "b", "to": "b" })); let changed = renamed.as_ref().ok().and_then(|value| value.get("changed").and_then(Value::as_bool)); assert_eq!(changed, Some(false), "{renamed:?}"); let bad = reg.run("note.property_rename", &mut session, &json!({ "path": "a.md", "from": "b", "to": "-no" })); assert_eq!(failure_code(&bad), Some(String::from("invalid_value")), "{bad:?}"); assert_eq!(on_disk(vault.path(), "a.md"), "---\nfirst: 1\nb: 2\n# kept\n---\nbody\n"); let missing = reg.run("note.property_remove", &mut session, &json!({ "path": "gone.md", "key": "a" })); assert_eq!(failure_code(&missing), Some(String::from("not_found")), "{missing:?}"); } /// With a buffer open the edit becomes one undoable buffer edit left dirty; /// the disk only changes when the buffer is saved. #[test] fn a_property_edit_goes_through_an_open_buffer() { let vault = temp_vault("properties-buffer"); seed(vault.path(), &[("a.md", "---\ntitle: Old\n---\nbody #tag\n")]); let mut session = Session::open(vault.path()).expect("session"); session.open_buffer("a.md").expect("buffer"); session.buffer_mut("a.md").expect("buffer").edit(on_disk(vault.path(), "a.md").len()..on_disk(vault.path(), "a.md").len(), " tail").expect("buffer edit"); let reg = registry(); let set = reg.run("note.property_set", &mut session, &json!({ "path": "a.md", "key": "title", "value": "New" })); assert!(set.is_ok(), "{set:?}"); let buffer = session.buffer("a.md").expect("open buffer"); assert!(buffer.text().contains("title: New"), "{}", buffer.text()); assert!(buffer.is_dirty(), "the buffer edit marks the note dirty"); assert_eq!(on_disk(vault.path(), "a.md"), "---\ntitle: Old\n---\nbody #tag\n", "the disk still holds what was saved last"); assert!(buffer.can_undo(), "one undo step covers the property edit"); let edit_area = session.buffer_mut("a.md").expect("buffer"); edit_area.undo().expect("undo"); assert!(edit_area.text().contains("title: Old"), "undo restores the entry: {}", edit_area.text()); assert!(edit_area.text().ends_with(" tail"), "undo keeps the editor's own edit"); let saved = reg.run("buffer.save", &mut session, &json!({ "path": "a.md" })); assert!(saved.is_ok(), "{saved:?}"); let after_undo = reg.run("note.property_set", &mut session, &json!({ "path": "a.md", "key": "title", "value": "New" })); assert!(after_undo.is_ok(), "{after_undo:?}"); } /// A read-only session refuses the three writing commands; reads stay fine. #[test] fn a_read_only_session_refuses_the_property_writes() { let vault = temp_vault("properties-readonly"); seed(vault.path(), &[("a.md", "---\ntitle: Old\n---\nbody\n")]); let mut session = Session::open_read_only(vault.path()).expect("read-only session"); let reg = registry(); for (id, params) in [ ("note.property_set", json!({ "path": "a.md", "key": "title", "value": "New" })), ("note.property_remove", json!({ "path": "a.md", "key": "title" })), ("note.property_rename", json!({ "path": "a.md", "from": "title", "to": "name" })), ] { assert!(matches!(reg.run(id, &mut session, ¶ms), Err(Error::Disabled(_))), "{id} must be refused"); } assert!(reg.run("note.properties", &mut session, &json!({ "path": "a.md" })).is_ok(), "reading still works"); assert_eq!(on_disk(vault.path(), "a.md"), "---\ntitle: Old\n---\nbody\n"); }