Skip to main content

patchloom/plan/
mod.rs

1//! Transaction plan format parsing.
2
3use serde::{Deserialize, Serialize};
4
5/// Current plan schema version.
6pub const SCHEMA_VERSION: u32 = 1;
7
8fn default_strict_true() -> bool {
9    true
10}
11
12fn default_version() -> u32 {
13    1
14}
15
16/// Resolve effective strict mode: `--no-strict` > plan field > config > default true.
17pub fn effective_strict(
18    plan_strict: Option<bool>,
19    config_strict: Option<bool>,
20    no_strict: bool,
21) -> bool {
22    if no_strict {
23        false
24    } else {
25        plan_strict
26            .or(config_strict)
27            .unwrap_or_else(default_strict_true)
28    }
29}
30
31/// A transaction plan containing multiple operations to execute atomically.
32#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
33pub struct Plan {
34    /// Schema version. Defaults to 1 when omitted.
35    #[serde(default = "default_version")]
36    pub version: u32,
37    /// Optional re-root for relative operation paths and lifecycle steps.
38    ///
39    /// Relative values resolve from the invocation / MCP server workspace root
40    /// (not from the plan file location). Example: `"fixtures/complex"` with
41    /// op path `"config.json"` targets `fixtures/complex/config.json`.
42    ///
43    /// On MCP, use a **relative** path that stays inside the server workspace;
44    /// absolute path strings and `../` escapes are rejected. Do not combine
45    /// with `for_each` on any surface (glob expansion uses the invocation cwd;
46    /// combining with `plan.cwd` double-prefixes `{path}`). Use
47    /// workspace-relative `{path}` templates without `cwd` instead.
48    /// CLI and library callers may use absolute paths when PathGuard policy
49    /// allows them (still without `for_each`).
50    pub cwd: Option<String>,
51    pub write_policy: Option<crate::write::WritePolicyOverride>,
52    /// When omitted from the plan, defaults to strict mode at execution time.
53    #[serde(default)]
54    pub strict: Option<bool>,
55    /// Operations to run. Accepts alias `ops` (common agent shorthand).
56    #[serde(alias = "ops")]
57    pub operations: Vec<Operation>,
58    pub format: Option<Vec<FormatStep>>,
59    pub validate: Option<Vec<ValidationStep>>,
60    /// Pre/post-operation symbol verification checks.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub verify: Option<Vec<VerifyCheck>>,
63    /// Glob-driven batch: expand operations once per matching file.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    #[serde(default)]
66    pub for_each: Option<ForEach>,
67}
68
69impl Plan {
70    /// Returns `true` if the plan has any format or validate steps that could
71    /// modify files outside the transaction scope.
72    pub fn has_lifecycle_steps(&self) -> bool {
73        self.format.as_ref().is_some_and(|v| !v.is_empty())
74            || self.validate.as_ref().is_some_and(|v| !v.is_empty())
75    }
76}
77
78/// Glob-driven batch expansion: apply the same set of operations to every
79/// file matching a glob pattern, with template variable substitution.
80#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
81pub struct ForEach {
82    /// Glob pattern to expand (e.g. `src/**/*.rs`).
83    pub glob: String,
84    /// Glob patterns to exclude from the matched set.
85    #[serde(default)]
86    pub exclude: Vec<String>,
87    /// Optional filter expression (e.g. `has_symbol(tests)`).
88    #[serde(default)]
89    pub filter: Option<String>,
90}
91
92/// A single verification check parsed from `--verify` or plan `verify` field.
93#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
94#[serde(untagged)]
95pub enum VerifyCheck {
96    /// `{"kind": "function", "attr": "test"}` or `{"kind": "function"}`
97    SymbolCount {
98        kind: String,
99        #[serde(default)]
100        attr: Option<String>,
101    },
102    /// `{"check": "unique_names"}` or `{"check": "no_orphans"}`
103    Named { check: String },
104}
105
106impl VerifyCheck {
107    /// Parse a CLI `--verify` value like `kind=function,attr=test` or `unique_names`.
108    #[cfg(feature = "cli")]
109    pub fn parse(s: &str) -> anyhow::Result<Self> {
110        if s == "unique_names" || s == "no_orphans" {
111            return Ok(VerifyCheck::Named {
112                check: s.to_string(),
113            });
114        }
115        let mut kind = None;
116        let mut attr = None;
117        for part in s.split(',') {
118            let part = part.trim();
119            if let Some((k, v)) = part.split_once('=') {
120                match k.trim() {
121                    "kind" => kind = Some(v.trim().to_string()),
122                    "attr" => attr = Some(v.trim().to_string()),
123                    other => {
124                        return Err(anyhow::Error::new(crate::exit::InvalidInputError {
125                            msg: format!("unknown verify key: {other}"),
126                        }));
127                    }
128                }
129            } else {
130                // Bare word like "function" treated as kind
131                kind = Some(part.to_string());
132            }
133        }
134        if let Some(kind) = kind {
135            Ok(VerifyCheck::SymbolCount { kind, attr })
136        } else {
137            Err(anyhow::Error::new(crate::exit::InvalidInputError {
138                msg: "verify spec must contain 'kind=<type>' or a named check (unique_names, no_orphans)".into(),
139            }))
140        }
141    }
142}
143
144/// A format step to run after applying operations but before validation.
145#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
146pub struct FormatStep {
147    #[serde(alias = "command")]
148    pub cmd: String,
149    /// Timeout in seconds (default: 60).
150    pub timeout: Option<u64>,
151}
152
153/// Iterate plan `format` then `validate` command strings (library host preflight).
154///
155/// MCP strips these steps (#1142). CLI without a PathGuard still runs them as
156/// raw shell. Hosts that pass a guard, or that call
157/// [`refuse_lifecycle_shell_metas`] first, should walk this iterator.
158pub fn lifecycle_cmds(plan: &Plan) -> impl Iterator<Item = &str> {
159    let format = plan.format.iter().flatten().map(|s| s.cmd.as_str());
160    let validate = plan.validate.iter().flatten().map(|s| s.cmd.as_str());
161    format.chain(validate)
162}
163
164/// Shell metacharacters that can read or write paths PathGuard never sees.
165/// Substring scan (no POSIX parser). Quoted metas still match.
166const LIFECYCLE_SHELL_METAS: &[char] = &['|', ';', '&', '>', '<', '`', '$', '\n', '\r'];
167
168/// Refuse a format/validate command that needs a shell for redirects,
169/// pipelines, or substitutions (#2168).
170///
171/// `true`, `cargo fmt`, and `rustfmt` (no metas) succeed. Fail-closed kind is
172/// [`crate::fallback::EditErrorKind::InvalidInput`]. `execute_plan` with a
173/// PathGuard maps the same cmds to `GuardRejected` before commit.
174pub fn refuse_lifecycle_shell_metas(cmd: &str) -> anyhow::Result<()> {
175    if let Some(ch) = cmd.chars().find(|c| LIFECYCLE_SHELL_METAS.contains(c)) {
176        return Err(crate::fallback::EditError::new(
177            crate::fallback::EditErrorKind::InvalidInput,
178            format!(
179                "lifecycle command contains shell metacharacter {ch:?}; \
180                 redirects, pipelines, and substitutions are refused. \
181                 Use a single command such as `true` or `cargo fmt`"
182            ),
183        )
184        .into());
185    }
186    Ok(())
187}
188
189/// When a PathGuard is present, refuse format/validate cmds that need a shell
190/// for redirects, pipelines, or substitutions (#2168).
191pub(crate) fn refuse_lifecycle_if_guarded(
192    plan: &Plan,
193    guard: Option<&crate::containment::PathGuard>,
194) -> anyhow::Result<()> {
195    if guard.is_none() {
196        return Ok(());
197    }
198    for cmd in lifecycle_cmds(plan) {
199        if let Err(e) = refuse_lifecycle_shell_metas(cmd) {
200            return Err(crate::fallback::EditError::new(
201                crate::fallback::EditErrorKind::GuardRejected,
202                format!("plan format/validate command refused under PathGuard: {cmd:?} ({e})"),
203            )
204            .into());
205        }
206    }
207    Ok(())
208}
209
210mod operation;
211mod unknown_keys;
212pub use operation::Operation;
213#[cfg(feature = "ast")]
214pub use operation::SplitTargetSpec;
215use unknown_keys::{map_yaml_plan_parse_error, note_unknown_keys_from_json_value};
216pub use unknown_keys::{take_unknown_plan_key_warnings, unknown_plan_key_warnings};
217
218/// Convert a doc-family `Operation` into a `(path, DocMutation)` pair.
219///
220/// Returns `None` for non-doc operations. This is the single source of truth
221/// for mapping `Operation::Doc*` variants to `DocMutation`, used by both the
222/// tx engine (`tx/execute/`) and any future callers.
223pub(crate) fn op_to_doc_mutation(op: &Operation) -> Option<(&str, crate::ops::doc::DocMutation)> {
224    use crate::ops::doc::DocMutation;
225    match op {
226        Operation::DocSet {
227            path,
228            selector,
229            value,
230            ..
231        } => Some((
232            path,
233            DocMutation::Set {
234                selector: selector.clone(),
235                value: value.clone(),
236            },
237        )),
238        Operation::DocDelete { path, selector } => Some((
239            path,
240            DocMutation::Delete {
241                selector: selector.clone(),
242            },
243        )),
244        Operation::DocMerge {
245            path,
246            selector,
247            value,
248        } => Some((
249            path,
250            DocMutation::Merge {
251                selector: selector.clone(),
252                value: value.clone(),
253            },
254        )),
255        Operation::DocAppend {
256            path,
257            selector,
258            value,
259        } => Some((
260            path,
261            DocMutation::Append {
262                selector: selector.clone(),
263                value: value.clone(),
264            },
265        )),
266        Operation::DocPrepend {
267            path,
268            selector,
269            value,
270        } => Some((
271            path,
272            DocMutation::Prepend {
273                selector: selector.clone(),
274                value: value.clone(),
275            },
276        )),
277        Operation::DocUpdate {
278            path,
279            selector,
280            value,
281        } => Some((
282            path,
283            DocMutation::Update {
284                selector: selector.clone(),
285                value: value.clone(),
286            },
287        )),
288        Operation::DocMove { path, from, to } => Some((
289            path,
290            DocMutation::Move {
291                from: from.clone(),
292                to: to.clone(),
293            },
294        )),
295        Operation::DocEnsure {
296            path,
297            selector,
298            value,
299        } => Some((
300            path,
301            DocMutation::Ensure {
302                selector: selector.clone(),
303                value: value.clone(),
304            },
305        )),
306        Operation::DocDeleteWhere {
307            path,
308            selector,
309            predicate,
310        } => Some((
311            path,
312            DocMutation::DeleteWhere {
313                selector: selector.clone(),
314                predicate: predicate.clone(),
315            },
316        )),
317        _ => None,
318    }
319}
320
321/// Returns the file paths (as `&str`) that are declared by the operation
322/// and should be subject to PathGuard / containment validation.
323///
324/// This eliminates duplication between:
325/// - upfront checks in `execute_plan` (library use, #755)
326/// - test validation logic in MCP
327///
328/// - `Replace`: includes `path` (if present) and `glob` pattern (if present).
329/// - Cross-file ops (`FileRename`, `MdMoveSection`): includes both source
330///   and destination file paths.
331/// - `PatchApply`: parses the embedded diff via `parse_patch()` and returns
332///   the file paths from `---`/`+++` headers. This ensures the upfront
333///   PathGuard check in `execute_plan` catches out-of-boundary patches
334///   (#1363). Returns empty on parse failure (error deferred to apply time).
335/// - All other ops: their primary `path` (or equivalent).
336/// - AST variants are included only when the `ast` feature is enabled.
337pub(crate) fn declared_paths(op: &Operation) -> Vec<String> {
338    match op {
339        Operation::Replace { path, glob, .. } => {
340            let mut p = Vec::new();
341            if let Some(s) = path {
342                p.push(s.clone());
343            }
344            if let Some(s) = glob {
345                p.push(s.clone());
346            }
347            p
348        }
349        Operation::FileRename { from, to, .. } => vec![from.clone(), to.clone()],
350        Operation::MdMoveSection { path, to, .. } => {
351            let mut p = vec![path.clone()];
352            if let Some(t) = to {
353                p.push(t.clone());
354            }
355            p
356        }
357        Operation::PatchApply { diff, .. } => {
358            if crate::ops::begin_patch::looks_like_begin_patch(diff) {
359                return crate::ops::begin_patch::begin_patch_declared_paths(diff)
360                    .unwrap_or_default();
361            }
362            if crate::ops::search_replace::looks_like_search_replace(diff) {
363                return crate::ops::search_replace::search_replace_declared_paths(diff)
364                    .unwrap_or_default();
365            }
366            // Parse the diff to extract write targets and git rename sources.
367            // rename_from must be guarded too (pure rename of ../outside must fail closed).
368            // If parsing fails, return empty (the error will surface at apply time).
369            match crate::ops::patch::parse_patch(diff) {
370                Ok(files) => files.iter().flat_map(|pf| pf.declared_paths()).collect(),
371                Err(_) => vec![],
372            }
373        }
374        // Single-path operations (file, doc, md, read, search, tidy, lint, etc.)
375        Operation::DocSet { path, .. }
376        | Operation::DocDelete { path, .. }
377        | Operation::DocMerge { path, .. }
378        | Operation::DocAppend { path, .. }
379        | Operation::DocPrepend { path, .. }
380        | Operation::DocUpdate { path, .. }
381        | Operation::DocMove { path, .. }
382        | Operation::DocEnsure { path, .. }
383        | Operation::DocDeleteWhere { path, .. }
384        | Operation::MdReplaceSection { path, .. }
385        | Operation::MdInsertAfterHeading { path, .. }
386        | Operation::MdInsertAfterSection { path, .. }
387        | Operation::MdInsertBeforeHeading { path, .. }
388        | Operation::MdUpsertBullet { path, .. }
389        | Operation::MdTableAppend { path, .. }
390        | Operation::MdDedupeHeadings { path, .. }
391        | Operation::TidyFix { path, .. }
392        | Operation::FileAppend { path, .. }
393        | Operation::FilePrepend { path, .. }
394        | Operation::FileCreate { path, .. }
395        | Operation::FileDelete { path, .. }
396        | Operation::Read { path, .. }
397        | Operation::Search { path, .. }
398        | Operation::MdLintAgents { path, .. }
399        | Operation::ApplyFragment { path, .. } => vec![path.clone()],
400        #[cfg(feature = "ast")]
401        Operation::AstRename { path, .. }
402        | Operation::AstReplace { path, .. }
403        | Operation::AstReplaceSymbol { path, .. }
404        | Operation::AstDeleteSymbol { path, .. }
405        | Operation::AstRewriteSignature { path, .. }
406        | Operation::AstInsert { path, .. }
407        | Operation::AstWrap { path, .. }
408        | Operation::AstImports { path, .. }
409        | Operation::AstReorder { path, .. }
410        | Operation::AstGroup { path, .. } => {
411            vec![path.clone()]
412        }
413        #[cfg(feature = "ast")]
414        Operation::AstMove { path, target, .. } => vec![path.clone(), target.clone()],
415        #[cfg(feature = "ast")]
416        Operation::AstExtractToFile { source, target, .. } => {
417            vec![source.clone(), target.clone()]
418        }
419        #[cfg(feature = "ast")]
420        Operation::AstSplit {
421            source, targets, ..
422        } => {
423            let mut p = vec![source.clone()];
424            for t in targets {
425                p.push(t.path.clone());
426            }
427            p
428        }
429    }
430}
431
432/// A validation step to run after applying operations.
433#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
434pub struct ValidationStep {
435    #[serde(alias = "command")]
436    pub cmd: String,
437    pub required: Option<bool>,
438    /// Timeout in seconds (default: 60).
439    pub timeout: Option<u64>,
440}
441
442/// Parse a plan from a JSON string.
443pub fn parse_plan(input: &str) -> anyhow::Result<Plan> {
444    let input = crate::ops::file::strip_utf8_bom(input);
445    let value: serde_json::Value = serde_json::from_str(input)?;
446    let plan: Plan = serde_json::from_value(value.clone())?;
447    note_unknown_keys_from_json_value(&value);
448    Ok(plan)
449}
450
451/// Parse a plan from a YAML string.
452pub fn parse_plan_yaml(input: &str) -> anyhow::Result<Plan> {
453    let input = crate::ops::file::strip_utf8_bom(input);
454    let plan: Plan =
455        serde_yaml_ng::from_str(input).map_err(|err| map_yaml_plan_parse_error(input, err))?;
456    if let Ok(value) = serde_yaml_ng::from_str::<serde_json::Value>(input) {
457        note_unknown_keys_from_json_value(&value);
458    }
459    Ok(plan)
460}
461
462/// Parse a plan from a TOML string.
463pub fn parse_plan_toml(input: &str) -> anyhow::Result<Plan> {
464    let input = crate::ops::file::strip_utf8_bom(input);
465    let plan: Plan = toml_edit::de::from_str(input)?;
466    if let Ok(value) = toml_edit::de::from_str::<serde_json::Value>(input) {
467        note_unknown_keys_from_json_value(&value);
468    }
469    Ok(plan)
470}
471
472/// Detect plan format from a file path extension and parse accordingly.
473pub fn parse_plan_auto(
474    input: &str,
475    path: Option<&str>,
476    format_hint: Option<&str>,
477) -> anyhow::Result<Plan> {
478    let fmt = format_hint.or_else(|| {
479        path.and_then(|p| {
480            crate::ops::doc::detect_format(p).ok().map(|f| match f {
481                crate::ops::doc::FileFormat::Yaml => "yaml",
482                crate::ops::doc::FileFormat::Toml => "toml",
483                crate::ops::doc::FileFormat::Json => "json",
484                crate::ops::doc::FileFormat::Env
485                | crate::ops::doc::FileFormat::Ini
486                | crate::ops::doc::FileFormat::Properties => "json",
487            })
488        })
489    });
490    match fmt {
491        Some("yaml" | "yml") => parse_plan_yaml(input),
492        Some("toml") => parse_plan_toml(input),
493        _ => parse_plan(input),
494    }
495}
496
497// ---------------------------------------------------------------------------
498// for_each expansion
499// ---------------------------------------------------------------------------
500
501/// Escape a string for safe embedding inside a JSON string literal.
502///
503/// The template substitution operates on the serialized JSON, replacing
504/// `{path}` etc. inside already-quoted `"..."` values. If the substituted
505/// text contains JSON-special characters (backslash, quote, newline, etc.),
506/// the resulting JSON would be malformed. This function applies the same
507/// escaping that `serde_json::to_string` would use for string content.
508#[cfg(feature = "files")]
509fn json_escape(s: &str) -> String {
510    // Use serde_json to produce `"escaped"`, then strip the surrounding quotes.
511    let quoted = serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""));
512    quoted[1..quoted.len() - 1].to_string()
513}
514
515/// Single-pass template substitution. Scans `template` left-to-right,
516/// replacing each known placeholder from the original text. This prevents
517/// cross-contamination where the replacement value of one placeholder
518/// contains another placeholder name as a literal substring.
519#[cfg(feature = "files")]
520fn substitute_single_pass(template: &str, vars: &[(&str, String)]) -> String {
521    let mut result = String::with_capacity(template.len());
522    let mut i = 0;
523    let bytes = template.as_bytes();
524    while i < bytes.len() {
525        if bytes[i] == b'{' {
526            let mut matched = false;
527            for (placeholder, value) in vars {
528                if template[i..].starts_with(placeholder) {
529                    result.push_str(value);
530                    i += placeholder.len();
531                    matched = true;
532                    break;
533                }
534            }
535            if !matched {
536                result.push('{');
537                i += 1;
538            }
539        } else {
540            // Advance by one full UTF-8 character, not one byte.
541            // `bytes[i] as char` would interpret each byte of a multi-byte
542            // sequence as a Latin-1 code point, corrupting non-ASCII text.
543            let ch = template[i..]
544                .chars()
545                .next()
546                .expect("i < len guarantees non-empty slice");
547            result.push(ch);
548            i += ch.len_utf8();
549        }
550    }
551    result
552}
553
554/// Expand a plan's `for_each` block: match files via glob, apply exclude/filter,
555/// substitute template variables into each operation, and flatten the result into
556/// `plan.operations`. After this call, `plan.for_each` is `None`.
557///
558/// Template variables: `{path}`, `{item}` (alias of `{path}`), `{dir}`,
559/// `{stem}`, `{ext}`, `{name}`.
560///
561/// Doubled braces (`{{` / `}}`) are treated as escape sequences and produce
562/// literal `{` / `}` in the output. For example, `{{path}}` becomes the
563/// literal string `{path}` rather than being substituted with the file path.
564#[cfg(feature = "files")]
565pub fn expand_for_each(plan: &mut Plan, cwd: &std::path::Path) -> anyhow::Result<()> {
566    let fe = match plan.for_each.take() {
567        Some(fe) => fe,
568        None => return Ok(()),
569    };
570
571    // for_each globs against the invocation cwd; plan.cwd re-roots op paths
572    // after expansion, which double-prefixes workspace-relative `{path}` values
573    // (CLI/library parity with MCP which already rejected this combo).
574    if plan.cwd.as_ref().is_some_and(|c| !c.trim().is_empty()) {
575        return Err(anyhow::Error::new(crate::exit::InvalidInputError {
576            msg: "plan.cwd cannot be combined with for_each; \
577                 omit cwd and use workspace-relative paths in for_each templates \
578                 (e.g. path \"{path}\"), or omit for_each and set cwd for a nested re-root"
579                .into(),
580        }));
581    }
582
583    // 1. Collect matching files (exclude prefixes pruned while walking).
584    let glob_set = match crate::files::build_glob_matcher(std::slice::from_ref(&fe.glob)) {
585        Ok(Some(set)) => set,
586        Ok(None) => {
587            return Err(anyhow::Error::new(crate::exit::InvalidInputError {
588                msg: "for_each: invalid glob pattern".into(),
589            }));
590        }
591        Err(e) => {
592            return Err(anyhow::Error::new(crate::exit::InvalidInputError {
593                msg: format!("for_each: invalid glob pattern: {e}"),
594            }));
595        }
596    };
597
598    if !fe.exclude.is_empty() {
599        crate::files::build_glob_matcher(&fe.exclude).map_err(|e| {
600            anyhow::Error::new(crate::exit::InvalidInputError {
601                msg: format!("for_each: invalid exclude glob: {e}"),
602            })
603        })?;
604    }
605
606    // Walk with exclude prefixes pruned (vendor/**); include glob still filters.
607    let all_files = crate::files::collect_file_paths_with_ignores(cwd, &[], &fe.exclude, false)?;
608    let mut matched: Vec<std::path::PathBuf> = all_files
609        .into_iter()
610        .filter(|p| {
611            let rel = p.strip_prefix(cwd).unwrap_or(p);
612            glob_set.is_match(rel)
613        })
614        .collect();
615    matched.sort();
616
617    // 2. Apply filter (currently supports `has_symbol(NAME)`).
618    if let Some(ref filter) = fe.filter {
619        let filter = filter.trim();
620        let Some(sym_name) = filter
621            .strip_prefix("has_symbol(")
622            .and_then(|s| s.strip_suffix(')'))
623        else {
624            return Err(anyhow::Error::new(crate::exit::InvalidInputError {
625                msg: format!("for_each: unsupported filter expression: {filter}"),
626            }));
627        };
628        let sym_name = sym_name.trim();
629        #[cfg(feature = "ast")]
630        {
631            // Nested children (impl methods, mod items), not top-level only.
632            matched = crate::ast::symbols::keep_files_with_symbol(matched, sym_name)?;
633        }
634        #[cfg(not(feature = "ast"))]
635        {
636            let _ = sym_name;
637            return Err(anyhow::Error::new(crate::exit::InvalidInputError {
638                msg: "for_each filter `has_symbol(...)` requires the `ast` feature".into(),
639            }));
640        }
641    }
642
643    if matched.is_empty() {
644        // Fail closed: typo globs / wrong cwd must not look like a successful
645        // empty apply. Agents branch on exit 0 as "batch done".
646        return Err(anyhow::Error::new(crate::exit::NoMatchError {
647            msg: "for_each matched zero files (check glob, cwd, exclude, and filter)".into(),
648        }));
649    }
650
651    // 4. Serialize template operations once, then substitute per file.
652    let template_ops_json = serde_json::to_string(&plan.operations)?;
653
654    // Protect escaped doubles `{{` / `}}` so they become literal braces
655    // in the output rather than being interpreted as template variables.
656    // Sentinel chars (\x00) are safe because they cannot appear in valid JSON.
657    // Hoisted outside the loop since template_ops_json is invariant.
658    let protected = template_ops_json
659        .replace("{{", "\x00LBRACE\x00")
660        .replace("}}", "\x00RBRACE\x00");
661
662    // Multi-match without any file placeholder multiplies fixed-path ops
663    // (e.g. append the same CHANGELOG.md once per .rs file). Fail closed when
664    // more than one file matches and the template never references a match var.
665    if matched.len() > 1 && !template_uses_match_var(&protected) {
666        return Err(anyhow::Error::new(crate::exit::InvalidInputError {
667            msg: format!(
668                "for_each matched {} files but no operation uses a file template \
669                 ({{path}}, {{item}}, {{dir}}, {{stem}}, {{ext}}, or {{name}}); \
670                 fixed-path ops would run once per match. Add a path template or \
671                 narrow the glob to a single file",
672                matched.len()
673            ),
674        }));
675    }
676
677    let mut expanded = Vec::with_capacity(matched.len() * plan.operations.len());
678    for file_path in &matched {
679        let rel = file_path
680            .strip_prefix(cwd)
681            .unwrap_or(file_path)
682            .to_string_lossy();
683        let rel_str = rel.replace('\\', "/");
684
685        let dir = std::path::Path::new(&rel_str)
686            .parent()
687            .map(|p| p.to_string_lossy().into_owned())
688            .unwrap_or_default();
689        let name = std::path::Path::new(&rel_str)
690            .file_name()
691            .map(|n| n.to_string_lossy().into_owned())
692            .unwrap_or_default();
693        let stem = std::path::Path::new(&rel_str)
694            .file_stem()
695            .map(|s| s.to_string_lossy().into_owned())
696            .unwrap_or_default();
697        let ext = std::path::Path::new(&rel_str)
698            .extension()
699            .map(|e| e.to_string_lossy().into_owned())
700            .unwrap_or_default();
701
702        // JSON-escape all substitution values so file paths containing
703        // quotes, backslashes, or control characters don't produce invalid JSON.
704        // Use single-pass substitution to prevent cross-contamination: if a
705        // file path contains a literal "{name}", sequential .replace() would
706        // double-substitute it. Single-pass scans the template once and
707        // replaces each placeholder from the original template text.
708        // `{item}` is a path alias agents often invent (for_each "item" loops).
709        let vars: &[(&str, String)] = &[
710            ("{path}", json_escape(&rel_str)),
711            ("{item}", json_escape(&rel_str)),
712            ("{dir}", json_escape(&dir)),
713            ("{stem}", json_escape(&stem)),
714            ("{ext}", json_escape(&ext)),
715            ("{name}", json_escape(&name)),
716        ];
717        let substituted = substitute_single_pass(&protected, vars);
718
719        // Restore sentinels to literal single braces.
720        let substituted = substituted
721            .replace("\x00LBRACE\x00", "{")
722            .replace("\x00RBRACE\x00", "}");
723
724        // Fail closed on path fields that are still a lone `{placeholder}`
725        // (unknown name, or escaped `{{path}}` used by mistake). Without this,
726        // agents get opaque not_found for a file literally named `{item}`.
727        if let Some(bad) = unsubstituted_path_template(&substituted) {
728            return Err(anyhow::Error::new(crate::exit::InvalidInputError {
729                msg: format!(
730                    "for_each: unsubstituted template '{bad}' in a path field \
731                     (valid: {{path}}, {{item}}, {{dir}}, {{stem}}, {{ext}}, {{name}}; \
732                     double braces like {{{{path}}}} produce a literal {{path}})"
733                ),
734            }));
735        }
736
737        let file_ops: Vec<Operation> = serde_json::from_str(&substituted).map_err(|e| {
738            anyhow::Error::new(crate::exit::InvalidInputError {
739                msg: format!("for_each: template expansion failed: {e}"),
740            })
741        })?;
742        expanded.extend(file_ops);
743    }
744
745    plan.operations = expanded;
746    Ok(())
747}
748
749/// True when the protected for_each template JSON still contains a match
750/// variable (not escaped as `{{…}}`).
751#[cfg(feature = "files")]
752fn template_uses_match_var(protected_template: &str) -> bool {
753    const VARS: &[&str] = &["{path}", "{item}", "{dir}", "{stem}", "{ext}", "{name}"];
754    VARS.iter().any(|v| protected_template.contains(v))
755}
756
757/// Detect `"path":"{placeholder}"` (and `from`/`to`) after for_each expansion.
758/// Returns the raw `{name}` token when found.
759#[cfg(feature = "files")]
760fn unsubstituted_path_template(ops_json: &str) -> Option<String> {
761    // Match common path-like string fields that are still a single `{ident}`.
762    // Keep the scan intentional and local: full JSON path walking is overkill.
763    const KEYS: &[&str] = &["\"path\":\"", "\"from\":\"", "\"to\":\""];
764    for key in KEYS {
765        let mut rest = ops_json;
766        while let Some(idx) = rest.find(key) {
767            let after = &rest[idx + key.len()..];
768            if let Some(end) = after.find('"') {
769                let value = &after[..end];
770                if value.len() >= 3
771                    && value.starts_with('{')
772                    && value.ends_with('}')
773                    && value[1..value.len() - 1]
774                        .chars()
775                        .all(|c| c.is_ascii_alphanumeric() || c == '_')
776                {
777                    return Some(value.to_string());
778                }
779                rest = &after[end + 1..];
780            } else {
781                break;
782            }
783        }
784    }
785    None
786}
787
788#[cfg(test)]
789mod tests;