Files
Marcos Paulo 5e8020aa38
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: Obsidian basics, editor previews, graph rework, query blocks, stacked tabs, pop-out windows
Editor previews (colour swatches, arrows, embeds), the 18 obsidian-basics tasks (find and replace
with regex, back/forward, auto-pair, footnotes, folding, search operators, split view, PDF/audio/
video embeds, spelling, drag and drop, pinned tabs, settings) and round 2: ```query blocks, stacked
tabs, pop-out windows, tasks ticked inside embeds, text around an embed, embeds that follow edits,
block menu shortcut. New crates: notescraft-regex (linear-time engine) and notescraft-syntax.

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

381 lines
20 KiB
Rust

//! The `batch.*` commands: queue a scope, step through it, decide and apply.
mod common;
use std::path::Path;
use std::time::Duration;
use common::{on_disk, seed, temp_vault};
use notescraft_engine::{Error, Registry, Session, SessionConfig, register_core};
use serde_json::{Value, json};
/// A note whose `json` block is not formatted yet.
const JSON_NOTE: &str = "# J\n\n```json\n{\"b\":1,\"a\":2}\n```\n";
/// The same note after the block is formatted.
const JSON_FORMATTED: &str = "# J\n\n```json\n{\n \"b\": 1,\n \"a\": 2\n}\n```\n";
/// An unlabeled fence holding JSON: the change needs review.
const UNLABELED_NOTE: &str = "# U\n\n```\n{\"b\":1}\n```\n";
/// A single trailing space: `trailing-space` fires with one safe edit.
const LINT_NOTE: &str = "# L\n\ntrailing space \n";
const LINT_FIXED: &str = "# L\n\ntrailing space\n";
const CLEAN_NOTE: &str = "# C\n\na clean note\n";
const MAIN_NOTES: &[(&str, &str)] = &[("json.md", JSON_NOTE), ("unlabeled.md", UNLABELED_NOTE), ("lint.md", LINT_NOTE), ("clean.md", CLEAN_NOTE)];
/// A registry and a session on a seeded vault.
fn setup(name: &str, files: &[(&str, &str)]) -> (Registry<Session>, Session, common::TempVault) {
let vault = temp_vault(name);
seed(vault.path(), files);
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let session = Session::open(vault.path()).expect("open");
(registry, session, vault)
}
fn run(registry: &Registry<Session>, session: &mut Session, id: &str, params: &Value) -> Value {
registry.run(id, session, params).unwrap_or_else(|error| panic!("{id}: {error:?}"))
}
/// Steps until the batch finishes and returns the last progress.
fn drain(registry: &Registry<Session>, session: &mut Session) -> Value {
loop {
let progress = run(registry, session, "batch.step", &json!({ "budget_ms": 1 }));
if progress["finished"] == json!(true) {
return progress;
}
}
}
/// The bytes of every note, to prove the batch wrote nothing.
fn snapshot(vault: &Path, files: &[(&str, &str)]) -> Vec<(String, Vec<u8>)> {
files.iter().map(|(path, _)| ((*path).to_owned(), std::fs::read(vault.join(path)).unwrap_or_default())).collect()
}
/// The error code of a refused command call.
fn code(result: Result<Value, Error>) -> String {
match result {
Err(Error::Command { source, .. }) => source.code.clone(),
Err(other) => panic!("expected a command error, got {other:?}"),
Ok(value) => panic!("expected a refusal, got {value}"),
}
}
/// The row of `path` in a `batch.proposals` answer.
fn row(rows: &[Value], path: &str) -> Value {
rows.iter().find(|row| row["path"] == json!(path)).cloned().unwrap_or_else(|| panic!("no row for {path}"))
}
#[test]
fn start_and_step_review_without_writing() {
let (registry, mut session, vault) = setup("batch-review", MAIN_NOTES);
let before = snapshot(vault.path(), MAIN_NOTES);
let started = run(&registry, &mut session, "batch.start", &json!({ "scope": "vault" }));
assert_eq!(started, json!({ "total": 4 }));
let progress = drain(&registry, &mut session);
assert_eq!(progress["total"], json!(4));
assert_eq!(progress["done"], json!(4));
assert_eq!(progress["finished"], json!(true));
assert_eq!(progress["proposed"], json!(2), "json.md and lint.md");
assert_eq!(progress["needs_review"], json!(1), "unlabeled.md");
assert_eq!(progress["clean"], json!(1), "clean.md");
assert_eq!(progress["not_edited"], json!(0));
assert_eq!(snapshot(vault.path(), MAIN_NOTES), before, "reading the batch writes nothing");
assert_eq!(run(&registry, &mut session, "batch.status", &Value::Null), progress, "status changes nothing");
let list = run(&registry, &mut session, "batch.proposals", &Value::Null);
let rows = list["proposals"].as_array().expect("rows").clone();
assert_eq!(rows.len(), 4);
assert_eq!(row(&rows, "json.md")["status"], json!("proposed"));
assert_eq!(row(&rows, "json.md")["changes"], json!(1));
assert_eq!(row(&rows, "unlabeled.md")["status"], json!("needs_review"));
assert_eq!(row(&rows, "clean.md")["status"], json!("clean"));
assert_eq!(row(&rows, "clean.md")["changes"], json!(0));
assert_eq!(list["truncated"], json!(false));
let only_clean = run(&registry, &mut session, "batch.proposals", &json!({ "status": "clean" }));
assert_eq!(only_clean["proposals"].as_array().expect("rows").len(), 1);
let full = run(&registry, &mut session, "batch.proposal", &json!({ "path": "json.md" }));
assert_eq!(full["path"], json!("json.md"));
assert_eq!(full["status"], json!("Proposed"));
let change = &full["changes"][0];
let at = JSON_NOTE.find("{\"b\":1,\"a\":2}").expect("the JSON core");
assert_eq!(change["id"], json!(0));
assert_eq!(change["kind"], json!("Json"));
assert_eq!(change["range"], json!({ "start": at, "end": at + "{\"b\":1,\"a\":2}".len() }));
assert_eq!(change["before"], json!("{\"b\":1,\"a\":2}"));
assert_eq!(change["after"], json!("{\n \"b\": 1,\n \"a\": 2\n}"));
assert_eq!(change["safe"], json!(true));
assert_eq!(change["decision"], json!("Pending"));
assert_eq!(change["rule"], Value::Null);
let lint_full = run(&registry, &mut session, "batch.proposal", &json!({ "path": "lint.md" }));
let lint_change = &lint_full["changes"][0];
assert_eq!(lint_change["kind"], json!("Lint"));
assert_eq!(lint_change["rule"], json!("trailing-space"));
assert_eq!(lint_change["safe"], json!(true));
}
#[test]
fn decide_then_apply_writes_only_the_accepted_changes() {
let (registry, mut session, vault) = setup("batch-apply", MAIN_NOTES);
run(&registry, &mut session, "batch.start", &json!({ "scope": "vault" }));
drain(&registry, &mut session);
let accepted = run(&registry, &mut session, "batch.decide", &json!({ "path": "json.md", "change_id": 0, "decision": "accept" }));
assert_eq!(accepted["decided"], json!(1));
let skipped = run(&registry, &mut session, "batch.decide", &json!({ "path": "lint.md", "decision": "skip" }));
assert_eq!(skipped["decided"], json!(1));
let applied = run(&registry, &mut session, "batch.apply", &Value::Null);
assert_eq!(applied["written"], json!(1));
assert_eq!(applied["skipped"], json!([]));
assert_eq!(applied["failed"], json!([]));
assert_eq!(on_disk(vault.path(), "json.md"), JSON_FORMATTED);
assert_eq!(on_disk(vault.path(), "lint.md"), LINT_NOTE, "the skipped fix is not applied");
assert_eq!(on_disk(vault.path(), "unlabeled.md"), UNLABELED_NOTE, "pending is not applied");
assert_eq!(on_disk(vault.path(), "clean.md"), CLEAN_NOTE);
let clean = run(&registry, &mut session, "batch.proposals", &json!({ "status": "clean" }));
let rows = clean["proposals"].as_array().expect("rows").clone();
assert_eq!(rows.len(), 2, "json.md became clean; clean.md already was");
assert_eq!(row(&rows, "json.md")["changes"], json!(0), "the written note has no changes left");
let again = run(&registry, &mut session, "batch.apply", &Value::Null);
assert_eq!(again["written"], json!(0));
}
#[test]
fn all_true_accepts_every_change_of_the_proposed_notes() {
let (registry, mut session, vault) = setup("batch-all", MAIN_NOTES);
run(&registry, &mut session, "batch.start", &json!({ "scope": "vault" }));
drain(&registry, &mut session);
let decided = run(&registry, &mut session, "batch.decide", &json!({ "all": true, "decision": "accept" }));
assert_eq!(decided["decided"], json!(2), "the two proposed notes, not the needs-review one");
let applied = run(&registry, &mut session, "batch.apply", &Value::Null);
assert_eq!(applied["written"], json!(2));
assert_eq!(on_disk(vault.path(), "json.md"), JSON_FORMATTED);
assert_eq!(on_disk(vault.path(), "lint.md"), LINT_FIXED);
assert_eq!(on_disk(vault.path(), "unlabeled.md"), UNLABELED_NOTE, "needs review waits for a per-change decision");
}
#[test]
fn apply_skips_a_note_edited_after_the_check() {
let files: &[(&str, &str)] = &[("a.md", JSON_NOTE), ("b.md", JSON_NOTE), ("lint.md", LINT_NOTE)];
let (registry, mut session, vault) = setup("batch-stale", files);
run(&registry, &mut session, "batch.start", &json!({ "scope": "vault" }));
drain(&registry, &mut session);
for path in ["a.md", "b.md"] {
run(&registry, &mut session, "batch.decide", &json!({ "path": path, "decision": "accept" }));
}
let edited = "# A\n\nedited by hand\n";
std::fs::write(vault.path().join("a.md"), edited).expect("edit a.md after the check");
let applied = run(&registry, &mut session, "batch.apply", &Value::Null);
assert_eq!(applied["written"], json!(1));
assert_eq!(applied["skipped"], json!([{ "path": "a.md", "reason": "changed since the check" }]));
assert_eq!(applied["failed"], json!([]));
assert_eq!(on_disk(vault.path(), "a.md"), edited, "the fresh edit stays");
assert_eq!(on_disk(vault.path(), "b.md"), JSON_FORMATTED);
}
#[test]
fn cancel_empties_the_queue_and_keeps_the_proposals() {
let (registry, mut session, _vault) = setup("batch-cancel", MAIN_NOTES);
run(&registry, &mut session, "batch.start", &json!({ "scope": "vault" }));
let first = run(&registry, &mut session, "batch.step", &json!({ "budget_ms": 1 }));
let done = first["done"].as_u64().expect("done");
assert!(done >= 1, "a step always reads at least one note");
let cancelled = run(&registry, &mut session, "batch.cancel", &Value::Null);
assert_eq!(cancelled["done"], json!(done));
assert_eq!(cancelled["finished"], json!(true));
assert_eq!(cancelled["cancelled"], json!(true));
let after = run(&registry, &mut session, "batch.step", &json!({ "budget_ms": 1 }));
assert_eq!(after["done"], json!(done), "nothing is read after the cancel");
let list = run(&registry, &mut session, "batch.proposals", &Value::Null);
assert_eq!(list["proposals"].as_array().expect("rows").len(), usize::try_from(done).unwrap_or(0));
}
#[test]
fn scopes_pick_their_notes_and_reserved_folders_are_never_queued() {
let files: &[(&str, &str)] =
&[("root.md", JSON_NOTE), ("sub/a.md", JSON_NOTE), ("sub/b.md", CLEAN_NOTE), (".obsidian/x.md", JSON_NOTE), (".notescraft/y.md", JSON_NOTE)];
let (registry, mut session, _vault) = setup("batch-scopes", files);
let vault_scope = run(&registry, &mut session, "batch.start", &json!({ "scope": "vault" }));
assert_eq!(vault_scope["total"], json!(3), "the reserved folders are never queued");
let folder = run(&registry, &mut session, "batch.start", &json!({ "scope": "folder", "path": "sub" }));
assert_eq!(folder["total"], json!(2));
let empty = run(&registry, &mut session, "batch.start", &json!({ "scope": "folder", "path": "nope" }));
assert_eq!(empty["total"], json!(0));
let note = run(&registry, &mut session, "batch.start", &json!({ "scope": "note", "path": "sub/a.md" }));
assert_eq!(note["total"], json!(1));
let drained = run(&registry, &mut session, "batch.step", &json!({ "budget_ms": 1000 }));
assert_eq!(drained["done"], json!(1));
let rows = run(&registry, &mut session, "batch.proposals", &Value::Null);
assert_eq!(rows["proposals"][0]["path"], json!("sub/a.md"));
}
#[test]
fn start_is_refused_until_the_opening_scan_ends() {
let vault = temp_vault("batch-indexing");
seed(vault.path(), MAIN_NOTES);
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let mut session = Session::open_lazy(vault.path()).expect("lazy open");
let refused = registry.run("batch.start", &mut session, &json!({ "scope": "vault" }));
assert_eq!(code(refused), "indexing");
while !session.index_progress().finished {
session.index_step(Duration::from_secs(30));
}
let started = run(&registry, &mut session, "batch.start", &json!({ "scope": "vault" }));
assert_eq!(started["total"], json!(4));
}
#[test]
fn hostile_params_are_refused_and_change_nothing() {
let (registry, mut session, vault) = setup("batch-hostile", MAIN_NOTES);
run(&registry, &mut session, "batch.start", &json!({ "scope": "vault" }));
assert_eq!(code(registry.run("batch.start", &mut session, &json!({ "scope": "x" }))), "invalid_params");
assert_eq!(code(registry.run("batch.start", &mut session, &json!({ "scope": "note" }))), "invalid_params");
assert_eq!(code(registry.run("batch.start", &mut session, &json!({ "scope": "note", "path": "missing.md" }))), "not_found");
assert_eq!(
code(registry.run("batch.start", &mut session, &json!({ "scope": "note", "path": ".obsidian/x.md" }))),
"not_found",
"a reserved path is never a batch note"
);
assert_eq!(code(registry.run("batch.start", &mut session, &json!({ "scope": "folder" }))), "invalid_params");
assert_eq!(code(registry.run("batch.step", &mut session, &json!({ "budget_ms": 0 }))), "invalid_params");
assert_eq!(code(registry.run("batch.step", &mut session, &json!({ "budget_ms": 1_000_000_000 }))), "invalid_params");
assert_eq!(code(registry.run("batch.step", &mut session, &json!({ "budget_ms": -5 }))), "invalid_params");
assert_eq!(code(registry.run("batch.proposals", &mut session, &json!({ "status": "bogus" }))), "invalid_params");
assert_eq!(code(registry.run("batch.proposal", &mut session, &json!({ "path": "missing.md" }))), "not_found");
assert_eq!(code(registry.run("batch.decide", &mut session, &json!({ "path": "missing.md", "decision": "accept" }))), "not_found");
assert_eq!(code(registry.run("batch.decide", &mut session, &json!({ "path": "json.md", "change_id": 1_000_000_000, "decision": "accept" }))), "not_found");
assert_eq!(code(registry.run("batch.decide", &mut session, &json!({ "path": "json.md", "change_id": -1, "decision": "accept" }))), "invalid_params");
assert_eq!(code(registry.run("batch.decide", &mut session, &json!({ "decision": "weird" }))), "invalid_params");
assert_eq!(code(registry.run("batch.decide", &mut session, &json!({ "decision": "accept" }))), "invalid_params");
assert_eq!(code(registry.run("batch.decide", &mut session, &json!({ "all": true, "decision": "skip" }))), "invalid_params");
assert_eq!(code(registry.run("batch.decide", &mut session, &json!({ "all": true, "decision": "accept", "change_id": 3 }))), "invalid_params");
let progress = run(&registry, &mut session, "batch.status", &Value::Null);
assert_eq!(progress["done"], json!(0), "no hostile call touched the batch");
for (path, content) in MAIN_NOTES {
assert_eq!(on_disk(vault.path(), path), *content);
}
}
#[test]
fn unreadable_and_oversized_notes_are_reported_not_edited() {
let huge = "x".repeat(3 * 1024 * 1024);
let files: Vec<(&str, &str)> = vec![("gone.md", CLEAN_NOTE), ("huge.md", huge.as_str()), ("ok.md", JSON_NOTE)];
let (registry, mut session, vault) = setup("batch-refused", &files);
run(&registry, &mut session, "batch.start", &json!({ "scope": "vault" }));
std::fs::remove_file(vault.path().join("gone.md")).expect("delete a note after the start");
let progress = drain(&registry, &mut session);
assert_eq!(progress["not_edited"], json!(2));
assert_eq!(progress["proposed"], json!(1), "ok.md still reviews fine");
let list = run(&registry, &mut session, "batch.proposals", &json!({ "status": "not_edited" }));
let rows = list["proposals"].as_array().expect("rows").clone();
assert_eq!(rows.len(), 2);
let reason = |path: &str| row(&rows, path)["reason"].as_str().map(str::to_owned).unwrap_or_default();
assert!(reason("gone.md").contains("read"), "{}", reason("gone.md"));
assert!(reason("huge.md").contains("2 MiB"), "{}", reason("huge.md"));
}
#[test]
fn a_session_with_lint_off_reports_lint_notes_as_not_edited() {
let vault = temp_vault("batch-lint-off");
seed(vault.path(), MAIN_NOTES);
let mut config = SessionConfig::default();
config.lint.enabled = false;
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let mut session = Session::open_with(vault.path(), config).expect("open");
// a batch that asks for lint while lint is off reports every note as not edited
run(&registry, &mut session, "batch.start", &json!({ "scope": "vault" }));
let progress = drain(&registry, &mut session);
assert_eq!(progress["not_edited"], json!(4));
assert_eq!(progress["proposed"], json!(0));
// with the lint check off the batch still reviews JSON
run(&registry, &mut session, "batch.start", &json!({ "scope": "vault", "lint": false }));
let progress = drain(&registry, &mut session);
assert_eq!(progress["proposed"], json!(1), "json.md only: no lint fixes were requested");
assert_eq!(progress["needs_review"], json!(1));
assert_eq!(progress["not_edited"], json!(0));
}
/// An unlabeled Rust fence: the label change is a zero-length insertion right after the fence run.
const RUST_NOTE: &str = "# R\n\n```\nfn main() {\n let x = 1;\n}\n```\n";
/// The same note with the detected label inserted.
const RUST_LABELED: &str = "# R\n\n```rust\nfn main() {\n let x = 1;\n}\n```\n";
/// An unlabeled Python fence.
const PYTHON_NOTE: &str = "```\ndef add(a, b):\n return a + b\n```\n";
/// A fence that already carries its label.
const LABELED_NOTE: &str = "```rust\nfn main() {}\n```\n";
#[test]
fn unlabeled_code_blocks_get_label_changes_and_apply_inserts_the_label() {
let files: &[(&str, &str)] = &[("rust.md", RUST_NOTE), ("python.md", PYTHON_NOTE), ("labeled.md", LABELED_NOTE)];
let (registry, mut session, vault) = setup("batch-labels", files);
let started = run(&registry, &mut session, "batch.start", &json!({ "scope": "vault" }));
assert_eq!(started, json!({ "total": 3 }));
let progress = drain(&registry, &mut session);
assert_eq!(progress["needs_review"], json!(2), "rust.md and python.md guess their language");
assert_eq!(progress["clean"], json!(1), "labeled.md already has its label");
let full = run(&registry, &mut session, "batch.proposal", &json!({ "path": "rust.md" }));
assert_eq!(full["status"], json!("NeedsReview"));
let change = &full["changes"][0];
assert_eq!(change["kind"], json!("Label"));
assert_eq!(change["before"], json!(""));
assert_eq!(change["after"], json!("rust"));
assert_eq!(change["safe"], json!(false));
let fence = RUST_NOTE.find("```\n").expect("the opening fence line") + "```".len();
assert_eq!(change["range"], json!({ "start": fence, "end": fence }));
assert_eq!(change["line"], json!(3), "the insertion sits on the fence line");
run(&registry, &mut session, "batch.decide", &json!({ "path": "python.md", "decision": "accept" }));
let applied = run(&registry, &mut session, "batch.apply", &Value::Null);
assert_eq!(applied["written"], json!(1));
assert_eq!(on_disk(vault.path(), "python.md"), "```python\ndef add(a, b):\n return a + b\n```\n");
assert_eq!(on_disk(vault.path(), "rust.md"), RUST_NOTE, "pending is not applied");
assert_eq!(on_disk(vault.path(), "labeled.md"), LABELED_NOTE, "a labeled fence is never relabeled");
}
#[test]
fn labels_false_proposes_nothing_and_an_open_buffer_gets_one_undo_step() {
let files: &[(&str, &str)] = &[("rust.md", RUST_NOTE), ("labeled.md", LABELED_NOTE)];
let (registry, mut session, _vault) = setup("batch-labels-off", files);
run(&registry, &mut session, "batch.start", &json!({ "scope": "vault", "labels": false }));
let progress = drain(&registry, &mut session);
assert_eq!(progress["clean"], json!(2), "with the label check off nothing is guessed");
assert_eq!(progress["needs_review"], json!(0));
assert_eq!(progress["proposed"], json!(0));
// the same note, labels on, applied to an open buffer: ONE undo step takes it back
run(&registry, &mut session, "buffer.open", &json!({ "path": "rust.md" }));
run(&registry, &mut session, "batch.start", &json!({ "scope": "vault", "labels": true }));
drain(&registry, &mut session);
run(&registry, &mut session, "batch.decide", &json!({ "path": "rust.md", "decision": "accept" }));
let applied = run(&registry, &mut session, "batch.apply", &Value::Null);
assert_eq!(applied["written"], json!(1));
let opened = run(&registry, &mut session, "buffer.open", &json!({ "path": "rust.md" }));
assert_eq!(opened["text"], json!(RUST_LABELED));
let undone = run(&registry, &mut session, "buffer.undo", &json!({ "path": "rust.md" }));
assert_eq!(undone["undone"], json!(true), "one undo step covers the whole apply");
let back = run(&registry, &mut session, "buffer.open", &json!({ "path": "rust.md" }));
assert_eq!(back["text"], json!(RUST_NOTE));
}