1use std::collections::{HashMap, HashSet};
21use std::io::IsTerminal;
22
23#[derive(Debug, Clone, serde::Serialize)]
27pub struct PlanDiff {
28 pub identical: bool,
30 pub summary: DiffSummary,
32 pub units: Vec<UnitDiff>,
34 pub tools: ToolsDiff,
36 pub dependencies: DepsDiff,
38}
39
40#[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#[derive(Debug, Clone, serde::Serialize)]
55pub struct UnitDiff {
56 pub flow_name: String,
57 pub status: ChangeStatus,
58 pub field_changes: Vec<FieldChange>,
60 pub steps: Vec<StepDiff>,
62}
63
64#[derive(Debug, Clone, serde::Serialize)]
66pub struct StepDiff {
67 pub step_name: String,
68 pub status: ChangeStatus,
69 pub field_changes: Vec<FieldChange>,
71}
72
73#[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#[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#[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#[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
111pub 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
175fn 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 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 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 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
255fn 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 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 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 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
312fn 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
334fn 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
361fn 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
451pub 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 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 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
500fn 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 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 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 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 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#[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}