Files
Marcos Paulo 224ae2718a
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: index keeps note properties, size and times; base.* engine commands
Index: frontmatter properties (200 per note, 4 KiB per value, 500 list items, 3 levels), size and times; persistent cache format version 2 (old caches are replaced). Engine: base.list, base.read, base.properties, base.query over the index (limit 200 default, over 2000 is an error), base.write (validated; never replaces an unreadable file), base.create, base.view.add|remove|rename|duplicate|reorder, base.row.set (types.json, one undo step in an open buffer, file.name renames with links; other file.* and formula.* are read-only). Bases tasks 9, 10, 11 (Claude Sonnet). Verified with the full cargo xtask ci on a copy of the tree (4526 tests, clippy -D warnings, layers, tree, i18n). Gaps: no result cache keyed by index generation, no 15k-note perf test, base.row.set writes numbers as 5.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 03:23:00 -03:00

166 lines
8.6 KiB
Rust

//! 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<Session>,
}
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, &params).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");
}