Skip to main content

hoist_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    match change.kind {
28        ChangeKind::Added => {
29            let value_str = format_value_preview(change.new_value.as_ref());
30            format!("  + {}: {}\n", change.path, value_str)
31        }
32        ChangeKind::Removed => {
33            let value_str = format_value_preview(change.old_value.as_ref());
34            format!("  - {}: {}\n", change.path, value_str)
35        }
36        ChangeKind::Modified => {
37            let old_str = format_value_preview(change.old_value.as_ref());
38            let new_str = format_value_preview(change.new_value.as_ref());
39            format!("  ~ {}: {} -> {}\n", change.path, old_str, new_str)
40        }
41    }
42}
43
44fn format_value_preview(value: Option<&Value>) -> String {
45    match value {
46        None => "(none)".to_string(),
47        Some(Value::Null) => "null".to_string(),
48        Some(Value::Bool(b)) => b.to_string(),
49        Some(Value::Number(n)) => n.to_string(),
50        Some(Value::String(s)) => {
51            if s.len() > 50 {
52                format!("\"{}...\"", &s[..47])
53            } else {
54                format!("\"{}\"", s)
55            }
56        }
57        Some(Value::Array(arr)) => format!("[{} items]", arr.len()),
58        Some(Value::Object(obj)) => format!("{{...}} ({} keys)", obj.len()),
59    }
60}
61
62/// Format diff result as JSON
63pub fn format_json(result: &DiffResult) -> String {
64    serde_json::to_string_pretty(result).unwrap_or_else(|_| "{}".to_string())
65}
66
67/// Format a full diff report for multiple resources
68pub fn format_report(diffs: &[(String, DiffResult)], format: OutputFormat) -> String {
69    match format {
70        OutputFormat::Text => format_report_text(diffs),
71        OutputFormat::Json => format_report_json(diffs),
72    }
73}
74
75/// Output format options
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum OutputFormat {
78    Text,
79    Json,
80}
81
82fn format_report_text(diffs: &[(String, DiffResult)]) -> String {
83    let mut output = String::new();
84
85    let (changed, unchanged): (Vec<_>, Vec<_>) = diffs.iter().partition(|(_, r)| !r.is_equal);
86
87    if changed.is_empty() {
88        output.push_str("No changes detected.\n");
89        return output;
90    }
91
92    output.push_str(&format!(
93        "Found {} resource(s) with changes:\n\n",
94        changed.len()
95    ));
96
97    for (name, result) in &changed {
98        output.push_str(&format_text(result, name));
99        output.push('\n');
100    }
101
102    if !unchanged.is_empty() {
103        output.push_str(&format!("{} resource(s) unchanged.\n", unchanged.len()));
104    }
105
106    output
107}
108
109fn format_report_json(diffs: &[(String, DiffResult)]) -> String {
110    let report: Vec<_> = diffs
111        .iter()
112        .map(|(name, result)| {
113            serde_json::json!({
114                "resource": name,
115                "changed": !result.is_equal,
116                "changes": result.changes
117            })
118        })
119        .collect();
120
121    serde_json::to_string_pretty(&report).unwrap_or_else(|_| "[]".to_string())
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::semantic::diff;
128    use serde_json::json;
129
130    #[test]
131    fn test_format_text_no_changes() {
132        let result = DiffResult {
133            is_equal: true,
134            changes: vec![],
135        };
136
137        let output = format_text(&result, "test-index");
138        assert!(output.contains("no changes"));
139    }
140
141    #[test]
142    fn test_format_text_with_changes() {
143        let old = json!({"name": "test", "value": 1});
144        let new = json!({"name": "test", "value": 2});
145
146        let result = diff(&old, &new, "name");
147        let output = format_text(&result, "test-index");
148
149        assert!(output.contains("1 change"));
150        assert!(output.contains("value"));
151        assert!(output.contains("~")); // modified indicator
152    }
153
154    #[test]
155    fn test_format_json() {
156        let result = DiffResult {
157            is_equal: false,
158            changes: vec![Change {
159                path: "name".to_string(),
160                kind: ChangeKind::Modified,
161                old_value: Some(json!("old")),
162                new_value: Some(json!("new")),
163            }],
164        };
165
166        let output = format_json(&result);
167        assert!(output.contains("modified"));
168        assert!(output.contains("name"));
169    }
170}