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/// Format diff result as human-readable text
7pub fn format_text(result: &DiffResult, resource_name: &str) -> String {
8    if result.is_equal {
9        return format!("{}: no changes\n", resource_name);
10    }
11
12    let mut output = String::new();
13    output.push_str(&format!(
14        "{}: {} change(s)\n",
15        resource_name,
16        result.changes.len()
17    ));
18
19    for change in &result.changes {
20        output.push_str(&format_change_text(change));
21    }
22
23    output
24}
25
26fn format_change_text(change: &Change) -> String {
27    // If a higher layer set a description, use it directly
28    if let Some(desc) = &change.description {
29        return format!("  {}\n", desc);
30    }
31
32    match change.kind {
33        ChangeKind::Added => {
34            let value_str = format_value_preview(change.new_value.as_ref());
35            format!("  + {}: {}\n", change.path, value_str)
36        }
37        ChangeKind::Removed => {
38            let value_str = format_value_preview(change.old_value.as_ref());
39            format!("  - {}: {}\n", change.path, value_str)
40        }
41        ChangeKind::Modified => {
42            let old_str = format_value_preview(change.old_value.as_ref());
43            let new_str = format_value_preview(change.new_value.as_ref());
44            format!("  ~ {}: was {}, now {}\n", change.path, old_str, new_str)
45        }
46    }
47}
48
49/// Create a human-readable preview of a JSON value.
50///
51/// Used by the base diff formatter. Higher-level formatters (describe.rs)
52/// have their own value formatting with resource-aware context.
53pub fn format_value_preview(value: Option<&Value>) -> String {
54    match value {
55        None => "(none)".to_string(),
56        Some(Value::Null) => "null".to_string(),
57        Some(Value::Bool(b)) => b.to_string(),
58        Some(Value::Number(n)) => n.to_string(),
59        Some(Value::String(s)) => {
60            if s.len() > 500 {
61                format!("\"{}...\" ({} chars)", &s[..497], s.len())
62            } else {
63                format!("\"{}\"", s)
64            }
65        }
66        Some(Value::Array(arr)) => {
67            if arr.is_empty() {
68                "[]".to_string()
69            } else if arr.len() <= 3 && arr.iter().all(is_simple_value) {
70                // Show actual values for small arrays of simple items
71                let items: Vec<String> =
72                    arr.iter().map(|v| format_value_preview(Some(v))).collect();
73                format!("[{}]", items.join(", "))
74            } else {
75                format!("[{} items]", arr.len())
76            }
77        }
78        Some(Value::Object(obj)) => format!("{{...}} ({} keys)", obj.len()),
79    }
80}
81
82/// Check if a value is a simple scalar (not array or object).
83fn is_simple_value(value: &Value) -> bool {
84    matches!(
85        value,
86        Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null
87    )
88}
89
90/// Format diff result as JSON
91pub fn format_json(result: &DiffResult) -> String {
92    serde_json::to_string_pretty(result).unwrap_or_else(|_| "{}".to_string())
93}
94
95/// Format a full diff report for multiple resources
96pub fn format_report(diffs: &[(String, DiffResult)], format: OutputFormat) -> String {
97    match format {
98        OutputFormat::Text => format_report_text(diffs),
99        OutputFormat::Json => format_report_json(diffs),
100    }
101}
102
103/// Output format options
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum OutputFormat {
106    Text,
107    Json,
108}
109
110fn format_report_text(diffs: &[(String, DiffResult)]) -> String {
111    let mut output = String::new();
112
113    let (changed, unchanged): (Vec<_>, Vec<_>) = diffs.iter().partition(|(_, r)| !r.is_equal);
114
115    if changed.is_empty() {
116        output.push_str("No changes detected.\n");
117        return output;
118    }
119
120    output.push_str(&format!(
121        "Found {} resource(s) with changes:\n\n",
122        changed.len()
123    ));
124
125    for (name, result) in &changed {
126        output.push_str(&format_text(result, name));
127        output.push('\n');
128    }
129
130    if !unchanged.is_empty() {
131        output.push_str(&format!("{} resource(s) unchanged.\n", unchanged.len()));
132    }
133
134    output
135}
136
137fn format_report_json(diffs: &[(String, DiffResult)]) -> String {
138    let report: Vec<_> = diffs
139        .iter()
140        .map(|(name, result)| {
141            serde_json::json!({
142                "resource": name,
143                "changed": !result.is_equal,
144                "changes": result.changes
145            })
146        })
147        .collect();
148
149    serde_json::to_string_pretty(&report).unwrap_or_else(|_| "[]".to_string())
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::semantic::diff;
156    use serde_json::json;
157
158    #[test]
159    fn test_format_text_no_changes() {
160        let result = DiffResult {
161            is_equal: true,
162            changes: vec![],
163        };
164
165        let output = format_text(&result, "test-index");
166        assert!(output.contains("no changes"));
167    }
168
169    #[test]
170    fn test_format_text_with_changes() {
171        let old = json!({"name": "test", "value": 1});
172        let new = json!({"name": "test", "value": 2});
173
174        let result = diff(&old, &new, "name");
175        let output = format_text(&result, "test-index");
176
177        assert!(output.contains("1 change"));
178        assert!(output.contains("value"));
179        assert!(output.contains("~")); // modified indicator
180        assert!(output.contains("was"));
181        assert!(output.contains("now"));
182    }
183
184    #[test]
185    fn test_format_text_uses_description_when_set() {
186        let result = DiffResult {
187            is_equal: false,
188            changes: vec![Change {
189                path: "description".to_string(),
190                kind: ChangeKind::Modified,
191                old_value: Some(json!("old")),
192                new_value: Some(json!("new")),
193                description: Some(
194                    "The description differs: locally has \"old\" while on the server has \"new\""
195                        .to_string(),
196                ),
197            }],
198        };
199
200        let output = format_text(&result, "test-index");
201        assert!(output.contains("The description differs"));
202        assert!(!output.contains("~")); // Should not use generic format
203    }
204
205    #[test]
206    fn test_format_json() {
207        let result = DiffResult {
208            is_equal: false,
209            changes: vec![Change {
210                path: "name".to_string(),
211                kind: ChangeKind::Modified,
212                old_value: Some(json!("old")),
213                new_value: Some(json!("new")),
214                description: None,
215            }],
216        };
217
218        let output = format_json(&result);
219        assert!(output.contains("modified"));
220        assert!(output.contains("name"));
221    }
222
223    #[test]
224    fn test_format_value_preview_long_string() {
225        let long = "a".repeat(600);
226        let preview = format_value_preview(Some(&json!(long)));
227        assert!(preview.contains("..."));
228        assert!(preview.contains("600 chars"));
229    }
230
231    #[test]
232    fn test_format_value_preview_medium_string_not_truncated() {
233        let medium = "a".repeat(400);
234        let preview = format_value_preview(Some(&json!(medium)));
235        assert!(!preview.contains("..."));
236        assert_eq!(preview, format!("\"{}\"", medium));
237    }
238
239    #[test]
240    fn test_format_value_preview_small_array() {
241        let preview = format_value_preview(Some(&json!([1, 2, 3])));
242        assert_eq!(preview, "[1, 2, 3]");
243    }
244
245    #[test]
246    fn test_format_value_preview_small_string_array() {
247        let preview = format_value_preview(Some(&json!(["a", "b"])));
248        assert_eq!(preview, "[\"a\", \"b\"]");
249    }
250
251    #[test]
252    fn test_format_value_preview_large_array() {
253        let preview = format_value_preview(Some(&json!([1, 2, 3, 4])));
254        assert_eq!(preview, "[4 items]");
255    }
256
257    #[test]
258    fn test_format_value_preview_empty_array() {
259        let preview = format_value_preview(Some(&json!([])));
260        assert_eq!(preview, "[]");
261    }
262
263    #[test]
264    fn test_format_value_preview_complex_array_items() {
265        let preview = format_value_preview(Some(&json!([{"a": 1}])));
266        assert_eq!(preview, "[1 items]");
267    }
268
269    #[test]
270    fn test_modified_uses_english_phrasing() {
271        let change = Change {
272            path: "description".to_string(),
273            kind: ChangeKind::Modified,
274            old_value: Some(json!("old text")),
275            new_value: Some(json!("new text")),
276            description: None,
277        };
278        let output = format_change_text(&change);
279        assert!(output.contains("was"));
280        assert!(output.contains("now"));
281        assert!(!output.contains("->"));
282    }
283}
284
285/// Format a full diff report as Markdown (for PR comments).
286///
287/// Layout: `### <resource>` per changed resource with a fenced +/~/- hunk list.
288pub fn format_markdown(diffs: &[(String, DiffResult)]) -> String {
289    let changed: Vec<_> = diffs.iter().filter(|(_, d)| !d.is_equal).collect();
290    if changed.is_empty() {
291        return "✅ No differences.\n".to_string();
292    }
293    let mut out = String::new();
294    out.push_str(&format!(
295        "## rigg diff — {} resource(s) differ\n\n",
296        changed.len()
297    ));
298    for (name, result) in changed {
299        out.push_str(&format!(
300            "### `{}` — {} change(s)\n\n",
301            name,
302            result.changes.len()
303        ));
304        out.push_str("```diff\n");
305        for change in &result.changes {
306            match change.kind {
307                ChangeKind::Added => out.push_str(&format!(
308                    "+ {}: {}\n",
309                    change.path,
310                    format_value_preview(change.new_value.as_ref())
311                )),
312                ChangeKind::Removed => out.push_str(&format!(
313                    "- {}: {}\n",
314                    change.path,
315                    format_value_preview(change.old_value.as_ref())
316                )),
317                ChangeKind::Modified => out.push_str(&format!(
318                    "! {}: {} -> {}\n",
319                    change.path,
320                    format_value_preview(change.old_value.as_ref()),
321                    format_value_preview(change.new_value.as_ref())
322                )),
323            }
324        }
325        out.push_str("```\n\n");
326    }
327    out
328}
329
330#[cfg(test)]
331mod markdown_tests {
332    use super::*;
333    use serde_json::json;
334
335    #[test]
336    fn markdown_report_renders_hunks() {
337        let d = crate::semantic::diff(
338            &json!({"name": "i", "a": 1}),
339            &json!({"name": "i", "a": 2, "b": true}),
340            "name",
341        );
342        let md = format_markdown(&[("indexes/i".to_string(), d)]);
343        assert!(md.contains("### `indexes/i`"));
344        assert!(md.contains("```diff"));
345        assert!(md.contains("+ b: true"));
346        assert!(md.contains("! a: 1 -> 2"));
347    }
348
349    #[test]
350    fn markdown_report_clean() {
351        let d = crate::semantic::diff(&json!({"a": 1}), &json!({"a": 1}), "name");
352        assert_eq!(format_markdown(&[("x".into(), d)]), "✅ No differences.\n");
353    }
354}