1use crate::semantic::{Change, ChangeKind, DiffResult};
4use serde_json::Value;
5
6pub 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 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
49pub 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 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
82fn is_simple_value(value: &Value) -> bool {
84 matches!(
85 value,
86 Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null
87 )
88}
89
90pub fn format_json(result: &DiffResult) -> String {
92 serde_json::to_string_pretty(result).unwrap_or_else(|_| "{}".to_string())
93}
94
95pub 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#[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("~")); 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("~")); }
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}