use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 1;
fn default_strict_true() -> bool {
true
}
fn default_version() -> u32 {
1
}
pub fn effective_strict(
plan_strict: Option<bool>,
config_strict: Option<bool>,
no_strict: bool,
) -> bool {
if no_strict {
false
} else {
plan_strict
.or(config_strict)
.unwrap_or_else(default_strict_true)
}
}
#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
#[non_exhaustive]
pub struct Plan {
#[serde(default = "default_version")]
pub version: u32,
pub cwd: Option<String>,
pub write_policy: Option<crate::write::WritePolicyOverride>,
#[serde(default)]
pub strict: Option<bool>,
pub operations: Vec<Operation>,
pub format: Option<Vec<FormatStep>>,
pub validate: Option<Vec<ValidationStep>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verify: Option<Vec<VerifyCheck>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub for_each: Option<ForEach>,
}
impl Plan {
pub fn has_lifecycle_steps(&self) -> bool {
self.format.as_ref().is_some_and(|v| !v.is_empty())
|| self.validate.as_ref().is_some_and(|v| !v.is_empty())
}
}
#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
pub struct ForEach {
pub glob: String,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub filter: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(untagged)]
pub enum VerifyCheck {
SymbolCount {
kind: String,
#[serde(default)]
attr: Option<String>,
},
Named { check: String },
}
impl VerifyCheck {
#[cfg(feature = "cli")]
pub fn parse(s: &str) -> anyhow::Result<Self> {
if s == "unique_names" || s == "no_orphans" {
return Ok(VerifyCheck::Named {
check: s.to_string(),
});
}
let mut kind = None;
let mut attr = None;
for part in s.split(',') {
let part = part.trim();
if let Some((k, v)) = part.split_once('=') {
match k.trim() {
"kind" => kind = Some(v.trim().to_string()),
"attr" => attr = Some(v.trim().to_string()),
other => anyhow::bail!("unknown verify key: {other}"),
}
} else {
kind = Some(part.to_string());
}
}
if let Some(kind) = kind {
Ok(VerifyCheck::SymbolCount { kind, attr })
} else {
anyhow::bail!(
"verify spec must contain 'kind=<type>' or a named check (unique_names, no_orphans)"
)
}
}
}
#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
#[non_exhaustive]
pub struct FormatStep {
pub cmd: String,
pub timeout: Option<u64>,
}
#[deprecated(
since = "0.6.0",
note = "use crate::write::WritePolicyOverride instead"
)]
pub type PlanWritePolicy = crate::write::WritePolicyOverride;
#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
#[non_exhaustive]
#[serde(tag = "op")]
pub enum Operation {
#[serde(rename = "replace", alias = "replace_text")]
Replace {
glob: Option<String>,
path: Option<String>,
#[serde(default)]
regex: bool,
old: String,
#[serde(rename = "new")]
new_text: Option<String>,
nth: Option<usize>,
insert_before: Option<String>,
insert_after: Option<String>,
#[serde(default)]
case_insensitive: bool,
#[serde(default)]
multiline: bool,
#[serde(default)]
if_exists: bool,
#[serde(default)]
whole_line: bool,
range: Option<String>,
#[serde(default)]
word_boundary: bool,
before_context: Option<String>,
after_context: Option<String>,
},
#[serde(rename = "doc.set", alias = "doc_set")]
DocSet {
path: String,
key: String,
value: serde_json::Value,
},
#[serde(rename = "doc.delete", alias = "doc_delete")]
DocDelete { path: String, key: String },
#[serde(rename = "doc.merge", alias = "doc_merge")]
DocMerge {
path: String,
value: serde_json::Value,
},
#[serde(rename = "doc.append", alias = "doc_append")]
DocAppend {
path: String,
key: String,
value: serde_json::Value,
},
#[serde(rename = "doc.prepend", alias = "doc_prepend")]
DocPrepend {
path: String,
key: String,
value: serde_json::Value,
},
#[serde(rename = "doc.update", alias = "doc_update")]
DocUpdate {
path: String,
key: String,
value: serde_json::Value,
},
#[serde(rename = "doc.move", alias = "doc_move")]
DocMove {
path: String,
from: String,
to: String,
},
#[serde(rename = "doc.ensure", alias = "doc_ensure")]
DocEnsure {
path: String,
key: String,
value: serde_json::Value,
},
#[serde(rename = "doc.delete_where", alias = "doc_delete_where")]
DocDeleteWhere {
path: String,
key: String,
predicate: String,
},
#[serde(rename = "md.replace_section", alias = "md_replace_section")]
MdReplaceSection {
path: String,
heading: String,
content: String,
},
#[serde(rename = "md.insert_after_heading", alias = "md_insert_after_heading")]
MdInsertAfterHeading {
path: String,
heading: String,
content: String,
},
#[serde(
rename = "md.insert_before_heading",
alias = "md_insert_before_heading"
)]
MdInsertBeforeHeading {
path: String,
heading: String,
content: String,
},
#[serde(rename = "md.upsert_bullet", alias = "md_upsert_bullet")]
MdUpsertBullet {
path: String,
heading: String,
bullet: String,
},
#[serde(rename = "md.table_append", alias = "md_table_append")]
MdTableAppend {
path: String,
heading: String,
row: String,
},
#[serde(rename = "md.move_section", alias = "md_move_section")]
MdMoveSection {
path: String,
heading: String,
to: Option<String>,
before: Option<String>,
after: Option<String>,
},
#[serde(rename = "md.dedupe_headings", alias = "md_dedupe_headings")]
MdDedupeHeadings { path: String },
#[serde(rename = "tidy.fix", alias = "fix_whitespace")]
TidyFix {
path: String,
ensure_final_newline: Option<bool>,
trim_trailing_whitespace: Option<bool>,
normalize_eol: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
collapse_blanks: Option<bool>,
dedent: Option<String>,
indent: Option<String>,
lines: Option<String>,
},
#[serde(rename = "file.create", alias = "create_file")]
FileCreate {
path: String,
content: String,
force: Option<bool>,
},
#[serde(rename = "file.append", alias = "append_file")]
FileAppend { path: String, content: String },
#[serde(rename = "file.prepend", alias = "prepend_file")]
FilePrepend { path: String, content: String },
#[serde(rename = "file.delete", alias = "delete_file")]
FileDelete { path: String },
#[serde(rename = "file.rename", alias = "move_file")]
FileRename {
from: String,
to: String,
#[serde(default)]
force: bool,
},
#[serde(rename = "patch.apply", alias = "apply_patch")]
PatchApply {
diff: String,
#[serde(default)]
on_stale: crate::ops::patch::OnStale,
#[serde(default)]
allow_conflicts: bool,
},
#[serde(rename = "search", alias = "search_files")]
Search {
path: String,
pattern: String,
#[serde(default)]
regex: bool,
#[serde(default)]
case_insensitive: bool,
#[serde(default)]
multiline: bool,
#[serde(default)]
invert_match: bool,
context: Option<usize>,
before_context: Option<usize>,
after_context: Option<usize>,
assert_count: Option<usize>,
#[serde(default)]
literal: bool,
#[serde(default)]
globs: Vec<String>,
#[serde(default)]
max_results: usize,
#[serde(default)]
exclude_patterns: Vec<String>,
#[serde(default)]
custom_ignore_filenames: Vec<String>,
},
#[serde(rename = "read", alias = "read_file")]
Read {
path: String,
lines: Option<String>,
},
#[serde(rename = "md.lint_agents", alias = "md_lint")]
MdLintAgents { path: String },
#[cfg(feature = "ast")]
#[serde(rename = "ast.rename", alias = "ast_rename")]
AstRename {
path: String,
old_name: String,
new_name: String,
#[serde(default)]
lang: Option<String>,
},
#[cfg(feature = "ast")]
#[serde(rename = "ast.replace", alias = "ast_replace")]
AstReplace {
path: String,
symbol: String,
old: String,
#[serde(rename = "new")]
new_text: String,
#[serde(default)]
regex: bool,
#[serde(default)]
lang: Option<String>,
},
#[cfg(feature = "ast")]
#[serde(rename = "ast.insert", alias = "ast_insert")]
AstInsert {
path: String,
content: String,
#[serde(default)]
inside: Option<String>,
#[serde(default)]
after: Option<String>,
#[serde(default)]
before: Option<String>,
#[serde(default)]
position: Option<String>,
#[serde(default)]
lang: Option<String>,
},
#[cfg(feature = "ast")]
#[serde(rename = "ast.wrap", alias = "ast_wrap")]
AstWrap {
path: String,
#[serde(default)]
symbols: Option<Vec<String>>,
#[serde(default)]
lines: Option<String>,
wrapper: String,
#[serde(default)]
preamble: Option<String>,
#[serde(default)]
lang: Option<String>,
},
#[cfg(feature = "ast")]
#[serde(rename = "ast.imports", alias = "ast_imports")]
AstImports {
path: String,
#[serde(default)]
add: Option<Vec<String>>,
#[serde(default)]
remove: Option<Vec<String>>,
#[serde(default)]
dedupe: bool,
#[serde(default)]
lang: Option<String>,
},
#[cfg(feature = "ast")]
#[serde(rename = "ast.reorder", alias = "ast_reorder")]
AstReorder {
path: String,
#[serde(default)]
inside: Option<String>,
order: serde_json::Value,
#[serde(default)]
lang: Option<String>,
},
#[cfg(feature = "ast")]
#[serde(rename = "ast.group", alias = "ast_group")]
AstGroup {
path: String,
module: String,
symbols: Vec<String>,
#[serde(default)]
preamble: Option<String>,
#[serde(default)]
position: Option<String>,
#[serde(default)]
lang: Option<String>,
},
#[cfg(feature = "ast")]
#[serde(rename = "ast.move", alias = "ast_move")]
AstMove {
path: String,
target: String,
symbols: Vec<String>,
#[serde(default)]
position: Option<String>,
#[serde(default)]
target_prepend: Option<String>,
#[serde(default)]
lang: Option<String>,
},
#[cfg(feature = "ast")]
#[serde(rename = "ast.extract_to_file", alias = "ast_extract_to_file")]
AstExtractToFile {
source: String,
symbol: String,
target: String,
#[serde(default)]
replacement: Option<String>,
#[serde(default)]
unwrap: Option<bool>,
#[serde(default)]
prepend: Option<String>,
#[serde(default)]
force: bool,
#[serde(default)]
lang: Option<String>,
},
#[cfg(feature = "ast")]
#[serde(rename = "ast.split", alias = "ast_split")]
AstSplit {
source: String,
targets: Vec<SplitTargetSpec>,
#[serde(default)]
keep_in_source: Vec<String>,
#[serde(default)]
source_suffix: Option<String>,
#[serde(default)]
source_prefix: Option<String>,
#[serde(default)]
require_exhaustive: Option<bool>,
#[serde(default)]
lang: Option<String>,
},
}
#[cfg(feature = "ast")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
pub struct SplitTargetSpec {
pub path: String,
pub symbols: Vec<String>,
#[serde(default)]
pub prepend: Option<String>,
}
impl Operation {
pub fn label(&self) -> &'static str {
match self {
Operation::Replace { .. } => "replace",
Operation::DocSet { .. } => "doc.set",
Operation::DocDelete { .. } => "doc.delete",
Operation::DocMerge { .. } => "doc.merge",
Operation::DocAppend { .. } => "doc.append",
Operation::DocPrepend { .. } => "doc.prepend",
Operation::DocUpdate { .. } => "doc.update",
Operation::DocMove { .. } => "doc.move",
Operation::DocEnsure { .. } => "doc.ensure",
Operation::DocDeleteWhere { .. } => "doc.delete_where",
Operation::MdReplaceSection { .. } => "md.replace_section",
Operation::MdInsertAfterHeading { .. } => "md.insert_after_heading",
Operation::MdInsertBeforeHeading { .. } => "md.insert_before_heading",
Operation::MdUpsertBullet { .. } => "md.upsert_bullet",
Operation::MdTableAppend { .. } => "md.table_append",
Operation::MdMoveSection { .. } => "md.move_section",
Operation::MdDedupeHeadings { .. } => "md.dedupe_headings",
Operation::TidyFix { .. } => "tidy.fix",
Operation::FileAppend { .. } => "file.append",
Operation::FilePrepend { .. } => "file.prepend",
Operation::FileCreate { .. } => "file.create",
Operation::FileDelete { .. } => "file.delete",
Operation::FileRename { .. } => "file.rename",
Operation::PatchApply { .. } => "patch.apply",
Operation::Read { .. } => "read",
Operation::Search { .. } => "search",
Operation::MdLintAgents { .. } => "md.lint_agents",
#[cfg(feature = "ast")]
Operation::AstRename { .. } => "ast.rename",
#[cfg(feature = "ast")]
Operation::AstReplace { .. } => "ast.replace",
#[cfg(feature = "ast")]
Operation::AstInsert { .. } => "ast.insert",
#[cfg(feature = "ast")]
Operation::AstWrap { .. } => "ast.wrap",
#[cfg(feature = "ast")]
Operation::AstImports { .. } => "ast.imports",
#[cfg(feature = "ast")]
Operation::AstReorder { .. } => "ast.reorder",
#[cfg(feature = "ast")]
Operation::AstGroup { .. } => "ast.group",
#[cfg(feature = "ast")]
Operation::AstMove { .. } => "ast.move",
#[cfg(feature = "ast")]
Operation::AstExtractToFile { .. } => "ast.extract_to_file",
#[cfg(feature = "ast")]
Operation::AstSplit { .. } => "ast.split",
}
}
pub fn needs_doc_flush(&self) -> bool {
matches!(
self,
Operation::Replace { .. }
| Operation::MdReplaceSection { .. }
| Operation::MdInsertAfterHeading { .. }
| Operation::MdInsertBeforeHeading { .. }
| Operation::MdUpsertBullet { .. }
| Operation::MdTableAppend { .. }
| Operation::MdMoveSection { .. }
| Operation::MdDedupeHeadings { .. }
| Operation::PatchApply { .. }
| Operation::FileAppend { .. }
| Operation::FilePrepend { .. }
| Operation::FileCreate { .. }
| Operation::FileDelete { .. }
| Operation::FileRename { .. }
| Operation::Read { .. }
| Operation::Search { .. }
| Operation::MdLintAgents { .. }
| Operation::TidyFix { .. }
) || {
#[cfg(feature = "ast")]
{
matches!(
self,
Operation::AstRename { .. }
| Operation::AstReplace { .. }
| Operation::AstInsert { .. }
| Operation::AstWrap { .. }
| Operation::AstImports { .. }
| Operation::AstReorder { .. }
| Operation::AstGroup { .. }
| Operation::AstMove { .. }
| Operation::AstExtractToFile { .. }
| Operation::AstSplit { .. }
)
}
#[cfg(not(feature = "ast"))]
{
false
}
}
}
pub fn declared_paths(&self) -> Vec<&str> {
declared_paths(self)
}
}
pub(crate) fn op_to_doc_mutation(op: &Operation) -> Option<(&str, crate::ops::doc::DocMutation)> {
use crate::ops::doc::DocMutation;
match op {
Operation::DocSet { path, key, value } => Some((
path,
DocMutation::Set {
selector: key.clone(),
value: value.clone(),
},
)),
Operation::DocDelete { path, key } => Some((
path,
DocMutation::Delete {
selector: key.clone(),
},
)),
Operation::DocMerge { path, value } => Some((
path,
DocMutation::Merge {
value: value.clone(),
},
)),
Operation::DocAppend { path, key, value } => Some((
path,
DocMutation::Append {
selector: key.clone(),
value: value.clone(),
},
)),
Operation::DocPrepend { path, key, value } => Some((
path,
DocMutation::Prepend {
selector: key.clone(),
value: value.clone(),
},
)),
Operation::DocUpdate { path, key, value } => Some((
path,
DocMutation::Update {
selector: key.clone(),
value: value.clone(),
},
)),
Operation::DocMove { path, from, to } => Some((
path,
DocMutation::Move {
from: from.clone(),
to: to.clone(),
},
)),
Operation::DocEnsure { path, key, value } => Some((
path,
DocMutation::Ensure {
selector: key.clone(),
value: value.clone(),
},
)),
Operation::DocDeleteWhere {
path,
key,
predicate,
} => Some((
path,
DocMutation::DeleteWhere {
selector: key.clone(),
predicate: predicate.clone(),
},
)),
_ => None,
}
}
pub(crate) fn declared_paths(op: &Operation) -> Vec<&str> {
match op {
Operation::Replace { path, glob, .. } => {
let mut p = Vec::new();
if let Some(s) = path {
p.push(s.as_str());
}
if let Some(s) = glob {
p.push(s.as_str());
}
p
}
Operation::FileRename { from, to, .. } => vec![from.as_str(), to.as_str()],
Operation::MdMoveSection { path, to, .. } => {
let mut p = vec![path.as_str()];
if let Some(t) = to {
p.push(t.as_str());
}
p
}
Operation::PatchApply { .. } => vec![],
Operation::DocSet { path, .. }
| Operation::DocDelete { path, .. }
| Operation::DocMerge { path, .. }
| Operation::DocAppend { path, .. }
| Operation::DocPrepend { path, .. }
| Operation::DocUpdate { path, .. }
| Operation::DocMove { path, .. }
| Operation::DocEnsure { path, .. }
| Operation::DocDeleteWhere { path, .. }
| Operation::MdReplaceSection { path, .. }
| Operation::MdInsertAfterHeading { path, .. }
| Operation::MdInsertBeforeHeading { path, .. }
| Operation::MdUpsertBullet { path, .. }
| Operation::MdTableAppend { path, .. }
| Operation::MdDedupeHeadings { path, .. }
| Operation::TidyFix { path, .. }
| Operation::FileAppend { path, .. }
| Operation::FilePrepend { path, .. }
| Operation::FileCreate { path, .. }
| Operation::FileDelete { path, .. }
| Operation::Read { path, .. }
| Operation::Search { path, .. }
| Operation::MdLintAgents { path, .. } => vec![path.as_str()],
#[cfg(feature = "ast")]
Operation::AstRename { path, .. }
| Operation::AstReplace { path, .. }
| Operation::AstInsert { path, .. }
| Operation::AstWrap { path, .. }
| Operation::AstImports { path, .. }
| Operation::AstReorder { path, .. }
| Operation::AstGroup { path, .. } => {
vec![path.as_str()]
}
#[cfg(feature = "ast")]
Operation::AstMove { path, target, .. } => vec![path.as_str(), target.as_str()],
#[cfg(feature = "ast")]
Operation::AstExtractToFile { source, target, .. } => {
vec![source.as_str(), target.as_str()]
}
#[cfg(feature = "ast")]
Operation::AstSplit {
source, targets, ..
} => {
let mut p = vec![source.as_str()];
for t in targets {
p.push(t.path.as_str());
}
p
}
}
}
#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
#[non_exhaustive]
pub struct ValidationStep {
pub cmd: String,
pub required: Option<bool>,
pub timeout: Option<u64>,
}
pub fn parse_plan(input: &str) -> anyhow::Result<Plan> {
let plan: Plan = serde_json::from_str(input)?;
Ok(plan)
}
pub fn parse_plan_yaml(input: &str) -> anyhow::Result<Plan> {
let plan: Plan = serde_yaml_ng::from_str(input)?;
Ok(plan)
}
pub fn parse_plan_toml(input: &str) -> anyhow::Result<Plan> {
let plan: Plan = toml_edit::de::from_str(input)?;
Ok(plan)
}
pub fn parse_plan_auto(
input: &str,
path: Option<&str>,
format_hint: Option<&str>,
) -> anyhow::Result<Plan> {
let fmt = format_hint.or_else(|| {
path.and_then(|p| {
crate::ops::doc::detect_format(p).ok().map(|f| match f {
crate::ops::doc::FileFormat::Yaml => "yaml",
crate::ops::doc::FileFormat::Toml => "toml",
crate::ops::doc::FileFormat::Json => "json",
})
})
});
match fmt {
Some("yaml" | "yml") => parse_plan_yaml(input),
Some("toml") => parse_plan_toml(input),
_ => parse_plan(input),
}
}
#[cfg(feature = "cli")]
fn json_escape(s: &str) -> String {
let quoted = serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""));
quoted[1..quoted.len() - 1].to_string()
}
#[cfg(feature = "cli")]
fn substitute_single_pass(template: &str, vars: &[(&str, String)]) -> String {
let mut result = String::with_capacity(template.len());
let mut i = 0;
let bytes = template.as_bytes();
while i < bytes.len() {
if bytes[i] == b'{' {
let mut matched = false;
for (placeholder, value) in vars {
if template[i..].starts_with(placeholder) {
result.push_str(value);
i += placeholder.len();
matched = true;
break;
}
}
if !matched {
result.push('{');
i += 1;
}
} else {
let ch = template[i..].chars().next().unwrap();
result.push(ch);
i += ch.len_utf8();
}
}
result
}
#[cfg(feature = "cli")]
pub fn expand_for_each(plan: &mut Plan, cwd: &std::path::Path) -> anyhow::Result<()> {
let fe = match plan.for_each.take() {
Some(fe) => fe,
None => return Ok(()),
};
let glob_set = crate::files::build_glob_matcher(std::slice::from_ref(&fe.glob))?
.ok_or_else(|| anyhow::anyhow!("for_each: invalid glob pattern"))?;
let all_files = crate::files::collect_file_paths(cwd, false)?;
let mut matched: Vec<std::path::PathBuf> = all_files
.into_iter()
.filter(|p| {
let rel = p.strip_prefix(cwd).unwrap_or(p);
glob_set.is_match(rel)
})
.collect();
matched.sort();
if !fe.exclude.is_empty() {
let excl = crate::files::build_glob_matcher(&fe.exclude)?;
if let Some(excl_set) = excl {
matched.retain(|p| {
let rel = p.strip_prefix(cwd).unwrap_or(p);
!excl_set.is_match(rel)
});
}
}
if let Some(ref filter) = fe.filter {
let filter = filter.trim();
if let Some(sym_name) = filter
.strip_prefix("has_symbol(")
.and_then(|s| s.strip_suffix(')'))
{
let sym_name = sym_name.trim();
#[cfg(feature = "ast")]
{
matched.retain(|p| {
let syms = crate::ast::symbols::extract_symbols_from_file(p, None);
syms.iter().any(|s| s.name == sym_name)
});
}
#[cfg(not(feature = "ast"))]
{
let _ = sym_name;
anyhow::bail!("for_each filter `has_symbol(...)` requires the `ast` feature");
}
} else {
anyhow::bail!("for_each: unsupported filter expression: {filter}");
}
}
if matched.is_empty() {
plan.operations.clear();
return Ok(());
}
let template_ops_json = serde_json::to_string(&plan.operations)?;
let protected = template_ops_json
.replace("{{", "\x00LBRACE\x00")
.replace("}}", "\x00RBRACE\x00");
let mut expanded = Vec::with_capacity(matched.len() * plan.operations.len());
for file_path in &matched {
let rel = file_path
.strip_prefix(cwd)
.unwrap_or(file_path)
.to_string_lossy();
let rel_str = rel.replace('\\', "/");
let dir = std::path::Path::new(&rel_str)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let name = std::path::Path::new(&rel_str)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let stem = std::path::Path::new(&rel_str)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let ext = std::path::Path::new(&rel_str)
.extension()
.map(|e| e.to_string_lossy().into_owned())
.unwrap_or_default();
let vars: &[(&str, String)] = &[
("{path}", json_escape(&rel_str)),
("{dir}", json_escape(&dir)),
("{stem}", json_escape(&stem)),
("{ext}", json_escape(&ext)),
("{name}", json_escape(&name)),
];
let substituted = substitute_single_pass(&protected, vars);
let substituted = substituted
.replace("\x00LBRACE\x00", "{")
.replace("\x00RBRACE\x00", "}");
let file_ops: Vec<Operation> = serde_json::from_str(&substituted)
.map_err(|e| anyhow::anyhow!("for_each: template expansion failed: {e}"))?;
expanded.extend(file_ops);
}
plan.operations = expanded;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "cli")]
#[test]
fn json_escape_handles_special_chars() {
assert_eq!(json_escape(r#"a\b"#), r#"a\\b"#);
assert_eq!(json_escape(r#"a"b"#), r#"a\"b"#);
assert_eq!(json_escape("a\nb"), r#"a\nb"#);
assert_eq!(json_escape("he said \"hi\"\n"), r#"he said \"hi\"\n"#);
assert_eq!(json_escape("hello"), "hello");
}
#[cfg(feature = "cli")]
#[test]
fn substitute_single_pass_no_cross_contamination() {
let template = r#"{"path": "{path}", "name": "{name}"}"#;
let vars: &[(&str, String)] = &[
("{path}", "{name}/test.txt".into()),
("{name}", "test.txt".into()),
];
let result = substitute_single_pass(template, vars);
assert_eq!(result, r#"{"path": "{name}/test.txt", "name": "test.txt"}"#);
}
#[cfg(feature = "cli")]
#[test]
fn substitute_single_pass_basic() {
let template = "file: {path}, dir: {dir}";
let vars: &[(&str, String)] = &[("{path}", "src/main.rs".into()), ("{dir}", "src".into())];
let result = substitute_single_pass(template, vars);
assert_eq!(result, "file: src/main.rs, dir: src");
}
#[cfg(feature = "cli")]
#[test]
fn substitute_single_pass_preserves_utf8() {
let template = r#"{"path": "{path}", "to": "résumé café"}"#;
let vars: &[(&str, String)] = &[("{path}", "src/main.rs".into())];
let result = substitute_single_pass(template, vars);
assert_eq!(
result, r#"{"path": "src/main.rs", "to": "résumé café"}"#,
"multi-byte UTF-8 characters must survive template expansion"
);
}
#[test]
fn has_lifecycle_steps_none() {
let plan = Plan {
version: SCHEMA_VERSION,
operations: Vec::new(),
format: None,
validate: None,
verify: None,
cwd: None,
strict: None,
write_policy: None,
for_each: None,
};
assert!(!plan.has_lifecycle_steps());
}
#[test]
fn has_lifecycle_steps_empty_vecs() {
let plan = Plan {
version: SCHEMA_VERSION,
operations: Vec::new(),
format: Some(Vec::new()),
validate: Some(Vec::new()),
verify: None,
cwd: None,
strict: None,
write_policy: None,
for_each: None,
};
assert!(!plan.has_lifecycle_steps());
}
#[test]
fn has_lifecycle_steps_with_format() {
let plan = Plan {
version: SCHEMA_VERSION,
operations: Vec::new(),
format: Some(vec![FormatStep {
cmd: "cargo fmt".into(),
timeout: None,
}]),
validate: None,
verify: None,
cwd: None,
strict: None,
write_policy: None,
for_each: None,
};
assert!(plan.has_lifecycle_steps());
}
#[test]
fn has_lifecycle_steps_with_validate() {
let plan = Plan {
version: SCHEMA_VERSION,
operations: Vec::new(),
format: None,
validate: Some(vec![ValidationStep {
cmd: "cargo clippy".into(),
timeout: None,
required: Some(true),
}]),
verify: None,
cwd: None,
strict: None,
write_policy: None,
for_each: None,
};
assert!(plan.has_lifecycle_steps());
}
#[test]
fn parse_minimal_plan() {
let json = r#"{"version": 1, "operations": [{"op": "replace", "old": "a", "new": "b"}]}"#;
let plan = parse_plan(json).unwrap();
assert!(plan.cwd.is_none());
assert!(plan.write_policy.is_none());
assert!(plan.validate.is_none());
assert_eq!(plan.version, 1);
assert_eq!(plan.operations.len(), 1);
}
#[test]
fn parse_plan_version_field_accepted() {
let json = r#"{"version": 1, "operations": [{"op": "replace", "old": "a", "new": "b"}]}"#;
let plan = parse_plan(json).unwrap();
assert_eq!(plan.version, 1);
}
#[test]
fn parse_plan_without_version_defaults_to_1() {
let json = r#"{"operations": [{"op": "replace", "old": "a", "new": "b"}]}"#;
let plan = parse_plan(json).unwrap();
assert_eq!(plan.version, 1);
}
#[test]
fn parse_plan_with_all_fields() {
let json = r#"{
"version": 1,
"cwd": "/tmp",
"write_policy": {"ensure_final_newline": true, "normalize_eol": "lf"},
"operations": [{"op": "file.create", "path": "f.txt", "content": "hi"}],
"validate": [{"cmd": "echo ok"}]
}"#;
let plan = parse_plan(json).unwrap();
assert_eq!(plan.cwd.as_deref(), Some("/tmp"));
let wp = plan.write_policy.unwrap();
assert_eq!(wp.ensure_final_newline, Some(true));
assert_eq!(wp.normalize_eol.as_deref(), Some("lf"));
assert!(plan.validate.unwrap()[0].required.is_none());
}
#[test]
fn parse_plan_unknown_op_fails() {
let json = r#"{"version": 1, "operations": [{"op": "unknown", "x": 1}]}"#;
assert!(parse_plan(json).is_err());
}
#[test]
fn parse_plan_missing_operations_fails() {
let json = r#"{"version": 1, "cwd": "/tmp"}"#;
assert!(parse_plan(json).is_err());
}
#[test]
#[cfg(feature = "ast")]
fn parse_all_operation_variants() {
let json = r#"{"version": 1, "operations": [
{"op": "replace", "old": "a", "new": "b"},
{"op": "replace", "old": "a", "new": "b", "nth": 2},
{"op": "doc.set", "path": "f.json", "key": "k", "value": 1},
{"op": "doc.delete", "path": "f.json", "key": "k"},
{"op": "doc.merge", "path": "f.json", "value": {}},
{"op": "doc.append", "path": "f.json", "key": "arr", "value": 1},
{"op": "doc.prepend", "path": "f.json", "key": "arr", "value": 0},
{"op": "doc.update", "path": "f.json", "key": "k", "value": 2},
{"op": "doc.move", "path": "f.json", "from": "a", "to": "b"},
{"op": "doc.ensure", "path": "f.json", "key": "k", "value": 1},
{"op": "doc.delete_where", "path": "f.json", "key": "arr", "predicate": "name=x"},
{"op": "md.replace_section", "path": "f.md", "heading": "H", "content": "c"},
{"op": "md.insert_after_heading", "path": "f.md", "heading": "H", "content": "c"},
{"op": "md.insert_before_heading", "path": "f.md", "heading": "H", "content": "c"},
{"op": "md.upsert_bullet", "path": "f.md", "heading": "H", "bullet": "- item"},
{"op": "md.table_append", "path": "f.md", "heading": "H", "row": "| a | b |"},
{"op": "md.move_section", "path": "src.md", "heading": "FAQ", "before": "License"},
{"op": "md.move_section", "path": "src.md", "heading": "Appendix", "to": "dest.md", "after": "Body"},
{"op": "md.dedupe_headings", "path": "f.md"},
{"op": "tidy.fix", "path": "f.txt"},
{"op": "tidy.fix", "path": "f.txt", "trim_trailing_whitespace": true, "normalize_eol": "lf"},
{"op": "file.append", "path": "f.txt", "content": "extra"},
{"op": "file.create", "path": "f.txt", "content": "c"},
{"op": "file.create", "path": "g.txt", "content": "c", "force": true},
{"op": "file.delete", "path": "f.txt"},
{"op": "file.rename", "from": "old.txt", "to": "new.txt"},
{"op": "file.rename", "from": "a.txt", "to": "b.txt", "force": true},
{"op": "patch.apply", "diff": "--- a/f.txt\n+++ b/f.txt\n@@ -1 +1 @@\n-a\n+b"},
{"op": "read", "path": "f.txt"},
{"op": "read", "path": "f.txt", "lines": "1:10"},
{"op": "search", "path": "f.txt", "pattern": "hello"},
{"op": "search", "path": "f.txt", "pattern": "he.*o", "regex": true, "case_insensitive": true, "multiline": true},
{"op": "search", "path": "f.txt", "pattern": "TODO", "invert_match": true, "assert_count": 5},
{"op": "search", "path": ".", "pattern": "foo", "literal": true, "exclude_patterns": ["target/**"], "custom_ignore_filenames": [".blineignore"], "max_results": 10},
{"op": "ast.rename", "path": "f.rs", "old_name": "Foo", "new_name": "Bar"},
{"op": "ast.replace", "path": "f.rs", "symbol": "main", "old": "a", "new": "b"},
{"op": "ast.insert", "path": "f.rs", "content": "fn new() {}", "after": "main"},
{"op": "ast.wrap", "path": "f.rs", "symbols": ["helper"], "wrapper": "mod internal"},
{"op": "ast.imports", "path": "f.rs", "add": ["use std::io;"]},
{"op": "ast.reorder", "path": "f.rs", "order": "alphabetical"},
{"op": "ast.reorder", "path": "f.rs", "order": ["b", "a"], "inside": "mod tests"},
{"op": "ast.group", "path": "f.rs", "module": "tests", "symbols": ["test_a"]},
{"op": "ast.move", "path": "src.rs", "target": "dst.rs", "symbols": ["foo"]},
{"op": "ast.extract_to_file", "source": "lib.rs", "symbol": "tests", "target": "lib_tests.rs"},
{"op": "ast.split", "source": "big.rs", "targets": [{"path": "a.rs", "symbols": ["A"]}]}
]}"#;
let plan = parse_plan(json).unwrap();
assert_eq!(plan.operations.len(), 45);
}
#[test]
#[cfg(feature = "ast")]
fn parse_op_aliases_match_mcp_tool_names() {
let json = r#"{"version": 1, "operations": [
{"op": "replace_text", "old": "a", "new": "b"},
{"op": "doc_set", "path": "f.json", "key": "k", "value": 1},
{"op": "doc_delete", "path": "f.json", "key": "k"},
{"op": "doc_merge", "path": "f.json", "value": {}},
{"op": "doc_append", "path": "f.json", "key": "arr", "value": 1},
{"op": "doc_ensure", "path": "f.json", "key": "k", "value": 1},
{"op": "doc_delete_where", "path": "f.json", "key": "arr", "predicate": "x=1"},
{"op": "md_move_section", "path": "f.md", "heading": "H", "before": "X"},
{"op": "md_replace_section", "path": "f.md", "heading": "H", "content": "c"},
{"op": "md_upsert_bullet", "path": "f.md", "heading": "H", "bullet": "- item"},
{"op": "md_table_append", "path": "f.md", "heading": "H", "row": "| a |"},
{"op": "fix_whitespace", "path": "f.txt"},
{"op": "append_file", "path": "f.txt", "content": "extra"},
{"op": "create_file", "path": "f.txt", "content": "c"},
{"op": "delete_file", "path": "f.txt"},
{"op": "move_file", "from": "a.txt", "to": "b.txt"},
{"op": "apply_patch", "diff": "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-a\n+b"},
{"op": "search_files", "path": ".", "pattern": "x"},
{"op": "read_file", "path": "f.txt"},
{"op": "md_lint", "path": "f.md"},
{"op": "ast_rename", "path": "f.rs", "old_name": "A", "new_name": "B"},
{"op": "ast_replace", "path": "f.rs", "symbol": "main", "old": "x", "new": "y"},
{"op": "ast_insert", "path": "f.rs", "content": "fn x() {}", "inside": "Foo"},
{"op": "ast_wrap", "path": "f.rs", "lines": "1:10", "wrapper": "mod m"},
{"op": "ast_imports", "path": "f.rs", "remove": ["use old;"]},
{"op": "ast_reorder", "path": "f.rs", "order": "reverse"},
{"op": "ast_group", "path": "f.rs", "module": "m", "symbols": ["x"]},
{"op": "ast_move", "path": "a.rs", "target": "b.rs", "symbols": ["f"]},
{"op": "ast_extract_to_file", "source": "a.rs", "symbol": "tests", "target": "a_tests.rs"},
{"op": "ast_split", "source": "big.rs", "targets": [{"path": "s.rs", "symbols": ["S"]}]}
]}"#;
let plan = parse_plan(json).unwrap();
assert_eq!(plan.operations.len(), 30);
}
#[test]
fn parse_plan_with_for_each() {
let json = r#"{
"version": 1,
"for_each": {
"glob": "src/**/*.rs",
"exclude": ["src/main.rs"],
"filter": "has_symbol(tests)"
},
"operations": [{"op": "replace", "path": "{path}", "old": "a", "new": "b"}]
}"#;
let plan = parse_plan(json).unwrap();
let fe = plan.for_each.unwrap();
assert_eq!(fe.glob, "src/**/*.rs");
assert_eq!(fe.exclude, vec!["src/main.rs"]);
assert_eq!(fe.filter.as_deref(), Some("has_symbol(tests)"));
}
#[test]
fn parse_plan_without_for_each_is_none() {
let json = r#"{"version": 1, "operations": [{"op": "replace", "old": "a", "new": "b"}]}"#;
let plan = parse_plan(json).unwrap();
assert!(plan.for_each.is_none());
}
#[cfg(feature = "cli")]
#[test]
fn for_each_escape_preserves_literal_braces() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), "hello").unwrap();
let json = r#"{
"version": 1,
"for_each": { "glob": "*.txt" },
"operations": [
{"op": "replace", "path": "{path}", "old": "hello", "new": "{{path}} is literal"}
]
}"#;
let mut plan = parse_plan(json).unwrap();
expand_for_each(&mut plan, dir.path()).unwrap();
assert_eq!(plan.operations.len(), 1);
let op_json = serde_json::to_string(&plan.operations[0]).unwrap();
assert!(
op_json.contains(r#"{path} is literal"#),
"escaped braces should produce literal {{path}}: {op_json}"
);
assert!(
!op_json.contains("a.txt is literal"),
"escaped braces should NOT be substituted: {op_json}"
);
}
#[cfg(feature = "cli")]
#[test]
fn for_each_escape_mixed_literal_and_template() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("test.rs"), "x").unwrap();
let json = r#"{
"version": 1,
"for_each": { "glob": "*.rs" },
"operations": [
{"op": "replace", "path": "{path}", "old": "x", "new": "file={{stem}}.{{ext}}"}
]
}"#;
let mut plan = parse_plan(json).unwrap();
expand_for_each(&mut plan, dir.path()).unwrap();
let op_json = serde_json::to_string(&plan.operations[0]).unwrap();
assert!(
op_json.contains("file={stem}.{ext}"),
"escaped template vars should be literal: {op_json}"
);
}
#[cfg(feature = "cli")]
#[test]
fn for_each_unescaped_braces_still_substitute() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hello.txt"), "x").unwrap();
let json = r#"{
"version": 1,
"for_each": { "glob": "*.txt" },
"operations": [
{"op": "replace", "path": "{path}", "old": "x", "new": "{stem}-{ext}"}
]
}"#;
let mut plan = parse_plan(json).unwrap();
expand_for_each(&mut plan, dir.path()).unwrap();
let op_json = serde_json::to_string(&plan.operations[0]).unwrap();
assert!(
op_json.contains("hello-txt"),
"unescaped vars should substitute: {op_json}"
);
}
#[test]
fn parse_plan_with_format_steps() {
let json = r#"{
"version": 1,
"operations": [],
"format": [{"cmd": "cargo fmt"}],
"validate": [{"cmd": "make check"}]
}"#;
let plan = parse_plan(json).unwrap();
let fmt = plan.format.unwrap();
assert_eq!(fmt.len(), 1);
assert_eq!(fmt[0].cmd, "cargo fmt");
}
#[test]
fn parse_plan_yaml_basic() {
let yaml = "version: 1\noperations:\n - op: replace\n old: old\n new: new\n";
let plan = parse_plan_yaml(yaml).unwrap();
assert_eq!(plan.operations.len(), 1);
assert!(matches!(
&plan.operations[0],
Operation::Replace { old, new_text, .. } if old == "old" && new_text.as_deref() == Some("new")
));
}
#[test]
fn parse_plan_toml_basic() {
let toml =
"version = 1\n\n[[operations]]\nop = \"replace\"\nold = \"old\"\nnew = \"new\"\n";
let plan = parse_plan_toml(toml).unwrap();
assert_eq!(plan.operations.len(), 1);
assert!(matches!(
&plan.operations[0],
Operation::Replace { old, new_text, .. } if old == "old" && new_text.as_deref() == Some("new")
));
}
#[test]
fn parse_plan_auto_detects_yaml() {
let yaml = "version: 1\noperations:\n - op: replace\n old: a\n new: b\n";
let plan = parse_plan_auto(yaml, Some("plan.yaml"), None).unwrap();
assert_eq!(plan.operations.len(), 1);
}
#[test]
fn parse_plan_auto_format_hint_overrides_extension() {
let yaml = "version: 1\noperations:\n - op: replace\n old: a\n new: b\n";
let plan = parse_plan_auto(yaml, Some("plan.json"), Some("yaml")).unwrap();
assert_eq!(plan.operations.len(), 1);
}
#[test]
fn parse_plan_auto_defaults_to_json() {
let json = r#"{"version": 1, "operations": [{"op": "replace", "old": "a", "new": "b"}]}"#;
let plan = parse_plan_auto(json, Some("plan.txt"), None).unwrap();
assert_eq!(plan.operations.len(), 1);
}
#[test]
fn parse_plan_defaults_strict_when_omitted() {
let json = r#"{"version": 1, "operations": [{"op": "replace", "old": "a", "new": "b"}]}"#;
let plan = parse_plan(json).unwrap();
assert_eq!(plan.strict, None);
assert!(effective_strict(plan.strict, None, false));
assert!(!effective_strict(plan.strict, None, true));
assert!(!effective_strict(Some(true), None, true));
assert!(!effective_strict(None, Some(false), false));
assert!(effective_strict(Some(true), Some(false), false));
}
#[test]
fn parse_plan_strict_and_all_policy_fields() {
let json = r#"{
"version": 1,
"strict": true,
"write_policy": {
"ensure_final_newline": true,
"normalize_eol": "crlf",
"trim_trailing_whitespace": true,
"collapse_blanks": true
},
"operations": [],
"format": [{"cmd": "fmt", "timeout": 30}],
"validate": [{"cmd": "check", "required": true, "timeout": 120}]
}"#;
let plan = parse_plan(json).unwrap();
assert_eq!(plan.strict, Some(true));
let wp = plan.write_policy.unwrap();
assert_eq!(wp.ensure_final_newline, Some(true));
assert_eq!(wp.normalize_eol.as_deref(), Some("crlf"));
assert_eq!(wp.trim_trailing_whitespace, Some(true));
assert_eq!(wp.collapse_blanks, Some(true));
let fmt = &plan.format.unwrap()[0];
assert_eq!(fmt.timeout, Some(30));
let val = &plan.validate.unwrap()[0];
assert_eq!(val.required, Some(true));
assert_eq!(val.timeout, Some(120));
}
#[test]
fn declared_paths_covers_operation_variants() {
let json = r#"{"version": 1,"operations":[{"op":"replace","path":"src/main.rs","glob":"**/*.rs","old":"old","new":"new"}]}"#;
let plan = parse_plan(json).unwrap();
let ps = declared_paths(&plan.operations[0]);
assert!(ps.contains(&"src/main.rs") && ps.contains(&"**/*.rs"));
let json = r#"{"version": 1,"operations":[{"op":"file.rename","from":"old.txt","to":"new.txt","force":false}]}"#;
let plan = parse_plan(json).unwrap();
assert_eq!(
declared_paths(&plan.operations[0]),
vec!["old.txt", "new.txt"]
);
let json = r#"{"version": 1,"operations":[{"op":"md.move_section","path":"doc.md","heading":"Section"}]}"#;
let plan = parse_plan(json).unwrap();
assert_eq!(declared_paths(&plan.operations[0]), vec!["doc.md"]);
let json = r#"{"version": 1,"operations":[{"op":"md.move_section","path":"src.md","heading":"H","to":"dst.md"}]}"#;
let plan = parse_plan(json).unwrap();
let ps = declared_paths(&plan.operations[0]);
assert!(ps.contains(&"src.md") && ps.contains(&"dst.md"));
let json = r#"{"version": 1,"operations":[{"op":"patch.apply","diff":"--- a/x\n+++ b/x\n@@ -1 +1 @@\n- old\n+ new\n"}]}"#;
let plan = parse_plan(json).unwrap();
assert!(declared_paths(&plan.operations[0]).is_empty());
let json = r#"{"version": 1,"operations":[{"op":"doc.set","path":"c.json","key":"v","value":42}]}"#;
let plan = parse_plan(json).unwrap();
assert_eq!(declared_paths(&plan.operations[0]), vec!["c.json"]);
let json = r#"{"version": 1,"operations":[{"op":"read","path":"f.txt"}]}"#;
let plan = parse_plan(json).unwrap();
assert_eq!(declared_paths(&plan.operations[0]), vec!["f.txt"]);
}
#[test]
fn op_to_doc_mutation_covers_all_doc_variants() {
use crate::ops::doc::DocMutation;
let cases = [
r#"{"op":"doc.set","path":"f.json","key":"k","value":1}"#,
r#"{"op":"doc.delete","path":"f.json","key":"k"}"#,
r#"{"op":"doc.merge","path":"f.json","value":{}}"#,
r#"{"op":"doc.append","path":"f.json","key":"arr","value":1}"#,
r#"{"op":"doc.prepend","path":"f.json","key":"arr","value":0}"#,
r#"{"op":"doc.update","path":"f.json","key":"k","value":2}"#,
r#"{"op":"doc.move","path":"f.json","from":"a","to":"b"}"#,
r#"{"op":"doc.ensure","path":"f.json","key":"k","value":1}"#,
r#"{"op":"doc.delete_where","path":"f.json","key":"arr","predicate":"n=x"}"#,
];
for (i, case) in cases.iter().enumerate() {
let json = format!(r#"{{"version": 1,"operations":[{case}]}}"#);
let plan = parse_plan(&json).unwrap();
let result = op_to_doc_mutation(&plan.operations[0]);
assert!(
result.is_some(),
"doc variant {i} should return Some, got None"
);
let (path, _mutation) = result.unwrap();
assert_eq!(path, "f.json", "variant {i} path mismatch");
}
let non_doc = r#"{"version": 1,"operations":[{"op":"replace","old":"a","new":"b"}]}"#;
let plan = parse_plan(non_doc).unwrap();
assert!(op_to_doc_mutation(&plan.operations[0]).is_none());
let set_json = r#"{"version": 1,"operations":[{"op":"doc.set","path":"x.json","key":"key","value":"val"}]}"#;
let plan = parse_plan(set_json).unwrap();
let (_, mutation) = op_to_doc_mutation(&plan.operations[0]).unwrap();
assert!(matches!(mutation, DocMutation::Set { .. }));
}
#[test]
fn needs_doc_flush_includes_file_create_delete_rename() {
let create = Operation::FileCreate {
path: "f.json".into(),
content: "{}".into(),
force: Some(false),
};
assert!(
create.needs_doc_flush(),
"FileCreate must trigger doc flush"
);
let delete = Operation::FileDelete {
path: "f.json".into(),
};
assert!(
delete.needs_doc_flush(),
"FileDelete must trigger doc flush"
);
let rename = Operation::FileRename {
from: "a.json".into(),
to: "b.json".into(),
force: false,
};
assert!(
rename.needs_doc_flush(),
"FileRename must trigger doc flush"
);
}
}