Skip to main content

patchloom/
plan.rs

1//! Transaction plan format parsing.
2
3use serde::{Deserialize, Serialize};
4
5/// Current plan schema version.
6pub const SCHEMA_VERSION: &str = "1";
7
8/// A transaction plan containing multiple operations to execute atomically.
9#[derive(Debug, Deserialize, Serialize)]
10pub struct Plan {
11    /// Schema version string. Validated against the supported version.
12    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/// A format step to run after applying operations but before validation.
23#[derive(Debug, Deserialize, Serialize)]
24pub struct FormatStep {
25    pub cmd: String,
26    /// Timeout in seconds (default: 60).
27    pub timeout: Option<u64>,
28}
29
30/// Write policy settings specified in the plan.
31#[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/// A single operation within a plan.
40#[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        /// Line range restriction (e.g. "10:50"). Only valid with whole_line.
63        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        /// Destination file. When omitted, reorder within the same file.
152        to: Option<String>,
153        /// Insert before this heading at the destination.
154        before: Option<String>,
155        /// Insert after this heading at the destination.
156        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        /// If true, overwrite the destination if it already exists.
180        #[serde(default)]
181        force: bool,
182    },
183    #[serde(rename = "patch.apply", alias = "apply_patch")]
184    PatchApply {
185        /// Inline diff text to apply.
186        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        /// Enable multiline matching (dot matches newlines in regex mode).
197        #[serde(default)]
198        multiline: bool,
199        /// Show lines that do NOT match the pattern.
200        #[serde(default)]
201        invert_match: bool,
202        context: Option<usize>,
203        before_context: Option<usize>,
204        after_context: Option<usize>,
205        /// Assert that the total match count equals N. Fails the operation otherwise.
206        assert_count: Option<usize>,
207    },
208    #[serde(rename = "read", alias = "read_file")]
209    Read {
210        path: String,
211        /// Optional line range (e.g., "10:25").
212        lines: Option<String>,
213    },
214    #[serde(rename = "md.lint_agents", alias = "md_lint")]
215    MdLintAgents { path: String },
216}
217
218/// A validation step to run after applying operations.
219#[derive(Debug, Deserialize, Serialize)]
220pub struct ValidationStep {
221    pub cmd: String,
222    pub required: Option<bool>,
223    /// Timeout in seconds (default: 60).
224    pub timeout: Option<u64>,
225}
226
227/// Parse a plan from a JSON string.
228pub fn parse_plan(input: &str) -> anyhow::Result<Plan> {
229    let plan: Plan = serde_json::from_str(input)?;
230    Ok(plan)
231}
232
233/// Parse a plan from a YAML string.
234pub fn parse_plan_yaml(input: &str) -> anyhow::Result<Plan> {
235    let plan: Plan = serde_yaml_ng::from_str(input)?;
236    Ok(plan)
237}
238
239/// Parse a plan from a TOML string.
240pub fn parse_plan_toml(input: &str) -> anyhow::Result<Plan> {
241    let plan: Plan = toml_edit::de::from_str(input)?;
242    Ok(plan)
243}
244
245/// Detect plan format from a file path extension and parse accordingly.
246pub 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        // MCP tools use underscores (doc_set, create_file). Plan ops use dots
369        // (doc.set, file.create). Both forms should parse via serde aliases.
370        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    // ── YAML / TOML / auto-detect ─────────────────────────────────
410
411    #[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        // Extension says .json but hint says yaml.
445        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}