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 = matches!(sd.change, SheetChange::Added | SheetChange::Removed);
84
85        if !has_cell_changes && !is_structural {
86            continue;
87        }
88
89        let label = sheet_label(sd);
90        writeln!(out, "@@ sheet: {label} @@").unwrap();
91
92        match &sd.change {
93            SheetChange::Added => {
94                writeln!(out, "+[sheet added]").unwrap();
95            }
96            SheetChange::Removed => {
97                writeln!(out, "-[sheet removed]").unwrap();
98            }
99            SheetChange::Renamed { .. } | SheetChange::RenamedAndMoved { .. } => {
100                let old_n = sd
101                    .old_sheet
102                    .as_ref()
103                    .map(|s| s.name.as_str())
104                    .unwrap_or("?");
105                let new_n = sd
106                    .new_sheet
107                    .as_ref()
108                    .map(|s| s.name.as_str())
109                    .unwrap_or("?");
110                writeln!(out, " [renamed: '{old_n}' → '{new_n}']").unwrap();
111            }
112            _ => {}
113        }
114
115        for cd in &sd.cell_diffs {
116            write_cell_diff(&mut out, cd);
117        }
118    }
119
120    write_diagnostics_section(&mut out, &diff.diagnostics);
121    out
122}
123
124fn write_cell_diff(out: &mut String, cd: &CellDiff) {
125    let addr = &cd.address.a1;
126    if let Some(vc) = &cd.value {
127        writeln!(out, "-{addr}\t{}", vc.old.display_string()).unwrap();
128        writeln!(out, "+{addr}\t{}", vc.new.display_string()).unwrap();
129    }
130    if let Some(fc) = &cd.formula {
131        let old_f = fc.old.as_ref().map(|t| t.raw.as_str()).unwrap_or("");
132        let new_f = fc.new.as_ref().map(|t| t.raw.as_str()).unwrap_or("");
133        if !old_f.is_empty() || fc.old.is_none() && fc.new.is_some() {
134            // formula removed or changed
135            writeln!(out, "-{addr}~\t{old_f}").unwrap();
136        }
137        if !new_f.is_empty() || fc.new.is_none() && fc.old.is_some() {
138            writeln!(out, "+{addr}~\t{new_f}").unwrap();
139        }
140    }
141}
142
143fn write_diagnostics_section(out: &mut String, diagnostics: &[Diagnostic]) {
144    let shown: Vec<_> = diagnostics
145        .iter()
146        .filter(|d| d.severity >= Severity::Warning)
147        .collect();
148    if shown.is_empty() {
149        return;
150    }
151    writeln!(out, "\n# Diagnostics").unwrap();
152    for d in shown {
153        let prefix = match d.severity {
154            Severity::Error => "ERROR",
155            Severity::Warning => "WARN",
156            Severity::Info => "INFO",
157        };
158        writeln!(out, "  [{prefix}] {} — {}", d.kind.code(), d.message).unwrap();
159    }
160}
161
162// ---------------------------------------------------------------------------
163// Helpers
164// ---------------------------------------------------------------------------
165
166fn sheet_label(sd: &SheetDiff) -> String {
167    sd.new_sheet
168        .as_ref()
169        .or(sd.old_sheet.as_ref())
170        .map(|s| s.name.clone())
171        .unwrap_or_else(|| "?".into())
172}