//! 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, 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: &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: &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)> { 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) -> 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(®istry, &mut session, "batch.start", &json!({ "scope": "vault" })); assert_eq!(started, json!({ "total": 4 })); let progress = drain(®istry, &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(®istry, &mut session, "batch.status", &Value::Null), progress, "status changes nothing"); let list = run(®istry, &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(®istry, &mut session, "batch.proposals", &json!({ "status": "clean" })); assert_eq!(only_clean["proposals"].as_array().expect("rows").len(), 1); let full = run(®istry, &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(®istry, &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(®istry, &mut session, "batch.start", &json!({ "scope": "vault" })); drain(®istry, &mut session); let accepted = run(®istry, &mut session, "batch.decide", &json!({ "path": "json.md", "change_id": 0, "decision": "accept" })); assert_eq!(accepted["decided"], json!(1)); let skipped = run(®istry, &mut session, "batch.decide", &json!({ "path": "lint.md", "decision": "skip" })); assert_eq!(skipped["decided"], json!(1)); let applied = run(®istry, &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(®istry, &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(®istry, &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(®istry, &mut session, "batch.start", &json!({ "scope": "vault" })); drain(®istry, &mut session); let decided = run(®istry, &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(®istry, &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(®istry, &mut session, "batch.start", &json!({ "scope": "vault" })); drain(®istry, &mut session); for path in ["a.md", "b.md"] { run(®istry, &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(®istry, &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(®istry, &mut session, "batch.start", &json!({ "scope": "vault" })); let first = run(®istry, &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(®istry, &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(®istry, &mut session, "batch.step", &json!({ "budget_ms": 1 })); assert_eq!(after["done"], json!(done), "nothing is read after the cancel"); let list = run(®istry, &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(®istry, &mut session, "batch.start", &json!({ "scope": "vault" })); assert_eq!(vault_scope["total"], json!(3), "the reserved folders are never queued"); let folder = run(®istry, &mut session, "batch.start", &json!({ "scope": "folder", "path": "sub" })); assert_eq!(folder["total"], json!(2)); let empty = run(®istry, &mut session, "batch.start", &json!({ "scope": "folder", "path": "nope" })); assert_eq!(empty["total"], json!(0)); let note = run(®istry, &mut session, "batch.start", &json!({ "scope": "note", "path": "sub/a.md" })); assert_eq!(note["total"], json!(1)); let drained = run(®istry, &mut session, "batch.step", &json!({ "budget_ms": 1000 })); assert_eq!(drained["done"], json!(1)); let rows = run(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &mut session); assert_eq!(progress["not_edited"], json!(2)); assert_eq!(progress["proposed"], json!(1), "ok.md still reviews fine"); let list = run(®istry, &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(®istry, &mut session, "batch.start", &json!({ "scope": "vault" })); let progress = drain(®istry, &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(®istry, &mut session, "batch.start", &json!({ "scope": "vault", "lint": false })); let progress = drain(®istry, &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(®istry, &mut session, "batch.start", &json!({ "scope": "vault" })); assert_eq!(started, json!({ "total": 3 })); let progress = drain(®istry, &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(®istry, &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(®istry, &mut session, "batch.decide", &json!({ "path": "python.md", "decision": "accept" })); let applied = run(®istry, &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(®istry, &mut session, "batch.start", &json!({ "scope": "vault", "labels": false })); let progress = drain(®istry, &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(®istry, &mut session, "buffer.open", &json!({ "path": "rust.md" })); run(®istry, &mut session, "batch.start", &json!({ "scope": "vault", "labels": true })); drain(®istry, &mut session); run(®istry, &mut session, "batch.decide", &json!({ "path": "rust.md", "decision": "accept" })); let applied = run(®istry, &mut session, "batch.apply", &Value::Null); assert_eq!(applied["written"], json!(1)); let opened = run(®istry, &mut session, "buffer.open", &json!({ "path": "rust.md" })); assert_eq!(opened["text"], json!(RUST_LABELED)); let undone = run(®istry, &mut session, "buffer.undo", &json!({ "path": "rust.md" })); assert_eq!(undone["undone"], json!(true), "one undo step covers the whole apply"); let back = run(®istry, &mut session, "buffer.open", &json!({ "path": "rust.md" })); assert_eq!(back["text"], json!(RUST_NOTE)); }