use crate::cli::global::GlobalFlags;
use crate::exit;
use crate::plan::{Operation, Plan};
use clap::Args;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
pub const MAX_BATCH_OPERATIONS: usize = 10_000;
#[derive(Debug, Args)]
#[command(after_help = r#"OPERATIONS:
doc.set, doc.delete, doc.merge, doc.ensure, doc.append, doc.prepend,
doc.update, doc.move, doc.delete_where, replace (optional flags: --fuzzy, …), file.create,
file.delete, file.rename, file.append, file.prepend, md.upsert_bullet,
md.table_append, md.replace_section, md.insert_after_heading,
md.insert_after_section, md.insert_before_heading (alias md.insert_before_section),
md.move_section, md.dedupe_headings,
md.lint_agents, tidy.fix, ast.rename, ast.replace, ast.replace_symbol,
ast.delete_symbol, ast.rewrite_signature, ast.insert, ast.wrap, ast.imports,
ast.reorder, ast.group, ast.move, ast.extract_to_file, ast.split
REPLACE SHAPE:
Batch: replace PATH OLD NEW [--fuzzy …]
CLI: patchloom replace OLD --new NEW path
Do not paste CLI --new into a batch line (parse error with PATH OLD NEW hint).
PLAN KEYS:
Optional key= prefixes on positionals are peeled (content=, body=, old=, new=,
heading=, bullet=, row=, from=, to=, selector=, key=, value=, path=,
predicate=, symbol=, parameters=, return_type=, before=, after=).
EXAMPLES:
# Force string values with nested JSON quotes (bare 2.0 becomes a number).
printf 'doc.set config.json version '"'"'"2.0.0"'"'"'\nreplace README.md v1 v2\n' | patchloom batch
patchloom batch --apply <<'EOF'
doc.set package.json version '"3.0.0"'
replace README.md "v1.0" "v3.0"
replace src/main.rs "proccess" "process" --fuzzy --min-fuzzy-score 0.80
EOF"#)]
pub struct BatchArgs {
#[arg(default_value = "-")]
pub input: String,
#[command(flatten)]
pub write: crate::cli::global::WriteFlags,
}
macro_rules! op {
($Variant:ident { $($tt:tt)* }) => {
Ok(Operation::$Variant { $($tt)* })
};
}
macro_rules! doc_psv {
($op_name:expr, $args:expr, $line_num:expr, $Variant:ident) => {{
require_args($op_name, $args, 3, $line_num)?;
let value = parse_json_value(&peel_owned(&$args[2], &["value"]))?;
op!($Variant {
path: peel_owned(&$args[0], &["path"]),
selector: peel_owned(&$args[1], &["selector", "key"]),
value
})
}};
}
macro_rules! md_phc {
($op_name:expr, $args:expr, $line_num:expr, $Variant:ident, $field3:ident) => {{
require_args($op_name, $args, 3, $line_num)?;
let third_keys: &[&str] = match stringify!($field3) {
"content" => &["content", "body"],
"bullet" => &["bullet"],
"row" => &["row"],
_ => &[],
};
op!($Variant {
path: peel_owned(&$args[0], &["path"]),
heading: peel_owned(&$args[1], &["heading"]),
$field3: peel_owned(&$args[2], third_keys)
})
}};
}
#[cfg(test)]
fn parse_line(line: &str, line_num: usize) -> anyhow::Result<Operation> {
parse_line_at(line, line_num, None, None)
}
fn parse_line_at(
line: &str,
line_num: usize,
cwd: Option<&Path>,
global: Option<&GlobalFlags>,
) -> anyhow::Result<Operation> {
let tokens = tokenize_tokens(line).map_err(|e| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: {e}"),
})
})?;
if tokens.is_empty() {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: empty operation"),
}));
}
let op = match tokens[0].as_str() {
"md.insert_before_section" => "md.insert_before_heading",
other => other,
};
let args = &tokens[1..];
match op {
"doc.set" => {
let (args, if_exists) = peel_if_exists_flag(op, args, line_num)?;
require_args(op, &args, 3, line_num)?;
let value = parse_json_value(&peel_owned(&args[2], &["value"]))?;
op!(DocSet {
path: peel_owned(&args[0], &["path"]),
selector: peel_owned(&args[1], &["selector", "key"]),
value,
if_exists
})
}
"doc.ensure" => doc_psv!(op, args, line_num, DocEnsure),
"doc.append" => doc_psv!(op, args, line_num, DocAppend),
"doc.prepend" => doc_psv!(op, args, line_num, DocPrepend),
"doc.update" => doc_psv!(op, args, line_num, DocUpdate),
"doc.delete" => {
require_args(op, args, 2, line_num)?;
op!(DocDelete {
path: peel_owned(&args[0], &["path"]),
selector: peel_owned(&args[1], &["selector", "key"])
})
}
"doc.merge" => {
match args.len() {
2 => {
let value = parse_json_value(&peel_owned(&args[1], &["value"]))?;
op!(DocMerge {
path: peel_owned(&args[0], &["path"]),
selector: None,
value
})
}
3 => {
let value = parse_json_value(&peel_owned(&args[2], &["value"]))?;
op!(DocMerge {
path: peel_owned(&args[0], &["path"]),
selector: Some(peel_owned(&args[1], &["selector", "key"])),
value
})
}
n => Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: {op}: expected 2 args (path value) or 3 (path selector value), got {n}"
),
})),
}
}
"doc.move" => {
require_args(op, args, 3, line_num)?;
op!(DocMove {
path: peel_owned(&args[0], &["path"]),
from: peel_owned(&args[1], &["from", "selector", "key"]),
to: peel_owned(&args[2], &["to"])
})
}
"doc.delete_where" => {
require_args(op, args, 3, line_num)?;
op!(DocDeleteWhere {
path: peel_owned(&args[0], &["path"]),
selector: peel_owned(&args[1], &["selector", "key"]),
predicate: peel_owned(&args[2], &["predicate"])
})
}
"replace" => parse_replace_line(args, line_num, cwd, global),
"file.append" => {
let (path, content) = path_and_joined_content(op, args, line_num)?;
op!(FileAppend { path, content })
}
"file.prepend" => {
let (path, content) = path_and_joined_content(op, args, line_num)?;
op!(FilePrepend { path, content })
}
"file.create" => {
let (path, content, force) = path_and_joined_content_create(op, args, line_num)?;
op!(FileCreate {
path,
content,
force: if force { Some(true) } else { None }
})
}
"file.delete" => {
let (args, if_exists) = peel_if_exists_flag(op, args, line_num)?;
require_args(op, &args, 1, line_num)?;
op!(FileDelete {
path: peel_owned(&args[0], &["path"]),
if_exists
})
}
"file.rename" => {
let mut force = false;
let mut path_args: Vec<BatchToken> = Vec::new();
for tok in args {
if tok.as_str() == "--force" {
force = true;
continue;
}
if tok.starts_with("--") {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: 'file.rename' unknown flag {}; only --force is supported",
tok.as_str()
),
}));
}
path_args.push(tok.clone());
}
if path_args.len() != 2 {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: 'file.rename' requires exactly 2 arguments (from to [--force]), got {}",
path_args.len()
),
}));
}
op!(FileRename {
from: peel_owned(&path_args[0], &["from"]),
to: peel_owned(&path_args[1], &["to"]),
force
})
}
"md.upsert_bullet" => md_phc!(op, args, line_num, MdUpsertBullet, bullet),
"md.table_append" => md_phc!(op, args, line_num, MdTableAppend, row),
"md.replace_section" => md_phc!(op, args, line_num, MdReplaceSection, content),
"md.insert_after_heading" => md_phc!(op, args, line_num, MdInsertAfterHeading, content),
"md.insert_after_section" => md_phc!(op, args, line_num, MdInsertAfterSection, content),
"md.insert_before_heading" => md_phc!(op, args, line_num, MdInsertBeforeHeading, content),
"md.move_section" => {
if args.len() == 4 {
let (before, after) = parse_position_keyword(args[2].as_str(), line_num)?;
Ok(Operation::MdMoveSection {
path: peel_owned(&args[0], &["path"]),
heading: peel_owned(&args[1], &["heading"]),
to: None,
before: before.map(|_| peel_owned(&args[3], &["before", "target", "heading"])),
after: after.map(|_| peel_owned(&args[3], &["after", "target", "heading"])),
})
} else if args.len() == 5 {
let (before, after) = parse_position_keyword(args[3].as_str(), line_num)?;
Ok(Operation::MdMoveSection {
path: peel_owned(&args[0], &["path"]),
heading: peel_owned(&args[1], &["heading"]),
to: Some(peel_owned(&args[2], &["to", "dest", "path"])),
before: before.map(|_| peel_owned(&args[4], &["before", "target", "heading"])),
after: after.map(|_| peel_owned(&args[4], &["after", "target", "heading"])),
})
} else {
Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: md.move_section requires 4 args (same-file) or 5 args (cross-file)"
),
}))
}
}
"md.dedupe_headings" => {
require_args(op, args, 1, line_num)?;
op!(MdDedupeHeadings {
path: peel_owned(&args[0], &["path"])
})
}
"md.lint_agents" => {
require_args(op, args, 1, line_num)?;
op!(MdLintAgents {
path: peel_owned(&args[0], &["path"])
})
}
"tidy.fix" => {
require_args(op, args, 1, line_num)?;
op!(TidyFix {
path: peel_owned(&args[0], &["path"]),
ensure_final_newline: None,
trim_trailing_whitespace: None,
normalize_eol: None,
collapse_blanks: None,
dedent: None,
indent: None,
lines: None,
})
}
#[cfg(feature = "ast")]
"ast.rename" => {
require_args(op, args, 3, line_num)?;
op!(AstRename {
path: peel_owned(&args[0], &["path"]),
old: peel_owned(&args[1], &["old", "from"]),
new: peel_owned(&args[2], &["new", "to"]),
lang: None
})
}
#[cfg(feature = "ast")]
"ast.replace" => {
require_args(op, args, 4, line_num)?;
op!(AstReplace {
path: peel_owned(&args[0], &["path"]),
symbol: peel_owned(&args[1], &["symbol"]),
old: peel_owned(&args[2], &["old", "from"]),
new_text: peel_owned(&args[3], &["new", "to", "new_text"]),
regex: false,
lang: None
})
}
#[cfg(feature = "ast")]
"ast.replace_symbol" => {
require_args(op, args, 3, line_num)?;
op!(AstReplaceSymbol {
path: peel_owned(&args[0], &["path"]),
symbol: peel_owned(&args[1], &["symbol"]),
content: peel_owned(&args[2], &["content"]),
lang: None
})
}
#[cfg(feature = "ast")]
"ast.delete_symbol" => {
require_args(op, args, 2, line_num)?;
op!(AstDeleteSymbol {
path: peel_owned(&args[0], &["path"]),
symbol: peel_owned(&args[1], &["symbol"]),
lang: None
})
}
#[cfg(feature = "ast")]
"ast.rewrite_signature" => {
if args.len() < 3 || args.len() > 4 {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: 'ast.rewrite_signature' requires 3 or 4 arguments \
(path old parameters [return_type]), got {}",
args.len()
),
}));
}
op!(AstRewriteSignature {
path: peel_owned(&args[0], &["path"]),
old: peel_owned(&args[1], &["old", "from", "symbol"]),
new_signature: None,
visibility: None,
parameters: Some(peel_owned(&args[2], &["parameters", "params"])),
return_type: args
.get(3)
.map(|t| peel_owned(t, &["return_type", "return", "ret"])),
lang: None
})
}
#[cfg(feature = "ast")]
"ast.insert" => parse_ast_insert(args, line_num, cwd, global),
#[cfg(feature = "ast")]
"ast.wrap" => parse_ast_wrap(args, line_num, cwd, global),
#[cfg(feature = "ast")]
"ast.imports" => parse_ast_imports(args, line_num),
#[cfg(feature = "ast")]
"ast.reorder" => parse_ast_reorder(args, line_num),
#[cfg(feature = "ast")]
"ast.group" => parse_ast_group(args, line_num, cwd, global),
#[cfg(feature = "ast")]
"ast.move" => parse_ast_move(args, line_num),
#[cfg(feature = "ast")]
"ast.extract_to_file" => parse_ast_extract_to_file(args, line_num),
#[cfg(feature = "ast")]
"ast.split" => parse_ast_split(args, line_num),
_ => Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: unknown_batch_op_msg(line_num, op),
})),
}
}
const KNOWN_BATCH_OPS: &[&str] = &[
"doc.set",
"doc.delete",
"doc.merge",
"doc.ensure",
"doc.append",
"doc.prepend",
"doc.update",
"doc.move",
"doc.delete_where",
"replace",
"file.create",
"file.delete",
"file.rename",
"file.append",
"file.prepend",
"md.upsert_bullet",
"md.table_append",
"md.replace_section",
"md.insert_after_heading",
"md.insert_after_section",
"md.insert_before_heading",
"md.move_section",
"md.dedupe_headings",
"md.lint_agents",
"tidy.fix",
"ast.rename",
"ast.replace",
"ast.replace_symbol",
"ast.delete_symbol",
"ast.rewrite_signature",
"ast.insert",
"ast.wrap",
"ast.imports",
"ast.reorder",
"ast.group",
"ast.move",
"ast.extract_to_file",
"ast.split",
];
fn batch_unsupported_hint(op: &str) -> Option<&'static str> {
match op {
"read" => Some("not supported in batch; use `patchloom read` or a tx plan"),
"search" => Some("not supported in batch; use `patchloom search` or a tx plan"),
"patch" | "patch.apply" => {
Some("not supported in batch; use `patchloom patch` or a tx plan")
}
"status" | "git_status" => Some("not supported in batch; use `patchloom status`"),
"undo" => Some("not supported in batch; use `patchloom undo`"),
"explain" => Some("not supported in batch; use `patchloom explain`"),
"apply.fragment" | "apply_fragment" | "apply-fragment" => {
Some("not supported in batch; use a tx plan (`apply.fragment`) or MCP `apply_fragment`")
}
"notebook.edit" | "notebook_edit" | "notebook-edit" => {
Some("not supported in batch; use a tx plan (`notebook.edit`) or MCP `notebook_edit`")
}
_ => None,
}
}
fn suggest_batch_op(op: &str) -> Option<String> {
if let Some((_, leaf)) = op.split_once('.')
&& KNOWN_BATCH_OPS.contains(&leaf)
{
return Some(leaf.to_string());
}
let mut suffix: Vec<&str> = KNOWN_BATCH_OPS
.iter()
.copied()
.filter(|k| k.rsplit_once('.').is_some_and(|(_, leaf)| leaf == op))
.collect();
if !suffix.is_empty() {
suffix.sort_unstable();
return Some(suffix.join(", "));
}
let mut scored: Vec<(&str, f64)> = KNOWN_BATCH_OPS
.iter()
.copied()
.map(|k| {
let leaf = k.rsplit_once('.').map(|(_, l)| l).unwrap_or(k);
let score = strsim::jaro_winkler(op, k).max(strsim::jaro_winkler(op, leaf));
(k, score)
})
.filter(|(_, score)| *score >= 0.86)
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
if scored.is_empty() {
return None;
}
let top: Vec<&str> = scored.into_iter().take(3).map(|(k, _)| k).collect();
Some(top.join(", "))
}
fn unknown_batch_op_msg(line_num: usize, op: &str) -> String {
if let Some(hint) = batch_unsupported_hint(op) {
return format!("line {line_num}: unknown operation '{op}' ({hint})");
}
if let Some(suggestion) = suggest_batch_op(op) {
return format!("line {line_num}: unknown operation '{op}' (did you mean: {suggestion}?)");
}
format!("line {line_num}: unknown operation '{op}'")
}
fn parse_position_keyword(
keyword: &str,
line_num: usize,
) -> anyhow::Result<(Option<()>, Option<()>)> {
match keyword {
"before" => Ok((Some(()), None)),
"after" => Ok((None, Some(()))),
_ => Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: expected 'before' or 'after', got '{keyword}'"),
})),
}
}
fn peel_if_exists_flag(
op: &str,
args: &[BatchToken],
line_num: usize,
) -> anyhow::Result<(Vec<BatchToken>, bool)> {
let mut if_exists = false;
let mut out = Vec::with_capacity(args.len());
for tok in args {
if tok.as_str() == "--if-exists" {
if_exists = true;
continue;
}
if tok.starts_with("--") {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: '{op}' unknown flag {}; only --if-exists is supported",
tok.as_str()
),
}));
}
out.push(tok.clone());
}
Ok((out, if_exists))
}
fn require_args(
op: &str,
args: &[BatchToken],
expected: usize,
line_num: usize,
) -> anyhow::Result<()> {
if args.len() != expected {
let s = if expected == 1 { "" } else { "s" };
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: '{op}' requires exactly {expected} argument{s}, got {}",
args.len()
),
}));
}
Ok(())
}
fn batch_replace_cli_flag_confusion(tok: &str) -> Option<&str> {
let bare = tok.split_once('=').map(|(k, _)| k).unwrap_or(tok);
match bare {
"--new" => Some("--new"),
"--old" | "--from" | "--to" => Some(bare),
_ => None,
}
}
fn batch_replace_cli_shape_hint(line_num: usize, flag: &str) -> anyhow::Error {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: batch replace does not accept CLI flag '{flag}'. \
Batch is `replace PATH OLD NEW` (not CLI `replace OLD --new NEW path`). \
Example: replace README.md \"old text\" \"new text\""
),
})
}
fn path_is_file_under_cwd(p: &str, cwd: Option<&Path>) -> bool {
let path = Path::new(p);
if path.is_absolute() {
path.is_file()
} else if let Some(cwd) = cwd {
cwd.join(p).is_file()
} else {
path.is_file()
}
}
fn path_under_cwd(p: &str, cwd: Option<&Path>) -> std::path::PathBuf {
let path = Path::new(p);
if path.is_absolute() {
path.to_path_buf()
} else if let Some(cwd) = cwd {
cwd.join(p)
} else {
path.to_path_buf()
}
}
fn resolve_at_payload(
value: &str,
cwd: Option<&Path>,
global: Option<&GlobalFlags>,
line_num: usize,
field: &str,
) -> anyhow::Result<String> {
let Some(rest) = value.strip_prefix('@') else {
return Ok(value.to_string());
};
if rest.is_empty() {
return Ok(value.to_string());
}
if let (Some(global), Some(cwd)) = (global, cwd) {
global.check_paths_contained(cwd, [rest])?;
}
let path = path_under_cwd(rest, cwd);
if !path.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("line {line_num}: {field} file '{rest}' not found"),
)
.into());
}
std::fs::read_to_string(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("line {line_num}: {field} file '{rest}' not found"),
)
.into()
} else {
anyhow::Error::new(crate::exit::InvalidInputError {
msg: format!("line {line_num}: failed to read {field} file '{rest}': {e}"),
})
}
})
}
fn resolve_content_token(
tok: &BatchToken,
keys: &[&str],
cwd: Option<&Path>,
global: Option<&GlobalFlags>,
line_num: usize,
field: &str,
) -> anyhow::Result<String> {
let raw = peel_owned(tok, keys);
resolve_at_payload(&raw, cwd, global, line_num, field)
}
fn split_csv_symbols(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
struct PeeledFlags {
positionals: Vec<BatchToken>,
values: BTreeMap<String, Vec<String>>,
switches: BTreeSet<String>,
}
fn peel_cli_flags(
args: &[BatchToken],
valued: &[&str],
switches: &[&str],
line_num: usize,
op: &str,
) -> anyhow::Result<PeeledFlags> {
let mut positionals = Vec::new();
let mut values: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut switch_set = BTreeSet::new();
let mut i = 0usize;
while i < args.len() {
let tok = args[i].as_str();
if let Some(rest) = tok.strip_prefix("--") {
let (name, inline) = match rest.split_once('=') {
Some((n, v)) => (n, Some(v.to_string())),
None => (rest, None),
};
if switches.contains(&name) {
if inline.is_some() {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: '{op}' flag --{name} does not take a value"),
}));
}
switch_set.insert(name.to_string());
i += 1;
continue;
}
if valued.contains(&name) {
let val = if let Some(v) = inline {
v
} else {
i += 1;
args.get(i).map(|t| t.value.clone()).ok_or_else(|| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: '{op}' --{name} requires a value"),
})
})?
};
values.entry(name.to_string()).or_default().push(val);
i += 1;
continue;
}
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: '{op}' unknown flag --{name}"),
}));
}
positionals.push(args[i].clone());
i += 1;
}
Ok(PeeledFlags {
positionals,
values,
switches: switch_set,
})
}
fn first_flag<'a>(flags: &'a PeeledFlags, name: &str) -> Option<&'a str> {
flags
.values
.get(name)
.and_then(|v| v.first())
.map(String::as_str)
}
fn flag_values(flags: &PeeledFlags, name: &str) -> Vec<String> {
flags.values.get(name).cloned().unwrap_or_default()
}
#[cfg(feature = "ast")]
fn parse_ast_insert(
args: &[BatchToken],
line_num: usize,
cwd: Option<&Path>,
global: Option<&GlobalFlags>,
) -> anyhow::Result<Operation> {
let flags = peel_cli_flags(
args,
&["inside", "after", "before", "position", "content", "lang"],
&[],
line_num,
"ast.insert",
)?;
if flags.positionals.is_empty() {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'ast.insert' requires a path argument"),
}));
}
let path = peel_owned(&flags.positionals[0], &["path"]);
let content = if let Some(c) = first_flag(&flags, "content") {
resolve_at_payload(c, cwd, global, line_num, "content")?
} else if flags.positionals.len() >= 2 {
resolve_content_token(
&flags.positionals[1],
&["content", "body"],
cwd,
global,
line_num,
"content",
)?
} else {
return Err(anyhow::Error::new(crate::exit::InvalidInputError {
msg: format!("line {line_num}: 'ast.insert' requires content"),
}));
};
if flags.positionals.len() > 2 {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: 'ast.insert' unexpected extra argument '{}'",
flags.positionals[2].as_str()
),
}));
}
op!(AstInsert {
path,
content,
inside: first_flag(&flags, "inside").map(str::to_string),
after: first_flag(&flags, "after").map(str::to_string),
before: first_flag(&flags, "before").map(str::to_string),
position: first_flag(&flags, "position").map(str::to_string),
lang: first_flag(&flags, "lang").map(str::to_string),
})
}
#[cfg(feature = "ast")]
fn parse_ast_wrap(
args: &[BatchToken],
line_num: usize,
cwd: Option<&Path>,
global: Option<&GlobalFlags>,
) -> anyhow::Result<Operation> {
let flags = peel_cli_flags(
args,
&["symbols", "lines", "preamble", "wrapper", "lang"],
&[],
line_num,
"ast.wrap",
)?;
if flags.positionals.is_empty() {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'ast.wrap' requires a path argument"),
}));
}
let path = peel_owned(&flags.positionals[0], &["path"]);
let wrapper = if let Some(w) = first_flag(&flags, "wrapper") {
resolve_at_payload(w, cwd, global, line_num, "wrapper")?
} else if flags.positionals.len() >= 2 {
resolve_content_token(
&flags.positionals[1],
&["wrapper"],
cwd,
global,
line_num,
"wrapper",
)?
} else {
return Err(anyhow::Error::new(crate::exit::InvalidInputError {
msg: format!("line {line_num}: 'ast.wrap' requires a wrapper"),
}));
};
if flags.positionals.len() > 2 {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: 'ast.wrap' unexpected extra argument '{}'",
flags.positionals[2].as_str()
),
}));
}
let mut symbols = Vec::new();
for raw in flag_values(&flags, "symbols") {
symbols.extend(split_csv_symbols(&raw));
}
let preamble = match first_flag(&flags, "preamble") {
Some(p) => Some(resolve_at_payload(p, cwd, global, line_num, "preamble")?),
None => None,
};
op!(AstWrap {
path,
symbols: if symbols.is_empty() {
None
} else {
Some(symbols)
},
lines: first_flag(&flags, "lines").map(str::to_string),
wrapper,
preamble,
lang: first_flag(&flags, "lang").map(str::to_string),
})
}
#[cfg(feature = "ast")]
fn parse_ast_imports(args: &[BatchToken], line_num: usize) -> anyhow::Result<Operation> {
let flags = peel_cli_flags(
args,
&["add", "remove", "lang"],
&["dedupe"],
line_num,
"ast.imports",
)?;
if flags.positionals.len() != 1 {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: 'ast.imports' requires a path argument, got {}",
flags.positionals.len()
),
}));
}
let add = flag_values(&flags, "add");
let remove = flag_values(&flags, "remove");
op!(AstImports {
path: peel_owned(&flags.positionals[0], &["path"]),
add: if add.is_empty() { None } else { Some(add) },
remove: if remove.is_empty() {
None
} else {
Some(remove)
},
dedupe: flags.switches.contains("dedupe"),
lang: first_flag(&flags, "lang").map(str::to_string),
})
}
#[cfg(feature = "ast")]
fn parse_ast_reorder(args: &[BatchToken], line_num: usize) -> anyhow::Result<Operation> {
let flags = peel_cli_flags(
args,
&["order", "inside", "lang"],
&[],
line_num,
"ast.reorder",
)?;
if flags.positionals.is_empty() {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'ast.reorder' requires a path argument"),
}));
}
let path = peel_owned(&flags.positionals[0], &["path"]);
let order_raw = if let Some(o) = first_flag(&flags, "order") {
o.to_string()
} else if flags.positionals.len() >= 2 {
peel_owned(&flags.positionals[1], &["order"])
} else {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'ast.reorder' requires an order argument"),
}));
};
if flags.positionals.len() > 2 {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: 'ast.reorder' unexpected extra argument '{}'",
flags.positionals[2].as_str()
),
}));
}
let order = crate::cmd::ast::parse_reorder_order(&order_raw)?;
op!(AstReorder {
path,
inside: first_flag(&flags, "inside").map(str::to_string),
order,
lang: first_flag(&flags, "lang").map(str::to_string),
})
}
#[cfg(feature = "ast")]
fn parse_ast_group(
args: &[BatchToken],
line_num: usize,
cwd: Option<&Path>,
global: Option<&GlobalFlags>,
) -> anyhow::Result<Operation> {
let flags = peel_cli_flags(
args,
&["module", "preamble", "position", "lang"],
&[],
line_num,
"ast.group",
)?;
if flags.positionals.len() < 2 {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: 'ast.group' requires path, module, and at least one symbol"
),
}));
}
let path = peel_owned(&flags.positionals[0], &["path"]);
let module = first_flag(&flags, "module")
.map(str::to_string)
.unwrap_or_else(|| peel_owned(&flags.positionals[1], &["module"]));
let symbol_start = if first_flag(&flags, "module").is_some() {
1
} else {
2
};
if flags.positionals.len() <= symbol_start {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'ast.group' requires at least one symbol"),
}));
}
let symbols = flags.positionals[symbol_start..]
.iter()
.map(|t| peel_owned(t, &["symbol", "symbols"]))
.collect::<Vec<_>>();
let preamble = match first_flag(&flags, "preamble") {
Some(p) => Some(resolve_at_payload(p, cwd, global, line_num, "preamble")?),
None => None,
};
op!(AstGroup {
path,
module,
symbols,
preamble,
position: first_flag(&flags, "position").map(str::to_string),
lang: first_flag(&flags, "lang").map(str::to_string),
})
}
#[cfg(feature = "ast")]
fn parse_ast_move(args: &[BatchToken], line_num: usize) -> anyhow::Result<Operation> {
let flags = peel_cli_flags(
args,
&["target", "position", "target-prepend", "lang"],
&[],
line_num,
"ast.move",
)?;
if flags.positionals.len() < 2 {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: 'ast.move' requires path, target, and at least one symbol"
),
}));
}
let path = peel_owned(&flags.positionals[0], &["path"]);
let target = first_flag(&flags, "target")
.map(str::to_string)
.unwrap_or_else(|| peel_owned(&flags.positionals[1], &["target"]));
let symbol_start = if first_flag(&flags, "target").is_some() {
1
} else {
2
};
if flags.positionals.len() <= symbol_start {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'ast.move' requires at least one symbol"),
}));
}
let symbols = flags.positionals[symbol_start..]
.iter()
.map(|t| peel_owned(t, &["symbol", "symbols"]))
.collect::<Vec<_>>();
op!(AstMove {
path,
target,
symbols,
position: first_flag(&flags, "position").map(str::to_string),
target_prepend: first_flag(&flags, "target-prepend").map(str::to_string),
lang: first_flag(&flags, "lang").map(str::to_string),
update_imports: false,
old_module_path: None,
new_module_path: None,
})
}
#[cfg(feature = "ast")]
fn parse_ast_extract_to_file(args: &[BatchToken], line_num: usize) -> anyhow::Result<Operation> {
let flags = peel_cli_flags(
args,
&[
"source",
"symbol",
"target",
"replacement",
"prepend",
"lang",
],
&["unwrap", "force"],
line_num,
"ast.extract_to_file",
)?;
let mut positionals = flags.positionals.iter();
let source = first_flag(&flags, "source")
.map(str::to_string)
.or_else(|| {
positionals
.next()
.map(|t| peel_owned(t, &["source", "path"]))
})
.ok_or_else(|| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'ast.extract_to_file' requires a source path"),
})
})?;
let symbol = first_flag(&flags, "symbol")
.map(str::to_string)
.or_else(|| positionals.next().map(|t| peel_owned(t, &["symbol"])))
.ok_or_else(|| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'ast.extract_to_file' requires a symbol"),
})
})?;
let target = first_flag(&flags, "target")
.map(str::to_string)
.or_else(|| positionals.next().map(|t| peel_owned(t, &["target"])))
.ok_or_else(|| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'ast.extract_to_file' requires a target path"),
})
})?;
if positionals.next().is_some() {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'ast.extract_to_file' unexpected extra argument"),
}));
}
op!(AstExtractToFile {
source,
symbol,
target,
replacement: first_flag(&flags, "replacement").map(str::to_string),
unwrap: if flags.switches.contains("unwrap") {
Some(true)
} else {
None
},
prepend: first_flag(&flags, "prepend").map(str::to_string),
force: flags.switches.contains("force"),
lang: first_flag(&flags, "lang").map(str::to_string),
update_imports: false,
old_module_path: None,
new_module_path: None,
})
}
#[cfg(feature = "ast")]
fn parse_ast_split(args: &[BatchToken], line_num: usize) -> anyhow::Result<Operation> {
let flags = peel_cli_flags(
args,
&[
"source",
"targets",
"keep-in-source",
"source-suffix",
"source-prefix",
"lang",
],
&["require-exhaustive"],
line_num,
"ast.split",
)?;
let mut positionals = flags.positionals.iter();
let source = first_flag(&flags, "source")
.map(str::to_string)
.or_else(|| {
positionals
.next()
.map(|t| peel_owned(t, &["source", "path"]))
})
.ok_or_else(|| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'ast.split' requires a source path"),
})
})?;
let targets_raw = first_flag(&flags, "targets")
.map(str::to_string)
.or_else(|| positionals.next().map(|t| peel_owned(t, &["targets"])))
.ok_or_else(|| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'ast.split' requires a targets JSON array"),
})
})?;
if positionals.next().is_some() {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'ast.split' unexpected extra argument"),
}));
}
let targets = crate::cmd::ast::parse_split_targets(&targets_raw)?;
let mut keep_in_source = Vec::new();
for raw in flag_values(&flags, "keep-in-source") {
keep_in_source.extend(split_csv_symbols(&raw));
}
op!(AstSplit {
source,
targets,
keep_in_source,
source_suffix: first_flag(&flags, "source-suffix").map(str::to_string),
source_prefix: first_flag(&flags, "source-prefix").map(str::to_string),
require_exhaustive: if flags.switches.contains("require-exhaustive") {
Some(true)
} else {
None
},
lang: first_flag(&flags, "lang").map(str::to_string),
})
}
fn parse_replace_line(
args: &[BatchToken],
line_num: usize,
cwd: Option<&Path>,
_global: Option<&GlobalFlags>,
) -> anyhow::Result<Operation> {
if args.len() < 3 {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: 'replace' requires at least 3 arguments (path old new), got {}",
args.len()
),
}));
}
let mut path = None;
let mut old = None;
let mut new_text = None;
let mut case_insensitive = false;
let mut if_exists = false;
let mut word_boundary = false;
let mut require_change = false;
let mut command_position = false;
let mut fuzzy = false;
let mut min_fuzzy_score = None;
let mut allow_absent_old = false;
let mut i = 0usize;
while i < args.len() {
let tok = args[i].as_str();
if let Some(flag) = batch_replace_cli_flag_confusion(tok) {
return Err(batch_replace_cli_shape_hint(line_num, flag));
}
let is_flag = matches!(
tok,
"--fuzzy"
| "--if-exists"
| "--require-change"
| "--command-position"
| "--word-boundary"
| "-w"
| "-i"
| "--case-insensitive"
| "--min-fuzzy-score"
| "--allow-absent-old"
) || tok.starts_with("--min-fuzzy-score=");
if is_flag {
match tok {
"--fuzzy" => fuzzy = true,
"--allow-absent-old" => allow_absent_old = true,
"--if-exists" => if_exists = true,
"--require-change" => require_change = true,
"--command-position" => command_position = true,
"--word-boundary" | "-w" => word_boundary = true,
"-i" | "--case-insensitive" => case_insensitive = true,
flag if flag == "--min-fuzzy-score" || flag.starts_with("--min-fuzzy-score=") => {
let raw = if let Some(v) = flag.strip_prefix("--min-fuzzy-score=") {
v.to_string()
} else {
i += 1;
args.get(i)
.map(|t| t.value.clone())
.ok_or_else(|| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: --min-fuzzy-score requires a value (0.0..=1.0)"
),
})
})?
};
let score: f64 = raw.parse().map_err(|_| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: invalid --min-fuzzy-score value '{raw}' (expected number)"
),
})
})?;
if !(0.0..=1.0).contains(&score) {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: --min-fuzzy-score must be in 0.0..=1.0 (got {score})"
),
}));
}
min_fuzzy_score = Some(score);
}
other => {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: unknown replace flag '{other}' \
(supported: --fuzzy, --allow-absent-old, --min-fuzzy-score, --word-boundary/-w, \
--command-position, --require-change, -i/--case-insensitive, --if-exists); \
advanced flags (regex, context, nth) need `tx` plan JSON"
),
}));
}
}
i += 1;
continue;
}
if tok.starts_with('-')
&& path.is_some()
&& old.is_some()
&& new_text.is_some()
&& tok != "-"
{
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: unknown replace flag '{tok}' \
(supported: --fuzzy, --allow-absent-old, --min-fuzzy-score, --word-boundary/-w, \
--command-position, --require-change, -i/--case-insensitive, --if-exists); \
advanced flags (regex, context, nth) need `tx` plan JSON"
),
}));
}
if path.is_none() {
path = Some(args[i].clone());
} else if old.is_none() {
old = Some(args[i].clone());
} else if new_text.is_none() {
new_text = Some(args[i].clone());
} else {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: unexpected argument '{}' after replace PATH OLD NEW. \
Batch is `replace path old new` (not CLI `replace OLD --new NEW path`). \
Use flags like --fuzzy, or `tx` for full replace options",
args[i]
),
}));
}
i += 1;
}
let path = path.ok_or_else(|| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'replace' requires a path argument"),
})
})?;
let old = old.ok_or_else(|| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'replace' requires an old pattern argument"),
})
})?;
let new_text = new_text.ok_or_else(|| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: 'replace' requires a new text argument"),
})
})?;
let path = peel_owned(&path, &["path"]);
let old = peel_owned(&old, &["old", "from"]);
let new_text = peel_owned(&new_text, &["new", "to"]);
if !path_is_file_under_cwd(&path, cwd) && path_is_file_under_cwd(&new_text, cwd) {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: batch replace order is PATH OLD NEW \
(first arg '{path}' is not a file; third arg '{new_text}' is a file). \
CLI is `replace OLD --new NEW path`; batch is `replace path old new`"
),
}));
}
if command_position
&& let Some(msg) = crate::ops::shell_token::command_position_combo_error(
crate::ops::shell_token::CommandPositionIncompat {
regex: false,
case_insensitive,
word_boundary,
whole_line: false,
multiline: false,
nth: false,
insert_before: false,
insert_after: false,
before_context: false,
after_context: false,
fuzzy,
},
)
{
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: {msg}"),
}));
}
Ok(Operation::Replace {
glob: None,
path: Some(path),
regex: false,
old,
new_text: Some(new_text),
nth: None,
insert_before: None,
insert_after: None,
case_insensitive,
multiline: false,
if_exists,
whole_line: false,
range: None,
word_boundary,
before_context: None,
after_context: None,
unique: false,
require_change,
command_position,
fuzzy,
min_fuzzy_score,
allow_absent_old,
})
}
fn parse_json_value(s: &str) -> anyhow::Result<serde_json::Value> {
Ok(crate::ops::doc::parse_value(s))
}
fn peel_named_kv_prefix<'a>(tok: &'a str, keys: &[&str]) -> Option<&'a str> {
for key in keys {
if let Some(rest) = tok.strip_prefix(key)
&& let Some(rest) = rest.strip_prefix('=')
{
return Some(rest);
}
}
None
}
fn peel_owned(tok: &BatchToken, keys: &[&str]) -> String {
if tok.quoted {
return tok.value.clone();
}
peel_named_kv_prefix(&tok.value, keys)
.map(str::to_string)
.unwrap_or_else(|| tok.value.clone())
}
fn join_content_tokens(parts: &[BatchToken]) -> String {
if parts.is_empty() {
return String::new();
}
if parts.len() == 1 {
if !parts[0].quoted
&& let Some(rest) = peel_named_kv_prefix(&parts[0].value, &["content", "body"])
{
return expand_content_escapes(rest);
}
return expand_content_escapes(&parts[0].value);
}
if !parts[0].quoted
&& let Some(rest) = peel_named_kv_prefix(&parts[0].value, &["content", "body"])
{
let mut joined = rest.to_string();
for p in &parts[1..] {
joined.push(' ');
joined.push_str(&p.value);
}
return expand_content_escapes(&joined);
}
let joined = parts
.iter()
.map(|t| t.value.as_str())
.collect::<Vec<_>>()
.join(" ");
expand_content_escapes(&joined)
}
fn path_and_joined_content(
op: &str,
args: &[BatchToken],
line_num: usize,
) -> anyhow::Result<(String, String)> {
if args.is_empty() {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: '{op}' requires a path argument, got 0"),
}));
}
let path = peel_owned(&args[0], &["path"]);
Ok((path, join_content_tokens(&args[1..])))
}
fn path_and_joined_content_create(
op: &str,
args: &[BatchToken],
line_num: usize,
) -> anyhow::Result<(String, String, bool)> {
if args.is_empty() {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("line {line_num}: '{op}' requires a path argument, got 0"),
}));
}
let path = peel_owned(&args[0], &["path"]);
let mut force = false;
let mut content_parts: Vec<BatchToken> = Vec::new();
for tok in args.iter().skip(1) {
if tok.as_str() == "--force" {
force = true;
continue;
}
if tok.starts_with("--") {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!(
"line {line_num}: '{op}' unknown flag {}; only --force is supported before content",
tok.as_str()
),
}));
}
content_parts.push(tok.clone());
}
Ok((path, join_content_tokens(&content_parts), force))
}
fn expand_content_escapes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct BatchToken {
value: String,
quoted: bool,
}
impl BatchToken {
fn as_str(&self) -> &str {
&self.value
}
}
impl std::ops::Deref for BatchToken {
type Target = str;
fn deref(&self) -> &str {
&self.value
}
}
impl std::fmt::Display for BatchToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.value)
}
}
pub fn tokenize(line: &str) -> anyhow::Result<Vec<String>> {
Ok(tokenize_tokens(line)?
.into_iter()
.map(|t| t.value)
.collect())
}
fn tokenize_tokens(line: &str) -> anyhow::Result<Vec<BatchToken>> {
let mut tokens = Vec::new();
let mut chars = line.chars().peekable();
let mut current = String::new();
let mut in_token = false;
let mut quoted = false;
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
if in_token {
tokens.push(BatchToken {
value: std::mem::take(&mut current),
quoted,
});
in_token = false;
quoted = false;
}
chars.next();
} else if ch == '"' {
if !in_token {
quoted = true;
}
in_token = true;
chars.next(); loop {
match chars.next() {
Some('\\') => match chars.next() {
Some('"') => current.push('"'),
Some('\\') => current.push('\\'),
Some(other) => {
current.push('\\');
current.push(other);
}
None => {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: "unexpected end of line after backslash".into(),
}));
}
},
Some('"') => break,
Some(c) => current.push(c),
None => {
return Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: "unterminated double quote".into(),
}));
}
}
}
} else if !in_token && (ch == '{' || ch == '[') {
in_token = true;
let open = ch;
let close = if ch == '{' { '}' } else { ']' };
let mut depth = 0i32;
let mut in_string = false;
let mut escape = false;
loop {
let c = chars.next().ok_or_else(|| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("unterminated JSON value starting with '{open}'"),
})
})?;
current.push(c);
if in_string {
if escape {
escape = false;
} else if c == '\\' {
escape = true;
} else if c == '"' {
in_string = false;
}
} else if c == '"' {
in_string = true;
} else if c == open {
depth += 1;
} else if c == close {
depth -= 1;
if depth == 0 {
break;
}
}
}
} else {
in_token = true;
current.push(ch);
chars.next();
}
}
if in_token {
tokens.push(BatchToken {
value: current,
quoted,
});
}
Ok(tokens)
}
pub fn run(args: BatchArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
crate::verbose!("batch: input={}", args.input);
let input = if args.input == "-" {
std::io::read_to_string(std::io::stdin())?
} else {
let input_path = global.resolve_user_path(&args.input)?;
let display = input_path.display().to_string();
crate::files::load_text_strict(&input_path, &display).map_err(|e| {
let msg = crate::exit::agent_error_message(&e);
if crate::exit::is_io_not_found(&e) {
std::io::Error::new(std::io::ErrorKind::NotFound, msg).into()
} else if crate::exit::is_load_text_strict_fail(&e) {
e
} else {
anyhow::Error::new(crate::exit::InvalidInputError { msg })
}
})?
};
let input = crate::ops::file::strip_utf8_bom(&input);
let cwd = global.resolve_cwd()?;
let mut operations = Vec::new();
for (i, line) in input.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
match parse_line_at(trimmed, i + 1, Some(&cwd), Some(global)) {
Ok(op) => operations.push(op),
Err(e) => {
if crate::exit::is_io_not_found(&e) {
global.emit_error_json_kind(Some("not_found"), &e.to_string())?;
return Ok(exit::FAILURE);
}
if crate::exit::is_invalid_input(&e) {
global.emit_error_json_kind(Some("invalid_input"), &e.to_string())?;
return Ok(exit::FAILURE);
}
global.emit_error_json_kind(Some("parse_error"), &e.to_string())?;
return Ok(exit::PARSE_ERROR);
}
}
}
crate::verbose!("batch: parsed {} operations from input", operations.len());
if operations.is_empty() {
if !global.emit_json(&serde_json::json!({
"ok": true,
"status": "success",
"applied": false,
"files_changed": 0,
"files_created": 0,
"files_deleted": 0,
"changes": []
}))? && !global.quiet
{
eprintln!("batch: no operations found in input");
}
return Ok(exit::SUCCESS);
}
if operations.len() > MAX_BATCH_OPERATIONS {
let msg = format!(
"batch: too many operations ({}, max {MAX_BATCH_OPERATIONS})",
operations.len()
);
global.emit_error_json_kind(Some("invalid_input"), &msg)?;
return Ok(exit::FAILURE);
}
let plan = Plan {
version: crate::plan::SCHEMA_VERSION,
cwd: None,
write_policy: None,
strict: None,
operations,
format: None,
validate: None,
verify: None,
for_each: None,
agent_preset: false,
expected_sha256: None,
};
let _ = args.write;
crate::cmd::tx::run_parsed_plan(plan, false, &[], global)
}
#[path = "batch_tests.rs"]
#[cfg(test)]
mod tests;