Skip to main content

claw_branch/diff/
formatter.rs

1//! Human-readable and JSON diff formatting utilities.
2
3use std::collections::HashMap;
4
5use crate::{
6    error::BranchResult,
7    types::{DiffKind, DiffResult, DiffStats, FieldDiff},
8};
9
10/// High-level summary of a diff run, aggregated across entity types.
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12pub struct DiffSummary {
13    /// Number of entities added in branch B.
14    pub added: u32,
15    /// Number of entities removed from branch A.
16    pub removed: u32,
17    /// Number of entities modified between branches.
18    pub modified: u32,
19    /// Number of entities identical across both branches.
20    pub unchanged: u32,
21    /// Aggregate divergence score in `[0.0, 1.0]`.
22    pub divergence_score: f64,
23    /// True when all entities are identical.
24    pub is_identical: bool,
25    /// Per-entity-type breakdown of diff stats.
26    pub entity_type_breakdown: HashMap<String, DiffStats>,
27}
28
29/// Formats a diff result as a human-readable multi-line string with `[+]`, `[-]`, and `[~]` tags.
30pub fn format_diff_human(diff: &DiffResult) -> String {
31    let mut out = String::new();
32    out.push_str(&format!(
33        "diff {} → {}\n",
34        diff.branch_a_id, diff.branch_b_id
35    ));
36    out.push_str(&format!(
37        "  compared_at : {}\n",
38        diff.compared_at.format("%Y-%m-%d %H:%M:%S UTC")
39    ));
40    out.push_str(&format!("  divergence  : {:.4}\n", diff.divergence_score));
41    out.push_str(&format!(
42        "  stats       : +{} -{}  ~{}  ={}\n\n",
43        diff.stats.added, diff.stats.removed, diff.stats.modified, diff.stats.unchanged
44    ));
45
46    for ed in &diff.entity_diffs {
47        if matches!(ed.diff_kind, DiffKind::Unchanged) {
48            continue;
49        }
50        let tag = match ed.diff_kind {
51            DiffKind::Added => "[+]",
52            DiffKind::Removed => "[-]",
53            DiffKind::Modified => "[~]",
54            DiffKind::Unchanged => "[=]",
55        };
56        out.push_str(&format!("{tag} {} ({:?})\n", ed.entity_id, ed.entity_type));
57        for fd in &ed.field_diffs {
58            out.push_str(&format!("    {}\n", format_field_diff(fd)));
59        }
60    }
61    out
62}
63
64/// Serializes a diff result as a structured JSON value.
65pub fn format_diff_json(diff: &DiffResult) -> BranchResult<serde_json::Value> {
66    Ok(serde_json::to_value(diff)?)
67}
68
69/// Formats a single field-level diff as `field: before → after`.
70pub fn format_field_diff(fd: &FieldDiff) -> String {
71    format!("{}: {} → {}", fd.field, fd.before, fd.after)
72}
73
74/// Computes an aggregate [`DiffSummary`] from a diff result.
75pub fn summarise_diff(diff: &DiffResult) -> DiffSummary {
76    let mut breakdown: HashMap<String, DiffStats> = HashMap::new();
77
78    for ed in &diff.entity_diffs {
79        let key = format!("{:?}", ed.entity_type);
80        let entry = breakdown.entry(key).or_default();
81        entry.total_entities += 1;
82        match ed.diff_kind {
83            DiffKind::Added => entry.added += 1,
84            DiffKind::Removed => entry.removed += 1,
85            DiffKind::Modified => entry.modified += 1,
86            DiffKind::Unchanged => entry.unchanged += 1,
87        }
88    }
89
90    let is_identical = diff.stats.added == 0 && diff.stats.removed == 0 && diff.stats.modified == 0;
91
92    DiffSummary {
93        added: diff.stats.added,
94        removed: diff.stats.removed,
95        modified: diff.stats.modified,
96        unchanged: diff.stats.unchanged,
97        divergence_score: diff.divergence_score,
98        is_identical,
99        entity_type_breakdown: breakdown,
100    }
101}
102
103/// Formats a diff result as a compact text summary (legacy helper).
104pub fn format_text(diff: &DiffResult) -> String {
105    format!(
106        "diff {} → {}: {} entities, divergence {:.3}",
107        diff.branch_a_id, diff.branch_b_id, diff.stats.total_entities, diff.divergence_score
108    )
109}