5e8020aa38
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>
312 lines
17 KiB
Rust
312 lines
17 KiB
Rust
//! 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>, 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:?}"))
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// 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 error code of a refused command call.
|
|
fn code(result: Result<Value, Error>) -> 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>, 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);
|
|
}
|
|
}
|