1use serde::{Deserialize, Serialize};
4
5pub const SCHEMA_VERSION: &str = "1";
7
8#[derive(Debug, Deserialize, Serialize)]
10pub struct Plan {
11 pub version: String,
13 pub cwd: Option<String>,
14 pub write_policy: Option<PlanWritePolicy>,
15 #[serde(default)]
16 pub strict: bool,
17 pub operations: Vec<Operation>,
18 pub format: Option<Vec<FormatStep>>,
19 pub validate: Option<Vec<ValidationStep>>,
20}
21
22#[derive(Debug, Deserialize, Serialize)]
24pub struct FormatStep {
25 pub cmd: String,
26 pub timeout: Option<u64>,
28}
29
30#[derive(Debug, Deserialize, Serialize)]
32pub struct PlanWritePolicy {
33 pub ensure_final_newline: Option<bool>,
34 pub normalize_eol: Option<String>,
35 pub trim_trailing_whitespace: Option<bool>,
36 pub collapse_blanks: Option<bool>,
37}
38
39#[derive(Debug, Deserialize, Serialize)]
41#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
42#[serde(tag = "op")]
43pub enum Operation {
44 #[serde(rename = "replace", alias = "replace_text")]
45 Replace {
46 glob: Option<String>,
47 path: Option<String>,
48 mode: Option<String>,
49 from: String,
50 to: Option<String>,
51 nth: Option<usize>,
52 insert_before: Option<String>,
53 insert_after: Option<String>,
54 #[serde(default)]
55 case_insensitive: bool,
56 #[serde(default)]
57 multiline: bool,
58 #[serde(default)]
59 if_exists: bool,
60 #[serde(default)]
61 whole_line: bool,
62 range: Option<String>,
64 },
65 #[serde(rename = "doc.set", alias = "doc_set")]
66 DocSet {
67 path: String,
68 selector: String,
69 value: serde_json::Value,
70 },
71 #[serde(rename = "doc.delete", alias = "doc_delete")]
72 DocDelete { path: String, selector: String },
73 #[serde(rename = "doc.merge", alias = "doc_merge")]
74 DocMerge {
75 path: String,
76 value: serde_json::Value,
77 },
78 #[serde(rename = "doc.append", alias = "doc_append")]
79 DocAppend {
80 path: String,
81 selector: String,
82 value: serde_json::Value,
83 },
84 #[serde(rename = "doc.prepend", alias = "doc_prepend")]
85 DocPrepend {
86 path: String,
87 selector: String,
88 value: serde_json::Value,
89 },
90 #[serde(rename = "doc.update", alias = "doc_update")]
91 DocUpdate {
92 path: String,
93 selector: String,
94 value: serde_json::Value,
95 },
96 #[serde(rename = "doc.move", alias = "doc_move")]
97 DocMove {
98 path: String,
99 from: String,
100 to: String,
101 },
102 #[serde(rename = "doc.ensure", alias = "doc_ensure")]
103 DocEnsure {
104 path: String,
105 selector: String,
106 value: serde_json::Value,
107 },
108 #[serde(rename = "doc.delete_where", alias = "doc_delete_where")]
109 DocDeleteWhere {
110 path: String,
111 selector: String,
112 predicate: String,
113 },
114 #[serde(rename = "md.replace_section", alias = "md_replace_section")]
115 MdReplaceSection {
116 path: String,
117 heading: String,
118 content: String,
119 },
120 #[serde(rename = "md.insert_after_heading", alias = "md_insert_after_heading")]
121 MdInsertAfterHeading {
122 path: String,
123 heading: String,
124 content: String,
125 },
126 #[serde(
127 rename = "md.insert_before_heading",
128 alias = "md_insert_before_heading"
129 )]
130 MdInsertBeforeHeading {
131 path: String,
132 heading: String,
133 content: String,
134 },
135 #[serde(rename = "md.upsert_bullet", alias = "md_upsert_bullet")]
136 MdUpsertBullet {
137 path: String,
138 heading: String,
139 bullet: String,
140 },
141 #[serde(rename = "md.table_append", alias = "md_table_append")]
142 MdTableAppend {
143 path: String,
144 heading: String,
145 row: String,
146 },
147 #[serde(rename = "md.move_section", alias = "md_move_section")]
148 MdMoveSection {
149 path: String,
150 heading: String,
151 to: Option<String>,
153 before: Option<String>,
155 after: Option<String>,
157 },
158 #[serde(rename = "md.dedupe_headings", alias = "md_dedupe_headings")]
159 MdDedupeHeadings { path: String },
160 #[serde(rename = "tidy.fix", alias = "fix_whitespace")]
161 TidyFix {
162 path: String,
163 ensure_final_newline: Option<bool>,
164 trim_trailing_whitespace: Option<bool>,
165 normalize_eol: Option<String>,
166 },
167 #[serde(rename = "file.create", alias = "create_file")]
168 FileCreate {
169 path: String,
170 content: String,
171 force: Option<bool>,
172 },
173 #[serde(rename = "file.delete", alias = "delete_file")]
174 FileDelete { path: String },
175 #[serde(rename = "file.rename", alias = "move_file")]
176 FileRename {
177 from: String,
178 to: String,
179 #[serde(default)]
181 force: bool,
182 },
183 #[serde(rename = "patch.apply", alias = "apply_patch")]
184 PatchApply {
185 diff: String,
187 },
188 #[serde(rename = "search", alias = "search_files")]
189 Search {
190 path: String,
191 pattern: String,
192 #[serde(default)]
193 regex: bool,
194 #[serde(default)]
195 case_insensitive: bool,
196 #[serde(default)]
198 multiline: bool,
199 #[serde(default)]
201 invert_match: bool,
202 context: Option<usize>,
203 before_context: Option<usize>,
204 after_context: Option<usize>,
205 assert_count: Option<usize>,
207 },
208 #[serde(rename = "read", alias = "read_file")]
209 Read {
210 path: String,
211 lines: Option<String>,
213 },
214 #[serde(rename = "md.lint_agents", alias = "md_lint")]
215 MdLintAgents { path: String },
216}
217
218#[derive(Debug, Deserialize, Serialize)]
220pub struct ValidationStep {
221 pub cmd: String,
222 pub required: Option<bool>,
223 pub timeout: Option<u64>,
225}
226
227pub fn parse_plan(input: &str) -> anyhow::Result<Plan> {
229 let plan: Plan = serde_json::from_str(input)?;
230 Ok(plan)
231}
232
233pub fn parse_plan_yaml(input: &str) -> anyhow::Result<Plan> {
235 let plan: Plan = serde_yaml_ng::from_str(input)?;
236 Ok(plan)
237}
238
239pub fn parse_plan_toml(input: &str) -> anyhow::Result<Plan> {
241 let plan: Plan = toml_edit::de::from_str(input)?;
242 Ok(plan)
243}
244
245pub fn parse_plan_auto(
247 input: &str,
248 path: Option<&str>,
249 format_hint: Option<&str>,
250) -> anyhow::Result<Plan> {
251 let fmt = format_hint.or_else(|| {
252 path.and_then(|p| {
253 if p.ends_with(".yaml") || p.ends_with(".yml") {
254 Some("yaml")
255 } else if p.ends_with(".toml") {
256 Some("toml")
257 } else {
258 None
259 }
260 })
261 });
262 match fmt {
263 Some("yaml" | "yml") => parse_plan_yaml(input),
264 Some("toml") => parse_plan_toml(input),
265 _ => parse_plan(input),
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn parse_minimal_plan() {
275 let json = r#"{"version": "1", "operations": [{"op": "replace", "from": "a", "to": "b"}]}"#;
276 let plan = parse_plan(json).unwrap();
277 assert!(plan.cwd.is_none());
278 assert!(plan.write_policy.is_none());
279 assert!(plan.validate.is_none());
280 assert_eq!(plan.version, "1");
281 assert_eq!(plan.operations.len(), 1);
282 }
283
284 #[test]
285 fn parse_plan_version_field_accepted() {
286 let json = r#"{"version": "1", "operations": [{"op": "replace", "from": "a", "to": "b"}]}"#;
287 let plan = parse_plan(json).unwrap();
288 assert_eq!(plan.version, "1");
289 }
290
291 #[test]
292 fn parse_plan_without_version_fails() {
293 let json = r#"{"operations": [{"op": "replace", "from": "a", "to": "b"}]}"#;
294 assert!(parse_plan(json).is_err());
295 }
296
297 #[test]
298 fn parse_plan_with_all_fields() {
299 let json = r#"{
300 "version": "1",
301 "cwd": "/tmp",
302 "write_policy": {"ensure_final_newline": true, "normalize_eol": "lf"},
303 "operations": [{"op": "file.create", "path": "f.txt", "content": "hi"}],
304 "validate": [{"cmd": "echo ok"}]
305 }"#;
306 let plan = parse_plan(json).unwrap();
307 assert_eq!(plan.cwd.as_deref(), Some("/tmp"));
308 let wp = plan.write_policy.unwrap();
309 assert_eq!(wp.ensure_final_newline, Some(true));
310 assert_eq!(wp.normalize_eol.as_deref(), Some("lf"));
311 assert!(plan.validate.unwrap()[0].required.is_none());
312 }
313
314 #[test]
315 fn parse_plan_unknown_op_fails() {
316 let json = r#"{"version": "1", "operations": [{"op": "unknown", "x": 1}]}"#;
317 assert!(parse_plan(json).is_err());
318 }
319
320 #[test]
321 fn parse_plan_missing_operations_fails() {
322 let json = r#"{"version": "1", "cwd": "/tmp"}"#;
323 assert!(parse_plan(json).is_err());
324 }
325
326 #[test]
327 fn parse_all_operation_variants() {
328 let json = r#"{"version": "1", "operations": [
329 {"op": "replace", "from": "a", "to": "b"},
330 {"op": "replace", "from": "a", "to": "b", "nth": 2},
331 {"op": "doc.set", "path": "f.json", "selector": "k", "value": 1},
332 {"op": "doc.delete", "path": "f.json", "selector": "k"},
333 {"op": "doc.merge", "path": "f.json", "value": {}},
334 {"op": "doc.append", "path": "f.json", "selector": "arr", "value": 1},
335 {"op": "doc.prepend", "path": "f.json", "selector": "arr", "value": 0},
336 {"op": "doc.update", "path": "f.json", "selector": "k", "value": 2},
337 {"op": "doc.move", "path": "f.json", "from": "a", "to": "b"},
338 {"op": "doc.ensure", "path": "f.json", "selector": "k", "value": 1},
339 {"op": "doc.delete_where", "path": "f.json", "selector": "arr", "predicate": "name=x"},
340 {"op": "md.replace_section", "path": "f.md", "heading": "H", "content": "c"},
341 {"op": "md.insert_after_heading", "path": "f.md", "heading": "H", "content": "c"},
342 {"op": "md.insert_before_heading", "path": "f.md", "heading": "H", "content": "c"},
343 {"op": "md.upsert_bullet", "path": "f.md", "heading": "H", "bullet": "- item"},
344 {"op": "md.table_append", "path": "f.md", "heading": "H", "row": "| a | b |"},
345 {"op": "md.move_section", "path": "src.md", "heading": "FAQ", "before": "License"},
346 {"op": "md.move_section", "path": "src.md", "heading": "Appendix", "to": "dest.md", "after": "Body"},
347 {"op": "md.dedupe_headings", "path": "f.md"},
348 {"op": "tidy.fix", "path": "f.txt"},
349 {"op": "tidy.fix", "path": "f.txt", "trim_trailing_whitespace": true, "normalize_eol": "lf"},
350 {"op": "file.create", "path": "f.txt", "content": "c"},
351 {"op": "file.create", "path": "g.txt", "content": "c", "force": true},
352 {"op": "file.delete", "path": "f.txt"},
353 {"op": "file.rename", "from": "old.txt", "to": "new.txt"},
354 {"op": "file.rename", "from": "a.txt", "to": "b.txt", "force": true},
355 {"op": "patch.apply", "diff": "--- a/f.txt\n+++ b/f.txt\n@@ -1 +1 @@\n-a\n+b"},
356 {"op": "read", "path": "f.txt"},
357 {"op": "read", "path": "f.txt", "lines": "1:10"},
358 {"op": "search", "path": "f.txt", "pattern": "hello"},
359 {"op": "search", "path": "f.txt", "pattern": "he.*o", "regex": true, "case_insensitive": true, "multiline": true},
360 {"op": "search", "path": "f.txt", "pattern": "TODO", "invert_match": true, "assert_count": 5}
361 ]}"#;
362 let plan = parse_plan(json).unwrap();
363 assert_eq!(plan.operations.len(), 32);
364 }
365
366 #[test]
367 fn parse_op_aliases_match_mcp_tool_names() {
368 let json = r#"{"version": "1", "operations": [
371 {"op": "replace_text", "from": "a", "to": "b"},
372 {"op": "doc_set", "path": "f.json", "selector": "k", "value": 1},
373 {"op": "doc_delete", "path": "f.json", "selector": "k"},
374 {"op": "doc_merge", "path": "f.json", "value": {}},
375 {"op": "doc_append", "path": "f.json", "selector": "arr", "value": 1},
376 {"op": "doc_ensure", "path": "f.json", "selector": "k", "value": 1},
377 {"op": "doc_delete_where", "path": "f.json", "selector": "arr", "predicate": "x=1"},
378 {"op": "md_move_section", "path": "f.md", "heading": "H", "before": "X"},
379 {"op": "md_replace_section", "path": "f.md", "heading": "H", "content": "c"},
380 {"op": "md_upsert_bullet", "path": "f.md", "heading": "H", "bullet": "- item"},
381 {"op": "md_table_append", "path": "f.md", "heading": "H", "row": "| a |"},
382 {"op": "fix_whitespace", "path": "f.txt"},
383 {"op": "create_file", "path": "f.txt", "content": "c"},
384 {"op": "delete_file", "path": "f.txt"},
385 {"op": "move_file", "from": "a.txt", "to": "b.txt"},
386 {"op": "apply_patch", "diff": "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-a\n+b"},
387 {"op": "search_files", "path": ".", "pattern": "x"},
388 {"op": "read_file", "path": "f.txt"},
389 {"op": "md_lint", "path": "f.md"}
390 ]}"#;
391 let plan = parse_plan(json).unwrap();
392 assert_eq!(plan.operations.len(), 19);
393 }
394
395 #[test]
396 fn parse_plan_with_format_steps() {
397 let json = r#"{
398 "version": "1",
399 "operations": [],
400 "format": [{"cmd": "cargo fmt"}],
401 "validate": [{"cmd": "make check"}]
402 }"#;
403 let plan = parse_plan(json).unwrap();
404 let fmt = plan.format.unwrap();
405 assert_eq!(fmt.len(), 1);
406 assert_eq!(fmt[0].cmd, "cargo fmt");
407 }
408
409 #[test]
412 fn parse_plan_yaml_basic() {
413 let yaml = "version: \"1\"\noperations:\n - op: replace\n from: old\n to: new\n";
414 let plan = parse_plan_yaml(yaml).unwrap();
415 assert_eq!(plan.operations.len(), 1);
416 assert!(matches!(
417 &plan.operations[0],
418 Operation::Replace { from, to, .. } if from == "old" && to.as_deref() == Some("new")
419 ));
420 }
421
422 #[test]
423 fn parse_plan_toml_basic() {
424 let toml =
425 "version = \"1\"\n\n[[operations]]\nop = \"replace\"\nfrom = \"old\"\nto = \"new\"\n";
426 let plan = parse_plan_toml(toml).unwrap();
427 assert_eq!(plan.operations.len(), 1);
428 assert!(matches!(
429 &plan.operations[0],
430 Operation::Replace { from, to, .. } if from == "old" && to.as_deref() == Some("new")
431 ));
432 }
433
434 #[test]
435 fn parse_plan_auto_detects_yaml() {
436 let yaml = "version: \"1\"\noperations:\n - op: replace\n from: a\n to: b\n";
437 let plan = parse_plan_auto(yaml, Some("plan.yaml"), None).unwrap();
438 assert_eq!(plan.operations.len(), 1);
439 }
440
441 #[test]
442 fn parse_plan_auto_format_hint_overrides_extension() {
443 let yaml = "version: \"1\"\noperations:\n - op: replace\n from: a\n to: b\n";
444 let plan = parse_plan_auto(yaml, Some("plan.json"), Some("yaml")).unwrap();
446 assert_eq!(plan.operations.len(), 1);
447 }
448
449 #[test]
450 fn parse_plan_auto_defaults_to_json() {
451 let json = r#"{"version": "1", "operations": [{"op": "replace", "from": "a", "to": "b"}]}"#;
452 let plan = parse_plan_auto(json, Some("plan.txt"), None).unwrap();
453 assert_eq!(plan.operations.len(), 1);
454 }
455
456 #[test]
457 fn parse_plan_strict_and_all_policy_fields() {
458 let json = r#"{
459 "version": "1",
460 "strict": true,
461 "write_policy": {
462 "ensure_final_newline": true,
463 "normalize_eol": "crlf",
464 "trim_trailing_whitespace": true,
465 "collapse_blanks": true
466 },
467 "operations": [],
468 "format": [{"cmd": "fmt", "timeout": 30}],
469 "validate": [{"cmd": "check", "required": true, "timeout": 120}]
470 }"#;
471 let plan = parse_plan(json).unwrap();
472 assert!(plan.strict);
473 let wp = plan.write_policy.unwrap();
474 assert_eq!(wp.ensure_final_newline, Some(true));
475 assert_eq!(wp.normalize_eol.as_deref(), Some("crlf"));
476 assert_eq!(wp.trim_trailing_whitespace, Some(true));
477 assert_eq!(wp.collapse_blanks, Some(true));
478 let fmt = &plan.format.unwrap()[0];
479 assert_eq!(fmt.timeout, Some(30));
480 let val = &plan.validate.unwrap()[0];
481 assert_eq!(val.required, Some(true));
482 assert_eq!(val.timeout, Some(120));
483 }
484}