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