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)]
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)]
pub struct FormatStep {
#[serde(alias = "command")]
pub cmd: String,
pub timeout: Option<u64>,
}
mod operation;
pub use operation::Operation;
#[cfg(feature = "ast")]
pub use operation::SplitTargetSpec;
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,
selector,
value,
} => Some((
path,
DocMutation::Set {
selector: selector.clone(),
value: value.clone(),
},
)),
Operation::DocDelete { path, selector } => Some((
path,
DocMutation::Delete {
selector: selector.clone(),
},
)),
Operation::DocMerge { path, value } => Some((
path,
DocMutation::Merge {
value: value.clone(),
},
)),
Operation::DocAppend {
path,
selector,
value,
} => Some((
path,
DocMutation::Append {
selector: selector.clone(),
value: value.clone(),
},
)),
Operation::DocPrepend {
path,
selector,
value,
} => Some((
path,
DocMutation::Prepend {
selector: selector.clone(),
value: value.clone(),
},
)),
Operation::DocUpdate {
path,
selector,
value,
} => Some((
path,
DocMutation::Update {
selector: selector.clone(),
value: value.clone(),
},
)),
Operation::DocMove { path, from, to } => Some((
path,
DocMutation::Move {
from: from.clone(),
to: to.clone(),
},
)),
Operation::DocEnsure {
path,
selector,
value,
} => Some((
path,
DocMutation::Ensure {
selector: selector.clone(),
value: value.clone(),
},
)),
Operation::DocDeleteWhere {
path,
selector,
predicate,
} => Some((
path,
DocMutation::DeleteWhere {
selector: selector.clone(),
predicate: predicate.clone(),
},
)),
_ => None,
}
}
pub(crate) fn declared_paths(op: &Operation) -> Vec<String> {
match op {
Operation::Replace { path, glob, .. } => {
let mut p = Vec::new();
if let Some(s) = path {
p.push(s.clone());
}
if let Some(s) = glob {
p.push(s.clone());
}
p
}
Operation::FileRename { from, to, .. } => vec![from.clone(), to.clone()],
Operation::MdMoveSection { path, to, .. } => {
let mut p = vec![path.clone()];
if let Some(t) = to {
p.push(t.clone());
}
p
}
Operation::PatchApply { diff, .. } => {
match crate::ops::patch::parse_patch(diff) {
Ok(files) => files.into_iter().map(|pf| pf.path).collect(),
Err(_) => 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.clone()],
#[cfg(feature = "ast")]
Operation::AstRename { path, .. }
| Operation::AstReplace { path, .. }
| Operation::AstRewriteSignature { path, .. }
| Operation::AstInsert { path, .. }
| Operation::AstWrap { path, .. }
| Operation::AstImports { path, .. }
| Operation::AstReorder { path, .. }
| Operation::AstGroup { path, .. } => {
vec![path.clone()]
}
#[cfg(feature = "ast")]
Operation::AstMove { path, target, .. } => vec![path.clone(), target.clone()],
#[cfg(feature = "ast")]
Operation::AstExtractToFile { source, target, .. } => {
vec![source.clone(), target.clone()]
}
#[cfg(feature = "ast")]
Operation::AstSplit {
source, targets, ..
} => {
let mut p = vec![source.clone()];
for t in targets {
p.push(t.path.clone());
}
p
}
}
}
#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
pub struct ValidationStep {
#[serde(alias = "command")]
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()
.expect("i < len guarantees non-empty slice");
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;