Skip to main content

agm_core/diff/
render.rs

1//! Rendering a `DiffReport` to text, JSON, or Markdown.
2
3use super::{ChangeKind, ChangeSeverity, DiffReport};
4use crate::diff::fields::FieldValueSnapshot;
5
6/// Output format for diff reports.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum DiffFormat {
9    Text,
10    Json,
11    Markdown,
12}
13
14/// Renders a `DiffReport` in the requested format.
15#[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
24// ---------------------------------------------------------------------------
25// JSON renderer
26// ---------------------------------------------------------------------------
27
28fn render_json(report: &DiffReport) -> String {
29    serde_json::to_string_pretty(report).unwrap_or_default()
30}
31
32// ---------------------------------------------------------------------------
33// Text renderer
34// ---------------------------------------------------------------------------
35
36fn render_text(report: &DiffReport) -> String {
37    let mut out = String::new();
38
39    out.push_str("=== AGM Semantic Diff ===\n");
40
41    // Header changes
42    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    // Added nodes
74    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    // Removed nodes
86    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    // Modified nodes
98    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    // Summary
155    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
174// ---------------------------------------------------------------------------
175// Markdown renderer
176// ---------------------------------------------------------------------------
177
178fn render_markdown(report: &DiffReport) -> String {
179    let mut out = String::new();
180
181    out.push_str("# AGM Semantic Diff\n");
182
183    // Summary table
184    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    // Header changes table
215    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    // Added nodes
234    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    // Removed nodes
242    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    // Modified nodes
250    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
279// ---------------------------------------------------------------------------
280// Helpers
281// ---------------------------------------------------------------------------
282
283fn 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// ---------------------------------------------------------------------------
314// Tests
315// ---------------------------------------------------------------------------
316
317#[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}