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}
284
285pub 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}