Skip to main content

sheets_diff/output/
text.rs

1//! Human-readable text output renderer (RFC-013).
2//!
3//! All functions here are pure formatters over `WorkbookDiff`. They do not
4//! perform comparison logic and must never write to stdout/stderr directly.
5
6use std::fmt::Write as FmtWrite;
7
8use crate::model::{CellDiff, Diagnostic, Severity, SheetChange, SheetDiff, WorkbookDiff};
9
10// ---------------------------------------------------------------------------
11// Summary report
12// ---------------------------------------------------------------------------
13
14/// Render a compact human-readable summary of a `WorkbookDiff`.
15pub fn render_summary(diff: &WorkbookDiff) -> String {
16    let mut out = String::new();
17    let s = &diff.summary;
18
19    let old_name = diff.old.source.display_name.as_deref().unwrap_or("old");
20    let new_name = diff.new.source.display_name.as_deref().unwrap_or("new");
21    writeln!(out, "sheets-diff: {old_name}  →  {new_name}").unwrap();
22    writeln!(
23        out,
24        "  sheets : {} added, {} removed, {} renamed, {} changed",
25        s.sheets_added, s.sheets_removed, s.sheets_renamed, s.sheets_changed
26    )
27    .unwrap();
28    writeln!(
29        out,
30        "  cells  : {} changed ({} value, {} formula)",
31        s.cells_changed, s.values_changed, s.formulas_changed
32    )
33    .unwrap();
34
35    if s.diagnostics.errors + s.diagnostics.warnings > 0 {
36        writeln!(
37            out,
38            "  diagnostics: {} error(s), {} warning(s)",
39            s.diagnostics.errors, s.diagnostics.warnings
40        )
41        .unwrap();
42    }
43
44    for sd in &diff.sheets {
45        write_sheet_line(&mut out, sd);
46    }
47
48    out
49}
50
51fn write_sheet_line(out: &mut String, sd: &SheetDiff) {
52    let label = sheet_label(sd);
53    let tag = match &sd.change {
54        SheetChange::Added => " [added]",
55        SheetChange::Removed => " [removed]",
56        SheetChange::Renamed { .. } => " [renamed]",
57        SheetChange::RenamedAndMoved { .. } => " [renamed+moved]",
58        SheetChange::Moved => " [moved]",
59        SheetChange::Unchanged => return,
60        SheetChange::Modified => "",
61    };
62    let cells = sd.summary.cells_changed;
63    if cells > 0 || !tag.is_empty() {
64        writeln!(out, "  sheet '{label}'{tag}: {cells} cell(s) changed").unwrap();
65    }
66}
67
68// ---------------------------------------------------------------------------
69// Unified-style diff
70// ---------------------------------------------------------------------------
71
72/// Render a unified-style diff showing per-cell old/new values.
73pub fn render_unified(diff: &WorkbookDiff) -> String {
74    let mut out = String::new();
75
76    let old_name = diff.old.source.display_name.as_deref().unwrap_or("old");
77    let new_name = diff.new.source.display_name.as_deref().unwrap_or("new");
78    writeln!(out, "--- {old_name}").unwrap();
79    writeln!(out, "+++ {new_name}").unwrap();
80
81    for sd in &diff.sheets {
82        let has_cell_changes = !sd.cell_diffs.is_empty();
83        let is_structural =
84            matches!(sd.change, SheetChange::Added | SheetChange::Removed);
85
86        if !has_cell_changes && !is_structural {
87            continue;
88        }
89
90        let label = sheet_label(sd);
91        writeln!(out, "@@ sheet: {label} @@").unwrap();
92
93        match &sd.change {
94            SheetChange::Added => {
95                writeln!(out, "+[sheet added]").unwrap();
96            }
97            SheetChange::Removed => {
98                writeln!(out, "-[sheet removed]").unwrap();
99            }
100            SheetChange::Renamed { .. } | SheetChange::RenamedAndMoved { .. } => {
101                let old_n = sd.old_sheet.as_ref().map(|s| s.name.as_str()).unwrap_or("?");
102                let new_n = sd.new_sheet.as_ref().map(|s| s.name.as_str()).unwrap_or("?");
103                writeln!(out, " [renamed: '{old_n}' → '{new_n}']").unwrap();
104            }
105            _ => {}
106        }
107
108        for cd in &sd.cell_diffs {
109            write_cell_diff(&mut out, cd);
110        }
111    }
112
113    write_diagnostics_section(&mut out, &diff.diagnostics);
114    out
115}
116
117fn write_cell_diff(out: &mut String, cd: &CellDiff) {
118    let addr = &cd.address.a1;
119    if let Some(vc) = &cd.value {
120        writeln!(out, "-{addr}\t{}", vc.old.display_string()).unwrap();
121        writeln!(out, "+{addr}\t{}", vc.new.display_string()).unwrap();
122    }
123    if let Some(fc) = &cd.formula {
124        let old_f = fc.old.as_ref().map(|t| t.raw.as_str()).unwrap_or("");
125        let new_f = fc.new.as_ref().map(|t| t.raw.as_str()).unwrap_or("");
126        if !old_f.is_empty() || fc.old.is_none() && fc.new.is_some() {
127            // formula removed or changed
128            writeln!(out, "-{addr}~\t{old_f}").unwrap();
129        }
130        if !new_f.is_empty() || fc.new.is_none() && fc.old.is_some() {
131            writeln!(out, "+{addr}~\t{new_f}").unwrap();
132        }
133    }
134}
135
136fn write_diagnostics_section(out: &mut String, diagnostics: &[Diagnostic]) {
137    let shown: Vec<_> = diagnostics
138        .iter()
139        .filter(|d| d.severity >= Severity::Warning)
140        .collect();
141    if shown.is_empty() {
142        return;
143    }
144    writeln!(out, "\n# Diagnostics").unwrap();
145    for d in shown {
146        let prefix = match d.severity {
147            Severity::Error => "ERROR",
148            Severity::Warning => "WARN",
149            Severity::Info => "INFO",
150        };
151        writeln!(out, "  [{prefix}] {} — {}", d.kind.code(), d.message).unwrap();
152    }
153}
154
155// ---------------------------------------------------------------------------
156// Helpers
157// ---------------------------------------------------------------------------
158
159fn sheet_label(sd: &SheetDiff) -> String {
160    sd.new_sheet
161        .as_ref()
162        .or(sd.old_sheet.as_ref())
163        .map(|s| s.name.clone())
164        .unwrap_or_else(|| "?".into())
165}