//! The write commands of bases: `base.write`, `base.create`, `base.view.*`, `base.row.set`. mod common; use common::{on_disk, seed, temp_vault}; use notescraft_engine::{Error, Registry, Session, register_core}; use serde_json::{Value, json}; const BODY: &str = "# Dune\n\nBody text, [[Other]] and #tag\n\n- [ ] task\n"; const NOTE: &str = "---\ntype: book\nrating: 3\ndue: 2026-01-02\nyear: 1965\n---\n# Dune\n\nBody text, [[Other]] and #tag\n\n- [ ] task\n"; const BASE: &str = "filters: 'note.type == \"book\"'\nowner: me\nviews:\n - type: table\n name: A\n columnSize:\n file.name: 200\n - type: cards\n name: B\n cardSize: 300\n"; const TYPES: &str = "{\"types\":{\"due\":\"date\",\"year\":\"text\",\"rating\":\"number\"}}"; struct Fixture { _vault: common::TempVault, root: std::path::PathBuf, session: Session, reg: Registry, } impl Fixture { fn run(&mut self, id: &str, params: &Value) -> Value { self.reg.run(id, &mut self.session, params).unwrap_or_else(|error| panic!("{id}: {error:?}")) } fn fail(&mut self, id: &str, params: &Value) -> String { match self.reg.run(id, &mut self.session, params).expect_err("must fail") { Error::Command { source, .. } => source.code, other => format!("{other:?}"), } } } fn fixture(name: &str, read_only: bool) -> Fixture { let vault = temp_vault(name); seed( vault.path(), &[ ("Books/Dune.md", NOTE), ("Books/Other.md", "---\ntype: book\n---\nlinks [[Dune]]\n"), ("Misc/Thing.md", "---\ntype: thing\ntags: [book]\n---\nx\n"), ("Books.base", BASE), (".obsidian/types.json", TYPES), ], ); let session = if read_only { Session::open_read_only(vault.path()) } else { Session::open(vault.path()) }.expect("open"); let mut reg = Registry::new(); register_core(&mut reg).expect("register"); Fixture { root: vault.path().to_path_buf(), _vault: vault, session, reg } } #[test] fn row_set_keeps_the_body_and_honours_types() { let mut f = fixture("bw_row", false); let done = f.run("base.row.set", &json!({ "path": "Books/Dune.md", "property": "note.rating", "value": 5 })); assert_eq!(done["changed"], true); let text = on_disk(&f.root, "Books/Dune.md"); assert!(text.contains("rating: 5.0\n"), "{text}"); assert!(text.ends_with(BODY), "the body is byte-identical"); // `year` is text in types.json: a number-looking value stays a string. f.run("base.row.set", &json!({ "path": "Books/Dune.md", "property": "year", "value": "1966" })); let text = on_disk(&f.root, "Books/Dune.md"); assert!(text.contains("year: '1966'"), "{text}"); // the index follows, so the next query sees the cell let q = f.run("base.query", &json!({ "yaml": "views:\n - type: table\n order: [note.rating]\n", "limit": 5 })); assert!(q["rows"].as_array().expect("rows").iter().any(|r| r["cells"][0] == 5.0)); } #[test] fn invalid_values_change_nothing() { let mut f = fixture("bw_invalid", false); assert_eq!(f.fail("base.row.set", &json!({ "path": "Books/Dune.md", "property": "due", "value": "not a date" })), "invalid_value"); assert_eq!(f.fail("base.row.set", &json!({ "path": "Books/Dune.md", "property": "rating", "value": "high" })), "invalid_value"); assert_eq!(f.fail("base.row.set", &json!({ "path": "Books/Dune.md", "property": "file.size", "value": 1 })), "read_only_property"); assert_eq!(f.fail("base.row.set", &json!({ "path": "Books/Dune.md", "property": "formula.x", "value": 1 })), "read_only_property"); assert_eq!(on_disk(&f.root, "Books/Dune.md"), NOTE); } #[test] fn an_open_note_takes_the_edit_as_one_undo_step() { let mut f = fixture("bw_undo", false); f.session.open_buffer("Books/Dune.md").expect("open buffer"); f.run("base.row.set", &json!({ "path": "Books/Dune.md", "property": "rating", "value": 1 })); let buffer = f.session.buffer("Books/Dune.md").expect("buffer"); assert!(buffer.is_dirty() && buffer.text().contains("rating: 1.0\n")); assert_eq!(on_disk(&f.root, "Books/Dune.md"), NOTE, "disk untouched until saved"); let buffer = f.session.buffer_mut("Books/Dune.md").expect("buffer"); assert!(buffer.undo().expect("undo")); assert_eq!(buffer.text(), NOTE); } #[test] fn file_name_renames_and_updates_links() { let mut f = fixture("bw_rename", false); let done = f.run("base.row.set", &json!({ "path": "Books/Dune.md", "property": "file.name", "value": "Arrakis" })); assert_eq!(done["path"], "Books/Arrakis.md"); assert!(f.root.join("Books/Arrakis.md").is_file() && !f.root.join("Books/Dune.md").exists()); assert!(on_disk(&f.root, "Books/Other.md").contains("[[Arrakis]]")); } #[test] fn view_edits_keep_unknown_keys() { let mut f = fixture("bw_views", false); f.run("base.view.add", &json!({ "path": "Books.base", "type": "list", "name": "C" })); f.run("base.view.duplicate", &json!({ "path": "Books.base", "view": "A" })); f.run("base.view.rename", &json!({ "path": "Books.base", "view": 1, "name": "A2" })); f.run("base.view.reorder", &json!({ "path": "Books.base", "view": "C", "to": 0 })); let read = f.run("base.read", &json!({ "path": "Books.base" })); let names: Vec<&str> = read["views"].as_array().expect("views").iter().map(|v| v["name"].as_str().unwrap_or_default()).collect(); assert_eq!(names, ["C", "A", "A2", "B"]); f.run("base.view.remove", &json!({ "path": "Books.base", "view": "B" })); let text = on_disk(&f.root, "Books.base"); assert!(text.contains("owner: me") && !text.contains("cardSize") && text.contains("columnSize"), "{text}"); let read = f.run("base.read", &json!({ "path": "Books.base" })); assert_eq!(read["owner"], "me"); assert_eq!(read["views"][2]["columnSize"]["file.name"], 200, "the copy keeps the unknown keys of its source"); assert_eq!(f.fail("base.view.remove", &json!({ "path": "Books.base", "view": "Nope" })), "not_found"); assert_eq!(f.fail("base.view.reorder", &json!({ "path": "Books.base", "view": 0, "to": 9 })), "invalid_params"); } #[test] fn write_validates_and_never_replaces_an_unreadable_file() { let mut f = fixture("bw_write", false); assert_eq!(f.fail("base.write", &json!({ "path": "Books.base", "yaml": "views: [\n" })), "invalid_params"); assert_eq!(on_disk(&f.root, "Books.base"), BASE); let done = f.run("base.write", &json!({ "path": "New.base", "yaml": "views:\n - type: table\n" })); assert_eq!(done["changed"], true); assert_eq!(f.run("base.write", &json!({ "path": "New.base", "yaml": "views:\n - type: table\n" }))["changed"], false); assert_eq!(f.run("base.list", &Value::Null).as_array().expect("list").len(), 2, "the new base is listed"); std::fs::write(f.root.join("Bin.base"), [0xff, 0xfe, 0x00, 0x9f]).expect("write binary"); assert_eq!(f.fail("base.write", &json!({ "path": "Bin.base", "yaml": "views: []\n" })), "unreadable"); assert_eq!(std::fs::read(f.root.join("Bin.base")).expect("read"), [0xff, 0xfe, 0x00, 0x9f]); assert_eq!(f.fail("base.write", &json!({ "path": "Books/Dune.md", "yaml": "views: []\n" })), "invalid_path"); } #[test] fn create_seeds_a_folder_and_tag_filter() { let mut f = fixture("bw_create", false); let made = f.run("base.create", &json!({ "folder": "Books", "tag": "#book" })); assert_eq!(made["path"], "Books/Untitled.base"); let text = on_disk(&f.root, "Books/Untitled.base"); assert!(text.contains("file.inFolder(\"Books\")") && text.contains("file.hasTag(\"book\")"), "{text}"); assert_eq!(f.run("base.create", &json!({ "folder": "Books" }))["path"], "Books/Untitled 1.base"); let tagged = f.run("base.create", &json!({ "path": "Tagged.base", "tag": "book" })); let q = f.run("base.query", &json!({ "path": tagged["path"] })); assert_eq!(q["total"], 1, "only Misc/Thing.md has the tag"); assert_eq!(f.fail("base.create", &json!({ "path": "Tagged.base" })), "exists"); assert_eq!(f.fail("base.create", &json!({ "tag": "a\"b" })), "invalid_params"); } #[test] fn a_read_only_session_refuses_every_write() { let mut f = fixture("bw_ro", true); for (id, params) in [ ("base.write", json!({ "path": "Books.base", "yaml": "views: []\n" })), ("base.create", json!({})), ("base.view.add", json!({ "path": "Books.base" })), ("base.row.set", json!({ "path": "Books/Dune.md", "property": "rating", "value": 1 })), ] { assert!(f.reg.run(id, &mut f.session, ¶ms).is_err(), "{id}"); } assert_eq!(on_disk(&f.root, "Books.base"), BASE); assert_eq!(on_disk(&f.root, "Books/Dune.md"), NOTE); assert_eq!(f.run("base.query", &json!({ "path": "Books.base" }))["total"], 2, "reads still work"); }