Skip to main content

axon/
plan_diff.rs

1//! Plan Diff — compare two exported execution plans.
2//!
3//! Reads two plan JSON files (produced by `axon run --export-plan`) and
4//! produces a structured diff showing what changed between them:
5//!   - Added / removed / modified flows (units)
6//!   - Added / removed / modified steps within matching flows
7//!   - Changed prompts, step types, dependencies
8//!   - Changed tool registry
9//!   - Changed dependency graph (new parallel groups, unresolved refs)
10//!
11//! Usage:
12//!   axon diff plan_a.json plan_b.json
13//!   axon diff plan_a.json plan_b.json --json
14//!
15//! Exit codes:
16//!   0 — plans are identical
17//!   1 — plans differ
18//!   2 — I/O or parse error
19
20use std::collections::{HashMap, HashSet};
21use std::io::IsTerminal;
22
23// ── Diff result types ────────────────────────────────────────────────────
24
25/// Top-level diff between two plans.
26#[derive(Debug, Clone, serde::Serialize)]
27pub struct PlanDiff {
28    /// Whether the plans are identical.
29    pub identical: bool,
30    /// Summary of changes.
31    pub summary: DiffSummary,
32    /// Per-unit diffs (only for units that exist in at least one plan).
33    pub units: Vec<UnitDiff>,
34    /// Tool registry changes.
35    pub tools: ToolsDiff,
36    /// Dependency graph changes.
37    pub dependencies: DepsDiff,
38}
39
40/// Aggregate change counts.
41#[derive(Debug, Clone, serde::Serialize)]
42pub struct DiffSummary {
43    pub units_added: usize,
44    pub units_removed: usize,
45    pub units_modified: usize,
46    pub units_unchanged: usize,
47    pub steps_added: usize,
48    pub steps_removed: usize,
49    pub steps_modified: usize,
50    pub total_changes: usize,
51}
52
53/// Diff for a single execution unit (flow).
54#[derive(Debug, Clone, serde::Serialize)]
55pub struct UnitDiff {
56    pub flow_name: String,
57    pub status: ChangeStatus,
58    /// Changed fields at unit level (persona, context, effort, anchors).
59    pub field_changes: Vec<FieldChange>,
60    /// Per-step diffs within this unit.
61    pub steps: Vec<StepDiff>,
62}
63
64/// Diff for a single step.
65#[derive(Debug, Clone, serde::Serialize)]
66pub struct StepDiff {
67    pub step_name: String,
68    pub status: ChangeStatus,
69    /// Changed fields (type, prompt, tool_argument, dependencies, etc.).
70    pub field_changes: Vec<FieldChange>,
71}
72
73/// A single field-level change.
74#[derive(Debug, Clone, serde::Serialize)]
75pub struct FieldChange {
76    pub field: String,
77    pub old_value: String,
78    pub new_value: String,
79}
80
81/// Tool registry diff.
82#[derive(Debug, Clone, serde::Serialize)]
83pub struct ToolsDiff {
84    pub added: Vec<String>,
85    pub removed: Vec<String>,
86    pub total_before: usize,
87    pub total_after: usize,
88}
89
90/// Dependency graph diff.
91#[derive(Debug, Clone, serde::Serialize)]
92pub struct DepsDiff {
93    pub max_depth_before: usize,
94    pub max_depth_after: usize,
95    pub parallel_groups_before: usize,
96    pub parallel_groups_after: usize,
97    pub unresolved_before: usize,
98    pub unresolved_after: usize,
99}
100
101/// Change classification.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
103#[serde(rename_all = "lowercase")]
104pub enum ChangeStatus {
105    Added,
106    Removed,
107    Modified,
108    Unchanged,
109}
110
111// ── Core diff engine ─────────────────────────────────────────────────────
112
113/// Compare two plan JSON values and produce a structured diff.
114pub fn diff_plans(old: &serde_json::Value, new: &serde_json::Value) -> PlanDiff {
115    let units = diff_units(old, new);
116    let tools = diff_tools(old, new);
117    let dependencies = diff_deps(old, new);
118
119    let mut summary = DiffSummary {
120        units_added: 0,
121        units_removed: 0,
122        units_modified: 0,
123        units_unchanged: 0,
124        steps_added: 0,
125        steps_removed: 0,
126        steps_modified: 0,
127        total_changes: 0,
128    };
129
130    for u in &units {
131        match u.status {
132            ChangeStatus::Added => {
133                summary.units_added += 1;
134                summary.steps_added += u.steps.len();
135            }
136            ChangeStatus::Removed => {
137                summary.units_removed += 1;
138                summary.steps_removed += u.steps.len();
139            }
140            ChangeStatus::Modified => {
141                summary.units_modified += 1;
142                for s in &u.steps {
143                    match s.status {
144                        ChangeStatus::Added => summary.steps_added += 1,
145                        ChangeStatus::Removed => summary.steps_removed += 1,
146                        ChangeStatus::Modified => summary.steps_modified += 1,
147                        ChangeStatus::Unchanged => {}
148                    }
149                }
150            }
151            ChangeStatus::Unchanged => summary.units_unchanged += 1,
152        }
153    }
154
155    summary.total_changes = summary.units_added
156        + summary.units_removed
157        + summary.steps_added
158        + summary.steps_removed
159        + summary.steps_modified
160        + summary.units_modified
161        + tools.added.len()
162        + tools.removed.len();
163
164    let identical = summary.total_changes == 0;
165
166    PlanDiff {
167        identical,
168        summary,
169        units,
170        tools,
171        dependencies,
172    }
173}
174
175/// Compare units (flows) between two plans.
176fn diff_units(old: &serde_json::Value, new: &serde_json::Value) -> Vec<UnitDiff> {
177    let old_units = extract_units(old);
178    let new_units = extract_units(new);
179
180    let old_names: HashSet<&str> = old_units.keys().copied().collect();
181    let new_names: HashSet<&str> = new_units.keys().copied().collect();
182
183    let mut diffs = Vec::new();
184
185    // Removed units
186    for &name in old_names.difference(&new_names) {
187        let old_u = &old_units[name];
188        let steps: Vec<StepDiff> = extract_step_names(old_u)
189            .into_iter()
190            .map(|s| StepDiff {
191                step_name: s,
192                status: ChangeStatus::Removed,
193                field_changes: Vec::new(),
194            })
195            .collect();
196        diffs.push(UnitDiff {
197            flow_name: name.to_string(),
198            status: ChangeStatus::Removed,
199            field_changes: Vec::new(),
200            steps,
201        });
202    }
203
204    // Added units
205    for &name in new_names.difference(&old_names) {
206        let new_u = &new_units[name];
207        let steps: Vec<StepDiff> = extract_step_names(new_u)
208            .into_iter()
209            .map(|s| StepDiff {
210                step_name: s,
211                status: ChangeStatus::Added,
212                field_changes: Vec::new(),
213            })
214            .collect();
215        diffs.push(UnitDiff {
216            flow_name: name.to_string(),
217            status: ChangeStatus::Added,
218            field_changes: Vec::new(),
219            steps,
220        });
221    }
222
223    // Matching units — compare fields + steps
224    for &name in old_names.intersection(&new_names) {
225        let old_u = &old_units[name];
226        let new_u = &new_units[name];
227
228        let mut field_changes = Vec::new();
229        compare_field(old_u, new_u, "persona_name", &mut field_changes);
230        compare_field(old_u, new_u, "context_name", &mut field_changes);
231        compare_field(old_u, new_u, "effort", &mut field_changes);
232        compare_array_field(old_u, new_u, "anchors", &mut field_changes);
233
234        let steps = diff_steps(old_u, new_u);
235
236        let has_changes = !field_changes.is_empty()
237            || steps.iter().any(|s| s.status != ChangeStatus::Unchanged);
238
239        diffs.push(UnitDiff {
240            flow_name: name.to_string(),
241            status: if has_changes {
242                ChangeStatus::Modified
243            } else {
244                ChangeStatus::Unchanged
245            },
246            field_changes,
247            steps,
248        });
249    }
250
251    diffs.sort_by(|a, b| a.flow_name.cmp(&b.flow_name));
252    diffs
253}
254
255/// Compare steps within two matching units.
256fn diff_steps(old_unit: &serde_json::Value, new_unit: &serde_json::Value) -> Vec<StepDiff> {
257    let old_steps = extract_steps_map(old_unit);
258    let new_steps = extract_steps_map(new_unit);
259
260    let old_names: HashSet<&str> = old_steps.keys().copied().collect();
261    let new_names: HashSet<&str> = new_steps.keys().copied().collect();
262
263    let mut diffs = Vec::new();
264
265    // Removed steps
266    for &name in old_names.difference(&new_names) {
267        diffs.push(StepDiff {
268            step_name: name.to_string(),
269            status: ChangeStatus::Removed,
270            field_changes: Vec::new(),
271        });
272    }
273
274    // Added steps
275    for &name in new_names.difference(&old_names) {
276        diffs.push(StepDiff {
277            step_name: name.to_string(),
278            status: ChangeStatus::Added,
279            field_changes: Vec::new(),
280        });
281    }
282
283    // Matching steps — compare fields
284    for &name in old_names.intersection(&new_names) {
285        let old_s = &old_steps[name];
286        let new_s = &new_steps[name];
287
288        let mut field_changes = Vec::new();
289        compare_field(old_s, new_s, "step_type", &mut field_changes);
290        compare_field(old_s, new_s, "prompt_preview", &mut field_changes);
291        compare_field(old_s, new_s, "tool_argument", &mut field_changes);
292        compare_field(old_s, new_s, "memory_expression", &mut field_changes);
293        compare_array_field(old_s, new_s, "depends_on", &mut field_changes);
294
295        let status = if field_changes.is_empty() {
296            ChangeStatus::Unchanged
297        } else {
298            ChangeStatus::Modified
299        };
300
301        diffs.push(StepDiff {
302            step_name: name.to_string(),
303            status,
304            field_changes,
305        });
306    }
307
308    diffs.sort_by(|a, b| a.step_name.cmp(&b.step_name));
309    diffs
310}
311
312/// Compare tool registries between two plans.
313fn diff_tools(old: &serde_json::Value, new: &serde_json::Value) -> ToolsDiff {
314    let old_names = extract_tool_names(old);
315    let new_names = extract_tool_names(new);
316
317    let old_set: HashSet<&str> = old_names.iter().map(|s| s.as_str()).collect();
318    let new_set: HashSet<&str> = new_names.iter().map(|s| s.as_str()).collect();
319
320    let added: Vec<String> = new_set.difference(&old_set).map(|s| s.to_string()).collect();
321    let removed: Vec<String> = old_set.difference(&new_set).map(|s| s.to_string()).collect();
322
323    let total_before = old["tools"]["total"].as_u64().unwrap_or(0) as usize;
324    let total_after = new["tools"]["total"].as_u64().unwrap_or(0) as usize;
325
326    ToolsDiff {
327        added,
328        removed,
329        total_before,
330        total_after,
331    }
332}
333
334/// Compare dependency graphs between two plans.
335fn diff_deps(old: &serde_json::Value, new: &serde_json::Value) -> DepsDiff {
336    let od = &old["dependencies"];
337    let nd = &new["dependencies"];
338
339    DepsDiff {
340        max_depth_before: od["max_depth"].as_u64().unwrap_or(0) as usize,
341        max_depth_after: nd["max_depth"].as_u64().unwrap_or(0) as usize,
342        parallel_groups_before: od["parallel_groups"]
343            .as_array()
344            .map(|a| a.len())
345            .unwrap_or(0),
346        parallel_groups_after: nd["parallel_groups"]
347            .as_array()
348            .map(|a| a.len())
349            .unwrap_or(0),
350        unresolved_before: od["unresolved_refs"]
351            .as_array()
352            .map(|a| a.len())
353            .unwrap_or(0),
354        unresolved_after: nd["unresolved_refs"]
355            .as_array()
356            .map(|a| a.len())
357            .unwrap_or(0),
358    }
359}
360
361// ── JSON extraction helpers ──────────────────────────────────────────────
362
363fn extract_units(plan: &serde_json::Value) -> HashMap<&str, &serde_json::Value> {
364    let mut map = HashMap::new();
365    if let Some(units) = plan["units"].as_array() {
366        for u in units {
367            if let Some(name) = u["flow_name"].as_str() {
368                map.insert(name, u);
369            }
370        }
371    }
372    map
373}
374
375fn extract_step_names(unit: &serde_json::Value) -> Vec<String> {
376    unit["steps"]
377        .as_array()
378        .map(|arr| {
379            arr.iter()
380                .filter_map(|s| s["name"].as_str().map(String::from))
381                .collect()
382        })
383        .unwrap_or_default()
384}
385
386fn extract_steps_map(unit: &serde_json::Value) -> HashMap<&str, &serde_json::Value> {
387    let mut map = HashMap::new();
388    if let Some(steps) = unit["steps"].as_array() {
389        for s in steps {
390            if let Some(name) = s["name"].as_str() {
391                map.insert(name, s);
392            }
393        }
394    }
395    map
396}
397
398fn extract_tool_names(plan: &serde_json::Value) -> Vec<String> {
399    plan["tools"]["registered"]
400        .as_array()
401        .map(|arr| {
402            arr.iter()
403                .filter_map(|t| t["name"].as_str().map(String::from))
404                .collect()
405        })
406        .unwrap_or_default()
407}
408
409fn compare_field(
410    old: &serde_json::Value,
411    new: &serde_json::Value,
412    field: &str,
413    changes: &mut Vec<FieldChange>,
414) {
415    let old_val = json_str(&old[field]);
416    let new_val = json_str(&new[field]);
417    if old_val != new_val {
418        changes.push(FieldChange {
419            field: field.to_string(),
420            old_value: old_val,
421            new_value: new_val,
422        });
423    }
424}
425
426fn compare_array_field(
427    old: &serde_json::Value,
428    new: &serde_json::Value,
429    field: &str,
430    changes: &mut Vec<FieldChange>,
431) {
432    let old_val = old[field].to_string();
433    let new_val = new[field].to_string();
434    if old_val != new_val {
435        changes.push(FieldChange {
436            field: field.to_string(),
437            old_value: old_val,
438            new_value: new_val,
439        });
440    }
441}
442
443fn json_str(v: &serde_json::Value) -> String {
444    match v {
445        serde_json::Value::String(s) => s.clone(),
446        serde_json::Value::Null => String::new(),
447        other => other.to_string(),
448    }
449}
450
451// ── CLI entry point ──────────────────────────────────────────────────────
452
453/// Run the diff command. Returns exit code.
454pub fn run_diff(file_a: &str, file_b: &str, json_output: bool) -> i32 {
455    let use_color = !json_output && std::io::stdout().is_terminal();
456
457    // Read files
458    let content_a = match std::fs::read_to_string(file_a) {
459        Ok(s) => s,
460        Err(e) => {
461            eprintln!("Cannot read '{}': {e}", file_a);
462            return 2;
463        }
464    };
465    let content_b = match std::fs::read_to_string(file_b) {
466        Ok(s) => s,
467        Err(e) => {
468            eprintln!("Cannot read '{}': {e}", file_b);
469            return 2;
470        }
471    };
472
473    // Parse JSON
474    let plan_a: serde_json::Value = match serde_json::from_str(&content_a) {
475        Ok(v) => v,
476        Err(e) => {
477            eprintln!("Invalid JSON in '{}': {e}", file_a);
478            return 2;
479        }
480    };
481    let plan_b: serde_json::Value = match serde_json::from_str(&content_b) {
482        Ok(v) => v,
483        Err(e) => {
484            eprintln!("Invalid JSON in '{}': {e}", file_b);
485            return 2;
486        }
487    };
488
489    let diff = diff_plans(&plan_a, &plan_b);
490
491    if json_output {
492        println!("{}", serde_json::to_string_pretty(&diff).unwrap());
493    } else {
494        print_diff(&diff, file_a, file_b, use_color);
495    }
496
497    if diff.identical { 0 } else { 1 }
498}
499
500// ── Human-readable output ────────────────────────────────────────────────
501
502fn print_diff(diff: &PlanDiff, file_a: &str, file_b: &str, use_color: bool) {
503    let red = |s: &str| if use_color { format!("\x1b[1;31m{s}\x1b[0m") } else { s.to_string() };
504    let green = |s: &str| if use_color { format!("\x1b[1;32m{s}\x1b[0m") } else { s.to_string() };
505    let yellow = |s: &str| if use_color { format!("\x1b[1;33m{s}\x1b[0m") } else { s.to_string() };
506    let dim = |s: &str| if use_color { format!("\x1b[2m{s}\x1b[0m") } else { s.to_string() };
507    let bold = |s: &str| if use_color { format!("\x1b[1m{s}\x1b[0m") } else { s.to_string() };
508
509    println!(
510        "{} {} → {}",
511        bold("Plan Diff:"),
512        dim(file_a),
513        dim(file_b),
514    );
515
516    if diff.identical {
517        println!("  {} Plans are identical.", green("✓"));
518        return;
519    }
520
521    // Summary line
522    let s = &diff.summary;
523    println!(
524        "  {} changes: {} unit(s) added, {} removed, {} modified; {} step(s) added, {} removed, {} modified",
525        yellow(&format!("{}", s.total_changes)),
526        s.units_added,
527        s.units_removed,
528        s.units_modified,
529        s.steps_added,
530        s.steps_removed,
531        s.steps_modified,
532    );
533
534    // Unit diffs
535    for u in &diff.units {
536        match u.status {
537            ChangeStatus::Added => {
538                println!("\n  {} flow {}", green("+ "), bold(&u.flow_name));
539                for step in &u.steps {
540                    println!("    {} step {}", green("+"), step.step_name);
541                }
542            }
543            ChangeStatus::Removed => {
544                println!("\n  {} flow {}", red("- "), bold(&u.flow_name));
545                for step in &u.steps {
546                    println!("    {} step {}", red("-"), step.step_name);
547                }
548            }
549            ChangeStatus::Modified => {
550                println!("\n  {} flow {}", yellow("~ "), bold(&u.flow_name));
551                for fc in &u.field_changes {
552                    println!(
553                        "    {} {}: {} → {}",
554                        yellow("~"),
555                        fc.field,
556                        red(&fc.old_value),
557                        green(&fc.new_value),
558                    );
559                }
560                for step in &u.steps {
561                    match step.status {
562                        ChangeStatus::Added => {
563                            println!("    {} step {}", green("+"), step.step_name);
564                        }
565                        ChangeStatus::Removed => {
566                            println!("    {} step {}", red("-"), step.step_name);
567                        }
568                        ChangeStatus::Modified => {
569                            println!("    {} step {}", yellow("~"), step.step_name);
570                            for fc in &step.field_changes {
571                                println!(
572                                    "      {} {}: {} → {}",
573                                    yellow("~"),
574                                    fc.field,
575                                    red(&fc.old_value),
576                                    green(&fc.new_value),
577                                );
578                            }
579                        }
580                        ChangeStatus::Unchanged => {}
581                    }
582                }
583            }
584            ChangeStatus::Unchanged => {}
585        }
586    }
587
588    // Tool changes
589    if !diff.tools.added.is_empty() || !diff.tools.removed.is_empty() {
590        println!("\n  {}", bold("Tools:"));
591        for t in &diff.tools.added {
592            println!("    {} {}", green("+"), t);
593        }
594        for t in &diff.tools.removed {
595            println!("    {} {}", red("-"), t);
596        }
597    }
598
599    // Dependency changes
600    let d = &diff.dependencies;
601    if d.max_depth_before != d.max_depth_after
602        || d.parallel_groups_before != d.parallel_groups_after
603        || d.unresolved_before != d.unresolved_after
604    {
605        println!("\n  {}", bold("Dependencies:"));
606        if d.max_depth_before != d.max_depth_after {
607            println!(
608                "    max_depth: {} → {}",
609                d.max_depth_before, d.max_depth_after,
610            );
611        }
612        if d.parallel_groups_before != d.parallel_groups_after {
613            println!(
614                "    parallel_groups: {} → {}",
615                d.parallel_groups_before, d.parallel_groups_after,
616            );
617        }
618        if d.unresolved_before != d.unresolved_after {
619            println!(
620                "    unresolved_refs: {} → {}",
621                d.unresolved_before, d.unresolved_after,
622            );
623        }
624    }
625}
626
627// ── Tests ────────────────────────────────────────────────────────────────
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use serde_json::json;
633
634    fn make_plan(units: serde_json::Value, tools: serde_json::Value, deps: serde_json::Value) -> serde_json::Value {
635        json!({
636            "_schema": { "type": "axon.plan", "version": "1.0.0" },
637            "units": units,
638            "tools": tools,
639            "dependencies": deps,
640        })
641    }
642
643    fn simple_plan() -> serde_json::Value {
644        make_plan(
645            json!([{
646                "flow_name": "Flow1",
647                "persona_name": "P1",
648                "context_name": "default",
649                "effort": "medium",
650                "anchor_count": 1,
651                "anchors": ["NoHallucination"],
652                "steps": [
653                    { "name": "S1", "step_type": "step", "prompt_preview": "do something", "depends_on": [], "is_root": true },
654                    { "name": "S2", "step_type": "step", "prompt_preview": "use $S1", "depends_on": ["S1"], "is_root": false },
655                ]
656            }]),
657            json!({ "total": 2, "builtin": ["Calculator"], "program": [], "registered": [
658                { "name": "Calculator", "provider": "native", "source": "builtin" }
659            ]}),
660            json!({ "max_depth": 1, "parallel_groups": [["S1"]], "unresolved_refs": [] }),
661        )
662    }
663
664    #[test]
665    fn identical_plans() {
666        let plan = simple_plan();
667        let diff = diff_plans(&plan, &plan);
668        assert!(diff.identical);
669        assert_eq!(diff.summary.total_changes, 0);
670        assert_eq!(diff.summary.units_unchanged, 1);
671    }
672
673    #[test]
674    fn added_flow() {
675        let old = simple_plan();
676        let mut new = simple_plan();
677        new["units"].as_array_mut().unwrap().push(json!({
678            "flow_name": "Flow2",
679            "persona_name": "P2",
680            "context_name": "default",
681            "effort": "low",
682            "anchor_count": 0,
683            "anchors": [],
684            "steps": [
685                { "name": "A1", "step_type": "step", "prompt_preview": "new step", "depends_on": [], "is_root": true },
686            ]
687        }));
688
689        let diff = diff_plans(&old, &new);
690        assert!(!diff.identical);
691        assert_eq!(diff.summary.units_added, 1);
692        assert_eq!(diff.summary.steps_added, 1);
693
694        let added = diff.units.iter().find(|u| u.flow_name == "Flow2").unwrap();
695        assert_eq!(added.status, ChangeStatus::Added);
696    }
697
698    #[test]
699    fn removed_flow() {
700        let old = simple_plan();
701        let new = make_plan(json!([]), json!({ "total": 0, "builtin": [], "program": [], "registered": [] }), json!({ "max_depth": 0, "parallel_groups": [], "unresolved_refs": [] }));
702
703        let diff = diff_plans(&old, &new);
704        assert!(!diff.identical);
705        assert_eq!(diff.summary.units_removed, 1);
706        assert_eq!(diff.summary.steps_removed, 2);
707    }
708
709    #[test]
710    fn modified_step_prompt() {
711        let old = simple_plan();
712        let mut new = simple_plan();
713        new["units"][0]["steps"][0]["prompt_preview"] = json!("do something different");
714
715        let diff = diff_plans(&old, &new);
716        assert!(!diff.identical);
717        assert_eq!(diff.summary.units_modified, 1);
718        assert_eq!(diff.summary.steps_modified, 1);
719
720        let flow1 = diff.units.iter().find(|u| u.flow_name == "Flow1").unwrap();
721        assert_eq!(flow1.status, ChangeStatus::Modified);
722
723        let s1 = flow1.steps.iter().find(|s| s.step_name == "S1").unwrap();
724        assert_eq!(s1.status, ChangeStatus::Modified);
725        assert_eq!(s1.field_changes[0].field, "prompt_preview");
726    }
727
728    #[test]
729    fn added_step_in_existing_flow() {
730        let old = simple_plan();
731        let mut new = simple_plan();
732        new["units"][0]["steps"].as_array_mut().unwrap().push(json!({
733            "name": "S3",
734            "step_type": "use_tool",
735            "prompt_preview": "new tool step",
736            "depends_on": ["S2"],
737            "is_root": false,
738        }));
739
740        let diff = diff_plans(&old, &new);
741        assert!(!diff.identical);
742        assert_eq!(diff.summary.steps_added, 1);
743
744        let flow1 = diff.units.iter().find(|u| u.flow_name == "Flow1").unwrap();
745        let s3 = flow1.steps.iter().find(|s| s.step_name == "S3").unwrap();
746        assert_eq!(s3.status, ChangeStatus::Added);
747    }
748
749    #[test]
750    fn changed_persona() {
751        let old = simple_plan();
752        let mut new = simple_plan();
753        new["units"][0]["persona_name"] = json!("P2");
754
755        let diff = diff_plans(&old, &new);
756        assert!(!diff.identical);
757
758        let flow1 = diff.units.iter().find(|u| u.flow_name == "Flow1").unwrap();
759        assert_eq!(flow1.status, ChangeStatus::Modified);
760        assert!(flow1.field_changes.iter().any(|f| f.field == "persona_name"));
761    }
762
763    #[test]
764    fn tool_registry_changes() {
765        let old = simple_plan();
766        let mut new = simple_plan();
767        new["tools"]["registered"].as_array_mut().unwrap().push(json!({
768            "name": "WebSearch", "provider": "brave", "source": "program"
769        }));
770        new["tools"]["total"] = json!(3);
771
772        let diff = diff_plans(&old, &new);
773        assert_eq!(diff.tools.added, vec!["WebSearch"]);
774        assert!(diff.tools.removed.is_empty());
775        assert_eq!(diff.tools.total_before, 2);
776        assert_eq!(diff.tools.total_after, 3);
777    }
778
779    #[test]
780    fn dependency_changes() {
781        let old = simple_plan();
782        let mut new = simple_plan();
783        new["dependencies"]["max_depth"] = json!(3);
784        new["dependencies"]["parallel_groups"] = json!([["S1", "S2"], ["S3"]]);
785
786        let diff = diff_plans(&old, &new);
787        assert_eq!(diff.dependencies.max_depth_before, 1);
788        assert_eq!(diff.dependencies.max_depth_after, 3);
789        assert_eq!(diff.dependencies.parallel_groups_before, 1);
790        assert_eq!(diff.dependencies.parallel_groups_after, 2);
791    }
792
793    #[test]
794    fn step_type_change() {
795        let old = simple_plan();
796        let mut new = simple_plan();
797        new["units"][0]["steps"][0]["step_type"] = json!("use_tool");
798
799        let diff = diff_plans(&old, &new);
800        let flow1 = diff.units.iter().find(|u| u.flow_name == "Flow1").unwrap();
801        let s1 = flow1.steps.iter().find(|s| s.step_name == "S1").unwrap();
802        assert_eq!(s1.status, ChangeStatus::Modified);
803        assert!(s1.field_changes.iter().any(|f| f.field == "step_type"));
804    }
805
806    #[test]
807    fn dependency_list_change() {
808        let old = simple_plan();
809        let mut new = simple_plan();
810        new["units"][0]["steps"][1]["depends_on"] = json!(["S1", "S3"]);
811
812        let diff = diff_plans(&old, &new);
813        let flow1 = diff.units.iter().find(|u| u.flow_name == "Flow1").unwrap();
814        let s2 = flow1.steps.iter().find(|s| s.step_name == "S2").unwrap();
815        assert_eq!(s2.status, ChangeStatus::Modified);
816        assert!(s2.field_changes.iter().any(|f| f.field == "depends_on"));
817    }
818
819    #[test]
820    fn run_diff_file_not_found() {
821        assert_eq!(run_diff("nonexistent_a.json", "nonexistent_b.json", false), 2);
822    }
823
824    #[test]
825    fn run_diff_identical_files() {
826        let tmp = std::env::temp_dir().join("axon_diff_test.json");
827        let plan = simple_plan();
828        std::fs::write(&tmp, serde_json::to_string(&plan).unwrap()).unwrap();
829
830        let path = tmp.to_str().unwrap();
831        assert_eq!(run_diff(path, path, true), 0);
832        let _ = std::fs::remove_file(tmp);
833    }
834
835    #[test]
836    fn run_diff_different_files() {
837        let tmp_a = std::env::temp_dir().join("axon_diff_a.json");
838        let tmp_b = std::env::temp_dir().join("axon_diff_b.json");
839
840        let plan_a = simple_plan();
841        let mut plan_b = simple_plan();
842        plan_b["units"][0]["steps"][0]["prompt_preview"] = json!("changed");
843
844        std::fs::write(&tmp_a, serde_json::to_string(&plan_a).unwrap()).unwrap();
845        std::fs::write(&tmp_b, serde_json::to_string(&plan_b).unwrap()).unwrap();
846
847        assert_eq!(run_diff(tmp_a.to_str().unwrap(), tmp_b.to_str().unwrap(), true), 1);
848
849        let _ = std::fs::remove_file(tmp_a);
850        let _ = std::fs::remove_file(tmp_b);
851    }
852
853    #[test]
854    fn change_status_serializes() {
855        assert_eq!(
856            serde_json::to_string(&ChangeStatus::Added).unwrap(),
857            "\"added\"",
858        );
859        assert_eq!(
860            serde_json::to_string(&ChangeStatus::Modified).unwrap(),
861            "\"modified\"",
862        );
863    }
864}