1use super::{ChangeKind, ChangeSeverity, DiffReport};
4use crate::diff::fields::FieldValueSnapshot;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum DiffFormat {
9 Text,
10 Json,
11 Markdown,
12}
13
14#[must_use]
16pub fn render_diff(report: &DiffReport, format: DiffFormat) -> String {
17 match format {
18 DiffFormat::Text => render_text(report),
19 DiffFormat::Json => render_json(report),
20 DiffFormat::Markdown => render_markdown(report),
21 }
22}
23
24fn render_json(report: &DiffReport) -> String {
29 serde_json::to_string_pretty(report).unwrap_or_default()
30}
31
32fn render_text(report: &DiffReport) -> String {
37 let mut out = String::new();
38
39 out.push_str("=== AGM Semantic Diff ===\n");
40
41 if !report.header_changes.is_empty() {
43 out.push('\n');
44 out.push_str("--- Header Changes ---\n");
45 for hc in &report.header_changes {
46 let prefix = change_prefix(hc.kind);
47 let severity_tag = severity_tag(hc.severity);
48 let old = hc.old_value.as_deref().unwrap_or("--");
49 let new = hc.new_value.as_deref().unwrap_or("--");
50 match hc.kind {
51 ChangeKind::Modified => {
52 out.push_str(&format!(
53 " {prefix} {}: {:?} -> {:?} {}\n",
54 hc.field, old, new, severity_tag
55 ));
56 }
57 ChangeKind::Added => {
58 out.push_str(&format!(
59 " {prefix} {}: {:?} {}\n",
60 hc.field, new, severity_tag
61 ));
62 }
63 ChangeKind::Removed => {
64 out.push_str(&format!(
65 " {prefix} {}: {:?} {}\n",
66 hc.field, old, severity_tag
67 ));
68 }
69 }
70 }
71 }
72
73 if !report.added_nodes.is_empty() {
75 out.push('\n');
76 out.push_str(&format!(
77 "--- Nodes Added ({}) ---\n",
78 report.added_nodes.len()
79 ));
80 for id in &report.added_nodes {
81 out.push_str(&format!(" + {id}\n"));
82 }
83 }
84
85 if !report.removed_nodes.is_empty() {
87 out.push('\n');
88 out.push_str(&format!(
89 "--- Nodes Removed ({}) ---\n",
90 report.removed_nodes.len()
91 ));
92 for id in &report.removed_nodes {
93 out.push_str(&format!(" - {id} [BREAKING]\n"));
94 }
95 }
96
97 if !report.modified_nodes.is_empty() {
99 out.push('\n');
100 out.push_str(&format!(
101 "--- Nodes Modified ({}) ---\n",
102 report.modified_nodes.len()
103 ));
104 for nd in &report.modified_nodes {
105 let breaking_count = nd
106 .field_changes
107 .iter()
108 .filter(|fc| fc.severity == ChangeSeverity::Breaking)
109 .count();
110 if breaking_count > 0 {
111 out.push_str(&format!(
112 " ~ {} ({} changes, {} breaking)\n",
113 nd.node_id,
114 nd.field_changes.len(),
115 breaking_count
116 ));
117 } else {
118 out.push_str(&format!(
119 " ~ {} ({} change{})\n",
120 nd.node_id,
121 nd.field_changes.len(),
122 if nd.field_changes.len() == 1 { "" } else { "s" }
123 ));
124 }
125 for fc in &nd.field_changes {
126 let prefix = change_prefix(fc.kind);
127 let severity_tag = severity_tag(fc.severity);
128 let old = snapshot_display(fc.old_value.as_ref());
129 let new = snapshot_display(fc.new_value.as_ref());
130 match fc.kind {
131 ChangeKind::Modified => {
132 out.push_str(&format!(
133 " {prefix} {}: {} -> {} {}\n",
134 fc.field, old, new, severity_tag
135 ));
136 }
137 ChangeKind::Added => {
138 out.push_str(&format!(
139 " {prefix} {}: {} {}\n",
140 fc.field, new, severity_tag
141 ));
142 }
143 ChangeKind::Removed => {
144 out.push_str(&format!(
145 " {prefix} {}: {} {}\n",
146 fc.field, old, severity_tag
147 ));
148 }
149 }
150 }
151 }
152 }
153
154 out.push('\n');
156 out.push_str("--- Summary ---\n");
157 out.push_str(&format!(
158 " Added: {} | Removed: {} | Modified: {} | Unchanged: {}\n",
159 report.summary.nodes_added,
160 report.summary.nodes_removed,
161 report.summary.nodes_modified,
162 report.summary.nodes_unchanged,
163 ));
164 let breaking_str = if report.summary.has_breaking_changes {
165 "YES"
166 } else {
167 "NO"
168 };
169 out.push_str(&format!(" Breaking changes: {breaking_str}\n"));
170
171 out
172}
173
174fn render_markdown(report: &DiffReport) -> String {
179 let mut out = String::new();
180
181 out.push_str("# AGM Semantic Diff\n");
182
183 out.push_str("\n## Summary\n\n");
185 out.push_str("| Metric | Count |\n");
186 out.push_str("|---|---|\n");
187 out.push_str(&format!(
188 "| Nodes added | {} |\n",
189 report.summary.nodes_added
190 ));
191 out.push_str(&format!(
192 "| Nodes removed | {} |\n",
193 report.summary.nodes_removed
194 ));
195 out.push_str(&format!(
196 "| Nodes modified | {} |\n",
197 report.summary.nodes_modified
198 ));
199 out.push_str(&format!(
200 "| Nodes unchanged | {} |\n",
201 report.summary.nodes_unchanged
202 ));
203 out.push_str(&format!(
204 "| Header changes | {} |\n",
205 report.summary.header_changes
206 ));
207 let breaking_val = if report.summary.has_breaking_changes {
208 "**Yes**"
209 } else {
210 "No"
211 };
212 out.push_str(&format!("| **Breaking changes** | {breaking_val} |\n"));
213
214 if !report.header_changes.is_empty() {
216 out.push_str("\n## Header Changes\n\n");
217 out.push_str("| Field | Change | Old | New | Severity |\n");
218 out.push_str("|---|---|---|---|---|\n");
219 for hc in &report.header_changes {
220 let old = hc.old_value.as_deref().unwrap_or("--");
221 let new = hc.new_value.as_deref().unwrap_or("--");
222 out.push_str(&format!(
223 "| {} | {} | {} | {} | {} |\n",
224 hc.field,
225 hc.kind,
226 md_escape(old),
227 md_escape(new),
228 hc.severity
229 ));
230 }
231 }
232
233 if !report.added_nodes.is_empty() {
235 out.push_str("\n## Added Nodes\n\n");
236 for id in &report.added_nodes {
237 out.push_str(&format!("- `{id}`\n"));
238 }
239 }
240
241 if !report.removed_nodes.is_empty() {
243 out.push_str("\n## Removed Nodes\n\n");
244 for id in &report.removed_nodes {
245 out.push_str(&format!("- `{id}` (BREAKING)\n"));
246 }
247 }
248
249 if !report.modified_nodes.is_empty() {
251 out.push_str("\n## Modified Nodes\n");
252 for nd in &report.modified_nodes {
253 out.push_str(&format!("\n### {}\n\n", nd.node_id));
254 out.push_str("| Field | Change | Old | New | Severity |\n");
255 out.push_str("|---|---|---|---|---|\n");
256 for fc in &nd.field_changes {
257 let old = snapshot_display(fc.old_value.as_ref());
258 let new = snapshot_display(fc.new_value.as_ref());
259 let sev = if fc.severity == ChangeSeverity::Breaking {
260 format!("**{}**", fc.severity)
261 } else {
262 fc.severity.to_string()
263 };
264 out.push_str(&format!(
265 "| {} | {} | {} | {} | {} |\n",
266 fc.field,
267 fc.kind,
268 md_escape(&old),
269 md_escape(&new),
270 sev
271 ));
272 }
273 }
274 }
275
276 out
277}
278
279fn change_prefix(kind: ChangeKind) -> char {
284 match kind {
285 ChangeKind::Added => '+',
286 ChangeKind::Removed => '-',
287 ChangeKind::Modified => '~',
288 }
289}
290
291fn severity_tag(severity: ChangeSeverity) -> &'static str {
292 match severity {
293 ChangeSeverity::Breaking => "[BREAKING]",
294 ChangeSeverity::Minor => "(minor)",
295 ChangeSeverity::Info => "(info)",
296 }
297}
298
299fn snapshot_display(snap: Option<&FieldValueSnapshot>) -> String {
300 match snap {
301 None => "--".to_owned(),
302 Some(FieldValueSnapshot::Scalar(s)) => s.clone(),
303 Some(FieldValueSnapshot::List(v)) => format!("[{}]", v.join(", ")),
304 Some(FieldValueSnapshot::Block(b)) => b.clone(),
305 Some(FieldValueSnapshot::Complex(c)) => c.clone(),
306 }
307}
308
309fn md_escape(s: &str) -> String {
310 s.replace('|', "\\|")
311}
312
313#[cfg(test)]
318mod tests {
319 use crate::diff::{
320 ChangeKind, ChangeSeverity, DiffReport, DiffSummary, fields::FieldChange,
321 fields::FieldValueSnapshot, header::HeaderChange, node::NodeDiff,
322 };
323
324 use super::*;
325
326 fn empty_summary() -> DiffSummary {
327 DiffSummary {
328 nodes_added: 0,
329 nodes_removed: 0,
330 nodes_modified: 0,
331 nodes_unchanged: 0,
332 header_changes: 0,
333 total_field_changes: 0,
334 has_breaking_changes: false,
335 }
336 }
337
338 fn empty_report() -> DiffReport {
339 DiffReport {
340 header_changes: vec![],
341 added_nodes: vec![],
342 removed_nodes: vec![],
343 modified_nodes: vec![],
344 summary: empty_summary(),
345 }
346 }
347
348 fn full_report() -> DiffReport {
349 DiffReport {
350 header_changes: vec![HeaderChange {
351 field: "version".to_owned(),
352 kind: ChangeKind::Modified,
353 severity: ChangeSeverity::Info,
354 old_value: Some("0.1.0".to_owned()),
355 new_value: Some("0.2.0".to_owned()),
356 }],
357 added_nodes: vec!["auth.mfa".to_owned()],
358 removed_nodes: vec!["auth.legacy".to_owned()],
359 modified_nodes: vec![
360 NodeDiff {
361 node_id: "auth.login".to_owned(),
362 field_changes: vec![
363 FieldChange {
364 field: "type".to_owned(),
365 kind: ChangeKind::Modified,
366 severity: ChangeSeverity::Breaking,
367 old_value: Some(FieldValueSnapshot::Scalar("workflow".to_owned())),
368 new_value: Some(FieldValueSnapshot::Scalar("rules".to_owned())),
369 },
370 FieldChange {
371 field: "summary".to_owned(),
372 kind: ChangeKind::Modified,
373 severity: ChangeSeverity::Minor,
374 old_value: Some(FieldValueSnapshot::Scalar("old summary".to_owned())),
375 new_value: Some(FieldValueSnapshot::Scalar("new summary".to_owned())),
376 },
377 ],
378 has_breaking_change: true,
379 },
380 NodeDiff {
381 node_id: "auth.session".to_owned(),
382 field_changes: vec![FieldChange {
383 field: "priority".to_owned(),
384 kind: ChangeKind::Added,
385 severity: ChangeSeverity::Info,
386 old_value: None,
387 new_value: Some(FieldValueSnapshot::Scalar("critical".to_owned())),
388 }],
389 has_breaking_change: false,
390 },
391 ],
392 summary: DiffSummary {
393 nodes_added: 1,
394 nodes_removed: 1,
395 nodes_modified: 2,
396 nodes_unchanged: 5,
397 header_changes: 1,
398 total_field_changes: 3,
399 has_breaking_changes: true,
400 },
401 }
402 }
403
404 #[test]
405 fn test_render_text_empty_report() {
406 let report = empty_report();
407 let output = render_diff(&report, DiffFormat::Text);
408 insta::assert_snapshot!(output);
409 }
410
411 #[test]
412 fn test_render_text_full_report() {
413 let report = full_report();
414 let output = render_diff(&report, DiffFormat::Text);
415 insta::assert_snapshot!(output);
416 }
417
418 #[test]
419 fn test_render_text_breaking_only_format() {
420 let report = full_report().breaking_only();
421 let output = render_diff(&report, DiffFormat::Text);
422 insta::assert_snapshot!(output);
423 }
424
425 #[test]
426 fn test_render_json_roundtrip() {
427 let report = full_report();
428 let json = render_diff(&report, DiffFormat::Json);
429 let back: DiffReport = serde_json::from_str(&json).unwrap();
430 assert_eq!(report, back);
431 }
432
433 #[test]
434 fn test_render_markdown_empty_report() {
435 let report = empty_report();
436 let output = render_diff(&report, DiffFormat::Markdown);
437 insta::assert_snapshot!(output);
438 }
439
440 #[test]
441 fn test_render_markdown_full_report() {
442 let report = full_report();
443 let output = render_diff(&report, DiffFormat::Markdown);
444 insta::assert_snapshot!(output);
445 }
446}