//! The `replace` batch check: a vault-wide find and replace reviewed change by change. mod common; use std::path::Path; use common::{on_disk, seed, temp_vault}; use notescraft_engine::{Error, Registry, Session, register_core}; use serde_json::{Value, json}; /// A note with two matches of `tea`. const TEA_NOTE: &str = "---\ntitle: tea\n---\n\ntea time, tea again\n"; /// `TEA_NOTE` with every `tea` replaced by `coffee`. const TEA_REPLACED: &str = "---\ntitle: coffee\n---\n\ncoffee time, coffee again\n"; /// A note with no match at all. const CLEAN_NOTE: &str = "# C\n\nwater\n"; /// A note holding `tea` inside words, a code fence and the frontmatter. const TRICKY_NOTE: &str = "teapot tea\n\n```\ntea\n```\n"; /// A note a replace run touches, and the format and lint checks would too. const MIXED_NOTE: &str = "```json\n{\"a\":1,\"b\":2}\n```\n\ntea \n"; const NOTES: &[(&str, &str)] = &[("tea.md", TEA_NOTE), ("clean.md", CLEAN_NOTE), ("tricky.md", TRICKY_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:?}")) } /// 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() } /// 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 error code of a refused command call. fn code(result: Result) -> String { match result { Err(Error::Command { source, .. }) => source.code.clone(), Err(Error::InvalidParams { .. }) => "invalid_params".to_owned(), 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}")) } /// Starts a replace batch over the whole vault and drains it. fn replace_batch(registry: &Registry, session: &mut Session, params: &Value) -> Value { let mut start = json!({ "scope": "vault", "replace_find": "tea" }); merge(&mut start, params); run(registry, session, "batch.start", &start); drain(registry, session) } /// Copies the pairs of `extra` into `base`. fn merge(base: &mut Value, extra: &Value) { if let (Some(map), Some(extra)) = (base.as_object_mut(), extra.as_object()) { for (key, value) in extra { map.insert(key.clone(), value.clone()); } } } #[test] fn a_replace_run_reviews_without_writing() { let (registry, mut session, vault) = setup("batch-replace-dry", NOTES); let before = snapshot(vault.path(), NOTES); let progress = replace_batch(®istry, &mut session, &json!({ "replace_with": "coffee" })); assert_eq!(progress["total"], json!(3)); assert_eq!(progress["needs_review"], json!(2), "every replace change needs a look"); assert_eq!(progress["clean"], json!(1), "clean.md holds no match"); assert_eq!(progress["proposed"], json!(0), "a replace change is never safe"); assert_eq!(snapshot(vault.path(), NOTES), before, "reading the batch writes nothing"); let list = run(®istry, &mut session, "batch.proposals", &Value::Null); let rows = list["proposals"].as_array().expect("rows").clone(); assert_eq!(row(&rows, "tea.md")["changes"], json!(3), "the frontmatter match counts too"); assert_eq!(row(&rows, "tricky.md")["changes"], json!(3), "code fence and words count too"); assert_eq!(row(&rows, "clean.md")["changes"], json!(0)); let full = run(®istry, &mut session, "batch.proposal", &json!({ "path": "tea.md" })); let change = &full["changes"][0]; assert_eq!(change["kind"], json!("Replace")); assert_eq!(change["before"], json!("tea")); assert_eq!(change["after"], json!("coffee")); assert_eq!(change["safe"], json!(false)); assert_eq!(change["decision"], json!("Pending")); assert_eq!(change["rule"], Value::Null); assert_eq!(change["line"], json!(2), "the frontmatter match sits on line 2"); let at = TEA_NOTE.find("tea").expect("the first match"); assert_eq!(change["range"], json!({ "start": at, "end": at + 3 })); } #[test] fn case_and_whole_word_switches_change_what_matches() { let (registry, mut session, _vault) = setup("batch-replace-switches", NOTES); replace_batch(®istry, &mut session, &json!({ "replace_case": false })); let rows = run(®istry, &mut session, "batch.proposals", &Value::Null); let rows = rows["proposals"].as_array().expect("rows").clone(); assert_eq!(row(&rows, "tea.md")["changes"], json!(3), "lowercase text matches the same either way"); replace_batch(®istry, &mut session, &json!({ "replace_case": false, "replace_find": "TEA" })); let rows = run(®istry, &mut session, "batch.proposals", &Value::Null); let rows = rows["proposals"].as_array().expect("rows").clone(); assert_eq!(row(&rows, "tea.md")["changes"], json!(3), "folded case finds the lowercase matches"); assert_eq!(row(&rows, "tricky.md")["changes"], json!(3)); replace_batch(®istry, &mut session, &json!({ "replace_word": true })); let rows = run(®istry, &mut session, "batch.proposals", &Value::Null); let rows = rows["proposals"].as_array().expect("rows").clone(); assert_eq!(row(&rows, "tricky.md")["changes"], json!(2), "`teapot` is not a whole word"); assert_eq!(row(&rows, "tea.md")["changes"], json!(3)); replace_batch(®istry, &mut session, &json!({ "replace_case": false, "replace_word": true })); let rows = run(®istry, &mut session, "batch.proposals", &Value::Null); let rows = rows["proposals"].as_array().expect("rows").clone(); assert_eq!(row(&rows, "tricky.md")["changes"], json!(2), "both switches together"); } #[test] fn decide_one_change_or_all_then_apply_writes_only_what_was_accepted() { let (registry, mut session, vault) = setup("batch-replace-apply", NOTES); // an open buffer shows each written file as ONE commit run(®istry, &mut session, "buffer.open", &json!({ "path": "tea.md" })); run(®istry, &mut session, "buffer.open", &json!({ "path": "tricky.md" })); replace_batch(®istry, &mut session, &json!({ "replace_with": "coffee" })); let one = run(®istry, &mut session, "batch.decide", &json!({ "path": "tea.md", "change_id": 0, "decision": "accept" })); assert_eq!(one["decided"], json!(1)); for change_id in 1..3 { let rest = run(®istry, &mut session, "batch.decide", &json!({ "path": "tea.md", "change_id": change_id, "decision": "skip" })); assert_eq!(rest["decided"], json!(1), "the other matches are skipped one by one"); } let every = run(®istry, &mut session, "batch.decide", &json!({ "path": "tricky.md", "decision": "accept" })); assert_eq!(every["decided"], json!(3)); let applied = run(®istry, &mut session, "batch.apply", &Value::Null); assert_eq!(applied["written"], json!(2), "only the notes with accepted changes are written"); assert_eq!(applied["skipped"], json!([])); assert_eq!(applied["failed"], json!([])); assert_eq!(on_disk(vault.path(), "clean.md"), CLEAN_NOTE, "a note with no match is never written"); // an open buffer takes the whole file in ONE commit, so one undo takes it all back for (path, changed, before) in [("tea.md", "---\ntitle: coffee\n---\n\ntea time, tea again\n", TEA_NOTE), ("tricky.md", "coffeepot coffee\n\n```\ncoffee\n```\n", TRICKY_NOTE)] { let now = run(®istry, &mut session, "buffer.open", &json!({ "path": path })); assert_eq!(now["text"], json!(changed), "{path}: only the accepted matches"); let undone = run(®istry, &mut session, "buffer.undo", &json!({ "path": path })); assert_eq!(undone["undone"], json!(true), "{path}: one undo step"); let back = run(®istry, &mut session, "buffer.open", &json!({ "path": path })); assert_eq!(back["text"], json!(before), "{path}: the whole apply is undone at once"); assert_eq!(run(®istry, &mut session, "buffer.undo", &json!({ "path": path }))["undone"], json!(false), "{path}: nothing left"); } assert_eq!(run(®istry, &mut session, "batch.apply", &Value::Null)["written"], json!(0)); } #[test] fn a_note_edited_after_the_check_is_skipped() { let (registry, mut session, vault) = setup("batch-replace-stale", NOTES); replace_batch(®istry, &mut session, &json!({ "replace_with": "coffee" })); for path in ["tea.md", "tricky.md"] { run(®istry, &mut session, "batch.decide", &json!({ "path": path, "decision": "accept" })); } let edited = "# tea\n\nedited by hand\n"; std::fs::write(vault.path().join("tea.md"), edited).expect("edit tea.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": "tea.md", "reason": "changed since the check" }])); assert_eq!(applied["failed"], json!([])); assert_eq!(on_disk(vault.path(), "tea.md"), edited, "the fresh edit stays"); assert_eq!(on_disk(vault.path(), "tricky.md"), "coffeepot coffee\n\n```\ncoffee\n```\n"); } #[test] fn reserved_folders_are_never_queued_nor_written() { let files: &[(&str, &str)] = &[("tea.md", TEA_NOTE), (".obsidian/tea.md", TEA_NOTE), (".notescraft/tea.md", TEA_NOTE)]; let (registry, mut session, vault) = setup("batch-replace-reserved", files); let started = replace_batch(®istry, &mut session, &json!({ "replace_with": "coffee" })); assert_eq!(started["total"], json!(1)); run(®istry, &mut session, "batch.decide", &json!({ "path": "tea.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(), "tea.md"), TEA_REPLACED); assert_eq!(on_disk(vault.path(), ".obsidian/tea.md"), TEA_NOTE, "reserved notes are never written"); assert_eq!(on_disk(vault.path(), ".notescraft/tea.md"), TEA_NOTE); } #[test] fn one_note_stops_at_two_thousand_changes() { let huge = "tea ".repeat(3000); let files: Vec<(&str, &str)> = vec![("huge.md", huge.as_str()), ("small.md", CLEAN_NOTE)]; let (registry, mut session, vault) = setup("batch-replace-cap", &files); let progress = replace_batch(®istry, &mut session, &json!({ "replace_with": "coffee" })); assert_eq!(progress["needs_review"], json!(1)); let list = run(®istry, &mut session, "batch.proposals", &Value::Null); let rows = list["proposals"].as_array().expect("rows").clone(); assert_eq!(row(&rows, "huge.md")["changes"], json!(2000), "a note never gets more than 2000 changes"); assert_eq!(row(&rows, "huge.md")["status"], json!("needs_review")); run(®istry, &mut session, "batch.decide", &json!({ "path": "huge.md", "decision": "accept" })); let applied = run(®istry, &mut session, "batch.apply", &Value::Null); assert_eq!(applied["written"], json!(1)); let written = on_disk(vault.path(), "huge.md"); assert_eq!(written.matches("coffee").count(), 2000, "only the accepted matches are written"); assert_eq!(written.matches("tea").count(), 1000, "the rest of the note is left alone"); } #[test] fn unicode_notes_and_find_strings_are_matched_on_char_boundaries() { let note = "café ISTANBUL ẞ\n\nstraße\n"; let files: &[(&str, &str)] = &[("uni.md", note)]; let (registry, mut session, vault) = setup("batch-replace-unicode", files); // `ß` folds to itself: a lowercase `ß` find does not touch `ẞ` unless case is off replace_batch(®istry, &mut session, &json!({ "replace_find": "ß" })); let list = run(®istry, &mut session, "batch.proposals", &Value::Null); let rows = list["proposals"].as_array().expect("rows").clone(); assert_eq!(row(&rows, "uni.md")["changes"], json!(1), "only the lowercase `ß` matches"); replace_batch(®istry, &mut session, &json!({ "replace_find": "ß", "replace_case": false })); let list = run(®istry, &mut session, "batch.proposals", &Value::Null); let rows = list["proposals"].as_array().expect("rows").clone(); assert_eq!(row(&rows, "uni.md")["changes"], json!(2), "`ẞ` folds to `ß` too"); replace_batch(®istry, &mut session, &json!({ "replace_find": "istanbul", "replace_case": false })); let list = run(®istry, &mut session, "batch.proposals", &Value::Null); let rows = list["proposals"].as_array().expect("rows").clone(); assert_eq!(row(&rows, "uni.md")["changes"], json!(1), "folded case finds `ISTANBUL`"); replace_batch(®istry, &mut session, &json!({ "replace_find": "é", "replace_with": "e", "replace_case": false })); run(®istry, &mut session, "batch.decide", &json!({ "path": "uni.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(), "uni.md"), "cafe ISTANBUL ẞ\n\nstraße\n", "only the one match is written"); } #[test] fn the_other_checks_default_to_off_for_a_pure_replace_run() { let files: &[(&str, &str)] = &[("tea.md", MIXED_NOTE)]; let (registry, mut session, vault) = setup("batch-replace-only", files); let progress = replace_batch(®istry, &mut session, &json!({ "replace_with": "coffee" })); assert_eq!(progress["needs_review"], json!(1)); let full = run(®istry, &mut session, "batch.proposal", &json!({ "path": "tea.md" })); assert_eq!(full["changes"].as_array().expect("changes").len(), 1, "the JSON and lint changes are off"); assert_eq!(full["changes"][0]["kind"], json!("Replace")); run(®istry, &mut session, "batch.decide", &json!({ "path": "tea.md", "decision": "accept" })); run(®istry, &mut session, "batch.apply", &Value::Null); assert_eq!(on_disk(vault.path(), "tea.md"), "```json\n{\"a\":1,\"b\":2}\n```\n\ncoffee \n", "only the match is written"); // naming another check turns the defaults back on std::fs::write(vault.path().join("tea.md"), MIXED_NOTE).expect("put the note back"); replace_batch(®istry, &mut session, &json!({ "replace_with": "coffee", "json": true })); let full = run(®istry, &mut session, "batch.proposal", &json!({ "path": "tea.md" })); let kinds: Vec<&str> = full["changes"].as_array().expect("changes").iter().filter_map(|change| change["kind"].as_str()).collect(); assert_eq!(kinds, vec!["Json", "Replace", "Lint"], "an explicit `json` keeps the other defaults"); // the checks alone, without a find, still work let started = run(®istry, &mut session, "batch.start", &json!({ "scope": "vault" })); assert_eq!(started, json!({ "total": 1 })); let progress = drain(®istry, &mut session); assert_eq!(progress["proposed"], json!(1), "no replace check, no replace change"); let full = run(®istry, &mut session, "batch.proposal", &json!({ "path": "tea.md" })); assert_eq!(full["changes"][0]["kind"], json!("Json")); } #[test] fn hostile_replace_params_are_refused_and_change_nothing() { let (registry, mut session, vault) = setup("batch-replace-hostile", NOTES); let huge = "a".repeat(501); let refused = registry.run("batch.start", &mut session, &json!({ "scope": "vault", "replace_find": huge })); assert_eq!(code(refused), "invalid_params", "a find string over 500 bytes is refused"); let refused = registry.run("batch.start", &mut session, &json!({ "scope": "vault", "replace_find": "tea", "replace_with": 7 })); assert_eq!(code(refused), "invalid_params", "a non-string replacement is refused"); let refused = registry.run("batch.start", &mut session, &json!({ "scope": "vault", "replace_find": "tea", "replace_case": "yes" })); assert_eq!(code(refused), "invalid_params", "a non-bool switch is refused"); // the empty find is not an error, it just turns the check off let empty = run(®istry, &mut session, "batch.start", &json!({ "scope": "vault", "replace_find": "" })); assert_eq!(empty["total"], json!(3)); let progress = drain(®istry, &mut session); assert_eq!(progress["clean"], json!(3), "an empty find changes nothing"); for (path, content) in NOTES { assert_eq!(on_disk(vault.path(), path), *content); } }