1use crate::semantic::{Change, ChangeKind, DiffResult};
4use serde_json::Value;
5
6const FIELD_COL_WIDTH: usize = 40;
8const VALUE_COL_WIDTH: usize = 20;
10
11#[derive(Debug, Clone)]
20pub struct SideLabels {
21 pub new_side: String,
24 pub old_side: String,
27}
28
29pub fn format_text(result: &DiffResult, resource_name: &str, labels: &SideLabels) -> String {
31 if result.is_equal {
32 return format!("{}: no changes\n", resource_name);
33 }
34
35 let mut output = String::new();
36 output.push_str(&format!(
37 "{} — differs ({} field(s))\n\n",
38 resource_name,
39 result.changes.len()
40 ));
41 output.push_str(&format!(
42 " {:<fw$} {:<vw$} {}\n",
43 "field",
44 labels.new_side,
45 labels.old_side,
46 fw = FIELD_COL_WIDTH,
47 vw = VALUE_COL_WIDTH
48 ));
49
50 for change in &result.changes {
51 output.push_str(&format_change_row(change));
52 }
53
54 output
55}
56
57fn format_change_row(change: &Change) -> String {
58 if let Some(desc) = &change.description {
60 return format!(" {}\n", desc);
61 }
62
63 let (new_str, old_str) = match change.kind {
64 ChangeKind::Added => (
65 table_value(change.new_value.as_ref()),
66 "(absent)".to_string(),
67 ),
68 ChangeKind::Removed => (
69 "(absent)".to_string(),
70 table_value(change.old_value.as_ref()),
71 ),
72 ChangeKind::Modified => (
73 table_value(change.new_value.as_ref()),
74 table_value(change.old_value.as_ref()),
75 ),
76 };
77
78 if change.path.len() > FIELD_COL_WIDTH {
79 let indent = " ".repeat(FIELD_COL_WIDTH + 3);
85 format!(
86 " {}\n{indent}{:<vw$} {}\n",
87 change.path,
88 new_str,
89 old_str,
90 vw = VALUE_COL_WIDTH
91 )
92 } else {
93 format!(
94 " {:<fw$} {:<vw$} {}\n",
95 change.path,
96 new_str,
97 old_str,
98 fw = FIELD_COL_WIDTH,
99 vw = VALUE_COL_WIDTH
100 )
101 }
102}
103
104const TABLE_VALUE_MAX: usize = 80;
107
108fn table_value(value: Option<&Value>) -> String {
109 let preview = format_value_preview(value);
110 if preview.chars().count() <= TABLE_VALUE_MAX {
111 return preview;
112 }
113 let cut: String = preview.chars().take(TABLE_VALUE_MAX - 3).collect();
114 format!("{cut}...")
115}
116
117pub fn format_value_preview(value: Option<&Value>) -> String {
122 match value {
123 None => "(none)".to_string(),
124 Some(Value::Null) => "null".to_string(),
125 Some(Value::Bool(b)) => b.to_string(),
126 Some(Value::Number(n)) => n.to_string(),
127 Some(Value::String(s)) => {
128 if s.chars().count() > 500 {
129 let cut: String = s.chars().take(497).collect();
130 format!("\"{cut}...\" ({} chars)", s.chars().count())
131 } else {
132 format!("\"{}\"", s)
133 }
134 }
135 Some(Value::Array(arr)) => {
136 if arr.is_empty() {
137 "[]".to_string()
138 } else if arr.len() <= 3 && arr.iter().all(is_simple_value) {
139 let items: Vec<String> =
141 arr.iter().map(|v| format_value_preview(Some(v))).collect();
142 format!("[{}]", items.join(", "))
143 } else {
144 format!("[{} items]", arr.len())
145 }
146 }
147 Some(Value::Object(obj)) => {
148 if obj.len() == 1 {
149 "{...} (1 key)".to_string()
150 } else {
151 format!("{{...}} ({} keys)", obj.len())
152 }
153 }
154 }
155}
156
157fn is_simple_value(value: &Value) -> bool {
159 matches!(
160 value,
161 Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null
162 )
163}
164
165pub fn format_json(result: &DiffResult) -> String {
167 serde_json::to_string_pretty(result).unwrap_or_else(|_| "{}".to_string())
168}
169
170pub fn format_report(
172 diffs: &[(String, DiffResult)],
173 format: OutputFormat,
174 labels: &SideLabels,
175) -> String {
176 match format {
177 OutputFormat::Text => format_report_text(diffs, labels),
178 OutputFormat::Json => format_report_json(diffs),
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum OutputFormat {
185 Text,
186 Json,
187}
188
189fn format_report_text(diffs: &[(String, DiffResult)], labels: &SideLabels) -> String {
190 let mut output = String::new();
191
192 let (changed, unchanged): (Vec<_>, Vec<_>) = diffs.iter().partition(|(_, r)| !r.is_equal);
193
194 if changed.is_empty() {
195 output.push_str("No changes detected.\n");
196 return output;
197 }
198
199 output.push_str(&format!(
200 "Found {} resource(s) with changes:\n\n",
201 changed.len()
202 ));
203
204 for (name, result) in &changed {
205 output.push_str(&format_text(result, name, labels));
206 output.push('\n');
207 }
208
209 if !unchanged.is_empty() {
210 output.push_str(&format!("{} resource(s) unchanged.\n", unchanged.len()));
211 }
212
213 output
214}
215
216fn format_report_json(diffs: &[(String, DiffResult)]) -> String {
217 let report: Vec<_> = diffs
218 .iter()
219 .map(|(name, result)| {
220 serde_json::json!({
221 "resource": name,
222 "changed": !result.is_equal,
223 "changes": result.changes
224 })
225 })
226 .collect();
227
228 serde_json::to_string_pretty(&report).unwrap_or_else(|_| "[]".to_string())
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::semantic::diff;
235 use serde_json::json;
236
237 fn labels() -> SideLabels {
238 SideLabels {
239 new_side: "local".to_string(),
240 old_side: "Azure (dev)".to_string(),
241 }
242 }
243
244 #[test]
245 fn test_format_text_no_changes() {
246 let result = DiffResult {
247 is_equal: true,
248 changes: vec![],
249 };
250
251 let output = format_text(&result, "test-index", &labels());
252 assert!(output.contains("no changes"));
253 }
254
255 #[test]
256 fn test_format_text_with_changes() {
257 let old = json!({"name": "test", "value": 1});
258 let new = json!({"name": "test", "value": 2});
259
260 let result = diff(&old, &new, "name");
261 let output = format_text(&result, "test-index", &labels());
262
263 assert!(output.contains("1 field"));
264 assert!(output.contains("value"));
265 assert!(!output.contains(" was "));
266 assert!(!output.contains(" now "));
267 }
268
269 #[test]
270 fn test_format_text_uses_description_when_set() {
271 let result = DiffResult {
272 is_equal: false,
273 changes: vec![Change {
274 path: "description".to_string(),
275 kind: ChangeKind::Modified,
276 old_value: Some(json!("old")),
277 new_value: Some(json!("new")),
278 description: Some(
279 "The description differs: locally has \"old\" while on the server has \"new\""
280 .to_string(),
281 ),
282 }],
283 };
284
285 let output = format_text(&result, "test-index", &labels());
286 assert!(output.contains("The description differs"));
287 assert!(!output.contains("~")); }
289
290 #[test]
291 fn test_format_json() {
292 let result = DiffResult {
293 is_equal: false,
294 changes: vec![Change {
295 path: "name".to_string(),
296 kind: ChangeKind::Modified,
297 old_value: Some(json!("old")),
298 new_value: Some(json!("new")),
299 description: None,
300 }],
301 };
302
303 let output = format_json(&result);
304 assert!(output.contains("modified"));
305 assert!(output.contains("name"));
306 }
307
308 #[test]
309 fn test_format_value_preview_long_string() {
310 let long = "a".repeat(600);
311 let preview = format_value_preview(Some(&json!(long)));
312 assert!(preview.contains("..."));
313 assert!(preview.contains("600 chars"));
314 }
315
316 #[test]
317 fn test_format_value_preview_medium_string_not_truncated() {
318 let medium = "a".repeat(400);
319 let preview = format_value_preview(Some(&json!(medium)));
320 assert!(!preview.contains("..."));
321 assert_eq!(preview, format!("\"{}\"", medium));
322 }
323
324 #[test]
325 fn test_format_value_preview_small_array() {
326 let preview = format_value_preview(Some(&json!([1, 2, 3])));
327 assert_eq!(preview, "[1, 2, 3]");
328 }
329
330 #[test]
331 fn test_format_value_preview_small_string_array() {
332 let preview = format_value_preview(Some(&json!(["a", "b"])));
333 assert_eq!(preview, "[\"a\", \"b\"]");
334 }
335
336 #[test]
337 fn test_format_value_preview_large_array() {
338 let preview = format_value_preview(Some(&json!([1, 2, 3, 4])));
339 assert_eq!(preview, "[4 items]");
340 }
341
342 #[test]
343 fn test_format_value_preview_empty_array() {
344 let preview = format_value_preview(Some(&json!([])));
345 assert_eq!(preview, "[]");
346 }
347
348 #[test]
349 fn test_format_value_preview_complex_array_items() {
350 let preview = format_value_preview(Some(&json!([{"a": 1}])));
351 assert_eq!(preview, "[1 items]");
352 }
353
354 #[test]
355 fn test_format_value_preview_object_singular_key() {
356 let preview = format_value_preview(Some(&json!({"a": 1})));
357 assert_eq!(preview, "{...} (1 key)");
358 }
359
360 #[test]
361 fn test_format_value_preview_object_plural_keys() {
362 let preview = format_value_preview(Some(&json!({"a": 1, "b": 2})));
363 assert_eq!(preview, "{...} (2 keys)");
364 }
365
366 #[test]
367 fn test_modified_row_has_no_temporal_words() {
368 let change = Change {
369 path: "description".to_string(),
370 kind: ChangeKind::Modified,
371 old_value: Some(json!("old text")),
372 new_value: Some(json!("new text")),
373 description: None,
374 };
375 let output = format_change_row(&change);
376 assert!(!output.contains(" was "));
377 assert!(!output.contains(" now "));
378 assert!(!output.contains("->"));
379 assert!(output.contains("old text"));
380 assert!(output.contains("new text"));
381 }
382
383 #[test]
384 fn table_renders_both_sides_with_labels_no_temporal_words() {
385 let result = diff(
386 &json!({"name": "a", "model": "gpt-5.6-luna"}), &json!({"name": "a", "model": "gpt-5.2-chat"}), "name",
389 );
390 let labels = SideLabels {
391 new_side: "local".to_string(),
392 old_side: "Azure (dev)".to_string(),
393 };
394 let out = format_text(&result, "regulus/agents/Regulus", &labels);
395 assert!(out.contains("local"), "{out}");
396 assert!(out.contains("Azure (dev)"), "{out}");
397 assert!(
398 out.contains("gpt-5.2-chat") && out.contains("gpt-5.6-luna"),
399 "{out}"
400 );
401 let row = out.lines().find(|l| l.contains("model")).unwrap();
403 let li = row.find("gpt-5.2-chat").unwrap();
404 let ri = row.find("gpt-5.6-luna").unwrap();
405 assert!(li < ri, "local value first: {row}");
406 assert!(!out.contains(" was "), "{out}");
407 assert!(!out.contains(" now "), "{out}");
408 }
409
410 #[test]
411 fn table_renders_absent_for_one_sided_values() {
412 let result = diff(
413 &json!({"name": "a", "reasoning": {"effort": "high"}}), &json!({"name": "a"}), "name",
416 );
417 let labels = SideLabels {
418 new_side: "local".into(),
419 old_side: "Azure (dev)".into(),
420 };
421 let out = format_text(&result, "r", &labels);
422 assert!(out.contains("(absent)"), "{out}");
423 assert!(
424 out.contains("1 key)") && !out.contains("1 keys"),
425 "pluralization: {out}"
426 );
427 }
428
429 #[test]
430 fn table_has_no_change_kind_markers() {
431 let result = DiffResult {
432 is_equal: false,
433 changes: vec![
434 Change {
435 path: "added_field".to_string(),
436 kind: ChangeKind::Added,
437 old_value: None,
438 new_value: Some(json!("x")),
439 description: None,
440 },
441 Change {
442 path: "removed_field".to_string(),
443 kind: ChangeKind::Removed,
444 old_value: Some(json!("y")),
445 new_value: None,
446 description: None,
447 },
448 Change {
449 path: "modified_field".to_string(),
450 kind: ChangeKind::Modified,
451 old_value: Some(json!("a")),
452 new_value: Some(json!("b")),
453 description: None,
454 },
455 ],
456 };
457 let out = format_text(&result, "r", &labels());
458 for line in out.lines() {
459 assert!(!line.starts_with(" - "), "removed marker in: {line}");
460 assert!(!line.starts_with(" + "), "added marker in: {line}");
461 assert!(!line.starts_with(" ~ "), "modified marker in: {line}");
462 }
463 assert!(out.contains("(absent)"), "{out}");
464 }
465
466 #[test]
467 fn long_field_paths_get_their_own_line() {
468 let long_path = "metadata.microsoft.voice-live.configuration";
469 assert!(long_path.len() > FIELD_COL_WIDTH, "fixture must be long");
470
471 let result = DiffResult {
472 is_equal: false,
473 changes: vec![Change {
474 path: long_path.to_string(),
475 kind: ChangeKind::Modified,
476 old_value: Some(json!("OLDVAL")),
477 new_value: Some(json!("NEWVAL")),
478 description: None,
479 }],
480 };
481 let out = format_text(&result, "r", &labels());
482 let lines: Vec<&str> = out.lines().collect();
483 let path_idx = lines
484 .iter()
485 .position(|l| l.contains(long_path))
486 .expect("path line present");
487 let path_line = lines[path_idx];
488 assert!(
489 !path_line.contains("NEWVAL") && !path_line.contains("OLDVAL"),
490 "path line must not carry values: {path_line}"
491 );
492 let value_line = lines[path_idx + 1];
493 assert!(
494 value_line.contains("NEWVAL") && value_line.contains("OLDVAL"),
495 "next line must carry both values: {value_line}"
496 );
497
498 let normal_row = format_change_row(&Change {
500 path: "short".to_string(),
501 kind: ChangeKind::Modified,
502 old_value: Some(json!("o")),
503 new_value: Some(json!("NEWVAL")),
504 description: None,
505 });
506 let expected_col = normal_row.find("NEWVAL").expect("value in normal row");
507 let actual_col = value_line.find("NEWVAL").expect("value in wrapped row");
508 assert_eq!(
509 actual_col, expected_col,
510 "wrapped value column should match normal row's value column\nnormal: {normal_row:?}\nwrapped: {value_line:?}"
511 );
512 }
513
514 #[test]
515 fn long_values_truncated_in_table() {
516 let long_value = "å".repeat(200);
520 let result = DiffResult {
521 is_equal: false,
522 changes: vec![Change {
523 path: "field".to_string(),
524 kind: ChangeKind::Modified,
525 old_value: Some(json!(long_value)),
526 new_value: Some(json!("short")),
527 description: None,
528 }],
529 };
530 let out = format_text(&result, "r", &labels());
531 assert!(out.contains("..."), "{out}");
532 assert!(
533 !out.contains(&long_value),
534 "full 300-char value must not appear verbatim: {out}"
535 );
536 }
537
538 #[test]
539 fn markdown_cells_truncated_and_unmarked() {
540 let long_value = "å".repeat(200);
544 let result = DiffResult {
545 is_equal: false,
546 changes: vec![Change {
547 path: "field".to_string(),
548 kind: ChangeKind::Modified,
549 old_value: Some(json!(long_value)),
550 new_value: Some(json!("short")),
551 description: None,
552 }],
553 };
554 let md = format_markdown(&[("r".to_string(), result)], &labels());
555 assert!(md.contains("..."), "{md}");
556 assert!(
557 !md.contains(&long_value),
558 "full 300-char value must not appear verbatim: {md}"
559 );
560 for line in md.lines().filter(|l| l.starts_with('|')) {
561 assert!(!line.contains("| - "), "removed marker in: {line}");
562 assert!(!line.contains("| + "), "added marker in: {line}");
563 assert!(!line.contains("| ~ "), "modified marker in: {line}");
564 }
565 }
566}
567
568pub fn format_markdown(diffs: &[(String, DiffResult)], labels: &SideLabels) -> String {
573 let changed: Vec<_> = diffs.iter().filter(|(_, d)| !d.is_equal).collect();
574 if changed.is_empty() {
575 return "✅ No differences.\n".to_string();
576 }
577 let mut out = String::new();
578 out.push_str(&format!(
579 "## rigg diff — {} resource(s) differ\n\n",
580 changed.len()
581 ));
582 for (name, result) in changed {
583 out.push_str(&format!(
584 "### `{}` — {} change(s)\n\n",
585 name,
586 result.changes.len()
587 ));
588 out.push_str(&format!(
589 "| field | {} | {} |\n",
590 escape_md(&labels.new_side),
591 escape_md(&labels.old_side)
592 ));
593 out.push_str("| --- | --- | --- |\n");
594 for change in &result.changes {
595 out.push_str(&format_change_markdown_row(change));
596 }
597 out.push('\n');
598 }
599 out
600}
601
602fn format_change_markdown_row(change: &Change) -> String {
603 if let Some(desc) = &change.description {
604 return format!("| {} | | |\n", escape_md(desc));
605 }
606
607 let (new_str, old_str) = match change.kind {
608 ChangeKind::Added => (
609 table_value(change.new_value.as_ref()),
610 "(absent)".to_string(),
611 ),
612 ChangeKind::Removed => (
613 "(absent)".to_string(),
614 table_value(change.old_value.as_ref()),
615 ),
616 ChangeKind::Modified => (
617 table_value(change.new_value.as_ref()),
618 table_value(change.old_value.as_ref()),
619 ),
620 };
621
622 format!(
623 "| {} | {} | {} |\n",
624 escape_md(&change.path),
625 escape_md(&new_str),
626 escape_md(&old_str)
627 )
628}
629
630fn escape_md(s: &str) -> String {
632 s.replace('|', "\\|")
633}
634
635#[cfg(test)]
636mod markdown_tests {
637 use super::*;
638 use serde_json::json;
639
640 fn labels() -> SideLabels {
641 SideLabels {
642 new_side: "local".to_string(),
643 old_side: "Azure (dev)".to_string(),
644 }
645 }
646
647 #[test]
648 fn markdown_report_renders_table() {
649 let d = crate::semantic::diff(
650 &json!({"name": "i", "a": 1}),
651 &json!({"name": "i", "a": 2, "b": true}),
652 "name",
653 );
654 let md = format_markdown(&[("indexes/i".to_string(), d)], &labels());
655 assert!(md.contains("### `indexes/i`"));
656 assert!(md.contains("| field | local | Azure (dev) |"));
657 assert!(md.contains("| b | true | (absent) |"));
658 assert!(md.contains("| a | 2 | 1 |"));
659 assert!(!md.contains(" was "));
660 }
661
662 #[test]
663 fn markdown_report_clean() {
664 let d = crate::semantic::diff(&json!({"a": 1}), &json!({"a": 1}), "name");
665 assert_eq!(
666 format_markdown(&[("x".into(), d)], &labels()),
667 "✅ No differences.\n"
668 );
669 }
670
671 #[test]
672 fn markdown_is_a_table_with_side_columns() {
673 let result = crate::semantic::diff(
674 &json!({"name": "a", "model": "x"}),
675 &json!({"name": "a", "model": "y"}),
676 "name",
677 );
678 let labels = SideLabels {
679 new_side: "local".into(),
680 old_side: "Azure (dev)".into(),
681 };
682 let out = format_markdown(&[("p/agents/a".to_string(), result)], &labels);
683 assert!(
684 out.contains("| field |") || out.contains("| Field |"),
685 "{out}"
686 );
687 assert!(
688 out.contains("| local |") || out.contains("local |"),
689 "{out}"
690 );
691 assert!(!out.contains(" was "), "{out}");
692 }
693
694 #[test]
695 fn long_multibyte_string_preview_does_not_panic() {
696 let long = "å".repeat(600);
699 let out = format_value_preview(Some(&Value::String(long)));
700 assert!(out.ends_with("(600 chars)"), "{out}");
701 assert!(out.contains("..."));
702 }
703}