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}
68
69impl Plan {
70 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#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
81pub struct ForEach {
82 pub glob: String,
84 #[serde(default)]
86 pub exclude: Vec<String>,
87 #[serde(default)]
89 pub filter: Option<String>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
94#[serde(untagged)]
95pub enum VerifyCheck {
96 SymbolCount {
98 kind: String,
99 #[serde(default)]
100 attr: Option<String>,
101 },
102 Named { check: String },
104}
105
106impl VerifyCheck {
107 #[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 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#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
146pub struct FormatStep {
147 #[serde(alias = "command")]
148 pub cmd: String,
149 pub timeout: Option<u64>,
151}
152
153pub 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
164const LIFECYCLE_SHELL_METAS: &[char] = &['|', ';', '&', '>', '<', '`', '$', '\n', '\r'];
167
168pub 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
189pub(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
218pub(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
321pub(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 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 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#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
434pub struct ValidationStep {
435 #[serde(alias = "command")]
436 pub cmd: String,
437 pub required: Option<bool>,
438 pub timeout: Option<u64>,
440}
441
442pub 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
451pub 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
462pub 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
472pub 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#[cfg(feature = "files")]
509fn json_escape(s: &str) -> String {
510 let quoted = serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""));
512 quoted[1..quoted.len() - 1].to_string()
513}
514
515#[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 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#[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 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 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 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 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 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 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 let template_ops_json = serde_json::to_string(&plan.operations)?;
653
654 let protected = template_ops_json
659 .replace("{{", "\x00LBRACE\x00")
660 .replace("}}", "\x00RBRACE\x00");
661
662 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 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 let substituted = substituted
721 .replace("\x00LBRACE\x00", "{")
722 .replace("\x00RBRACE\x00", "}");
723
724 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#[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#[cfg(feature = "files")]
760fn unsubstituted_path_template(ops_json: &str) -> Option<String> {
761 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;