1use serde::{Deserialize, Serialize};
4
5pub const SCHEMA_VERSION: u32 = 1;
7
8fn default_strict_true() -> bool {
9 true
10}
11
12fn default_version() -> u32 {
13 1
14}
15
16pub 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#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
33pub struct Plan {
34 #[serde(default = "default_version")]
36 pub version: u32,
37 pub cwd: Option<String>,
51 pub write_policy: Option<crate::write::WritePolicyOverride>,
52 #[serde(default)]
54 pub strict: Option<bool>,
55 #[serde(alias = "ops")]
57 pub operations: Vec<Operation>,
58 pub format: Option<Vec<FormatStep>>,
59 pub validate: Option<Vec<ValidationStep>>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub verify: Option<Vec<VerifyCheck>>,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 #[serde(default)]
66 pub for_each: Option<ForEach>,
67 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
71 pub agent_preset: bool,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub expected_sha256: Option<std::collections::BTreeMap<String, String>>,
78}
79
80impl Plan {
81 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#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
92pub struct ForEach {
93 pub glob: String,
95 #[serde(default)]
97 pub exclude: Vec<String>,
98 #[serde(default)]
100 pub filter: Option<String>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
105#[serde(untagged)]
106pub enum VerifyCheck {
107 SymbolCount {
109 kind: String,
110 #[serde(default)]
111 attr: Option<String>,
112 },
113 Named { check: String },
115}
116
117impl VerifyCheck {
118 #[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 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#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
157pub struct FormatStep {
158 #[serde(alias = "command")]
159 pub cmd: String,
160 pub timeout: Option<u64>,
162}
163
164pub 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
175const LIFECYCLE_SHELL_METAS: &[char] = &['|', ';', '&', '>', '<', '`', '$', '\n', '\r'];
178
179pub 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
200pub(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
231pub(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
334pub(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 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 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#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
448pub struct ValidationStep {
449 #[serde(alias = "command")]
450 pub cmd: String,
451 pub required: Option<bool>,
452 pub timeout: Option<u64>,
454}
455
456pub 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
463pub 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
474pub 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
484pub 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#[cfg(feature = "files")]
521fn json_escape(s: &str) -> String {
522 let quoted = serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""));
524 quoted[1..quoted.len() - 1].to_string()
525}
526
527#[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 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#[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 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 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 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 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 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 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 let template_ops_json = serde_json::to_string(&plan.operations)?;
665
666 let protected = template_ops_json
671 .replace("{{", "\x00LBRACE\x00")
672 .replace("}}", "\x00RBRACE\x00");
673
674 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 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 let substituted = substituted
733 .replace("\x00LBRACE\x00", "{")
734 .replace("\x00RBRACE\x00", "}");
735
736 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#[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#[cfg(feature = "files")]
771fn unsubstituted_path_template(ops_json: &str) -> Option<String> {
772 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;