Skip to main content

rigg_diff/
output.rs

1//! Diff output formatting
2
3use crate::semantic::{Change, ChangeKind, DiffResult};
4use serde_json::Value;
5
6/// Fixed column width for the field-path column in the text table renderer.
7const FIELD_COL_WIDTH: usize = 40;
8/// Fixed column width for the "new side" value column in the text table renderer.
9const VALUE_COL_WIDTH: usize = 20;
10
11/// Human-readable labels for the two sides of a diff.
12///
13/// The diff engine itself is direction-neutral internally (`old`/`new`), but
14/// callers know what those sides actually *are* — local project files, a
15/// specific Azure environment, or another environment in `--compare-env`
16/// mode. Renderers use these labels instead of temporal language ("was"/
17/// "now") so the output never implies a push- or pull-shaped direction that
18/// isn't actually happening.
19#[derive(Debug, Clone)]
20pub struct SideLabels {
21    /// Label for the diff engine's "new" side (typically local project files,
22    /// or the first-named environment in `--compare-env` mode).
23    pub new_side: String,
24    /// Label for the diff engine's "old" side (typically the resolved Azure
25    /// environment, or the second-named environment in `--compare-env` mode).
26    pub old_side: String,
27}
28
29/// Format diff result as human-readable text (a labeled two-column table).
30pub 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 a higher layer set a description, print it as a full-width row.
59    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        // The path alone is wider than the field column: give it its own
80        // line so it never shoves the value columns out of alignment, then
81        // print the values on the next line, indented to the same column
82        // where a normal row's value would start (2-space row indent + the
83        // field column width + the separator space between field and value).
84        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
104/// Table cells stay scannable: long previews are cut hard. The full value is
105/// always available in the file itself or via `--output json`.
106const 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
117/// Create a human-readable preview of a JSON value.
118///
119/// Used by the base diff formatter. Higher-level formatters (describe.rs)
120/// have their own value formatting with resource-aware context.
121pub 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                // Show actual values for small arrays of simple items
140                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
157/// Check if a value is a simple scalar (not array or object).
158fn is_simple_value(value: &Value) -> bool {
159    matches!(
160        value,
161        Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null
162    )
163}
164
165/// Format diff result as JSON
166pub fn format_json(result: &DiffResult) -> String {
167    serde_json::to_string_pretty(result).unwrap_or_else(|_| "{}".to_string())
168}
169
170/// Format a full diff report for multiple resources
171pub 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/// Output format options
183#[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("~")); // Should not use generic format
288    }
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"}), // old = Azure
387            &json!({"name": "a", "model": "gpt-5.2-chat"}), // new = local
388            "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        // local column before Azure column on the model row
402        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"}}), // old/Azure has it
414            &json!({"name": "a"}),                                  // new/local lacks it
415            "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        // The new-side value must land in the same column as a normal (short-path) row.
499        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        // 200 chars / 400 bytes: stays under format_value_preview's 500-byte cap
517        // (so its own truncation path isn't exercised), but its `"..."`-wrapped
518        // preview is well over TABLE_VALUE_MAX chars, so table_value must cut it.
519        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        // 200 chars / 400 bytes: stays under format_value_preview's 500-byte cap
541        // (so its own truncation path isn't exercised), but its `"..."`-wrapped
542        // preview is well over TABLE_VALUE_MAX chars, so table_value must cut it.
543        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
568/// Format a full diff report as Markdown (for PR comments).
569///
570/// Layout: `### {resource}` per changed resource with a Markdown table whose
571/// columns are `field | {new_side} | {old_side}`.
572pub 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
630/// Escape pipe characters so a value can't break a Markdown table row.
631fn 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        // Regression: byte-slicing at 497 panicked when it split a multi-byte
697        // char (e.g. Swedish text in long descriptions).
698        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}