Skip to main content

sheets_diff/output/
view.rs

1//! Framework-neutral GUI view adapters over `WorkbookDiff` (RFC-029).
2//!
3//! These types borrow from `WorkbookDiff` and allocate display strings only
4//! on demand.  No GUI framework dependency is introduced.
5
6use crate::address::CellAddress;
7use crate::model::{
8    CellChangeKind, CellDiff, SheetChange, Severity, WorkbookDiff,
9};
10
11// ---------------------------------------------------------------------------
12// Filtering
13// ---------------------------------------------------------------------------
14
15/// Controls which change categories are visible in a `DiffView`.
16#[derive(Clone, Debug)]
17pub struct ViewFilter {
18    pub include_values: bool,
19    pub include_formulas: bool,
20    /// Formatting diffs (always false until RFC-022 is implemented).
21    pub include_formatting: bool,
22    pub include_info_diagnostics: bool,
23    /// If `Some`, only include changes from the listed sheet indices (0-based).
24    pub sheets: Option<Vec<usize>>,
25}
26
27impl Default for ViewFilter {
28    fn default() -> Self {
29        Self {
30            include_values: true,
31            include_formulas: true,
32            include_formatting: false,
33            include_info_diagnostics: false,
34            sheets: None,
35        }
36    }
37}
38
39// ---------------------------------------------------------------------------
40// Stable change anchor (for virtualized tables / navigation)
41// ---------------------------------------------------------------------------
42
43/// A stable, deterministic identifier for a single change row.
44///
45/// Ordering matches the canonical `(sheet_index, row, col)` sort.
46#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize))]
48pub struct ChangeAnchor {
49    pub sheet_index: usize,
50    pub row: u32,
51    pub col: u32,
52}
53
54// ---------------------------------------------------------------------------
55// Flat change row (one entry per visible cell change)
56// ---------------------------------------------------------------------------
57
58/// A single row in the flat change list presented to a GUI table.
59pub struct CellChangeRow<'a> {
60    /// Stable anchor for navigation and virtualized table positioning.
61    pub anchor: ChangeAnchor,
62    pub sheet_name: &'a str,
63    pub address: &'a CellAddress,
64    /// Combined change kind derived from sub-fields.
65    pub change_kind: CellChangeKind,
66    /// Display string for the old value (empty if Added).
67    pub old_display: String,
68    /// Display string for the new value (empty if Removed).
69    pub new_display: String,
70    /// Whether a formula also changed on this cell.
71    pub formula_changed: bool,
72    /// Old formula text, if a formula change is present (Q2: borrowed from the
73    /// underlying `CellDiff`, so GUI consumers need not reach into the raw model).
74    pub old_formula: Option<&'a str>,
75    /// New formula text, if a formula change is present.
76    pub new_formula: Option<&'a str>,
77    /// Highest diagnostic severity attached to this cell.
78    pub max_severity: Option<Severity>,
79}
80
81impl<'a> CellChangeRow<'a> {
82    /// Convert this borrowed row into a fully owned [`OwnedCellChangeRow`]
83    /// (Q3: convenience for consumers whose model outlives the `WorkbookDiff`).
84    pub fn to_owned_row(&self) -> OwnedCellChangeRow {
85        OwnedCellChangeRow {
86            anchor: self.anchor.clone(),
87            sheet_name: self.sheet_name.to_owned(),
88            address: self.address.clone(),
89            change_kind: self.change_kind,
90            old_display: self.old_display.clone(),
91            new_display: self.new_display.clone(),
92            formula_changed: self.formula_changed,
93            old_formula: self.old_formula.map(|s| s.to_owned()),
94            new_formula: self.new_formula.map(|s| s.to_owned()),
95            max_severity: self.max_severity,
96        }
97    }
98}
99
100/// Fully owned counterpart to [`CellChangeRow`] (Q3).
101///
102/// All borrowed fields become owned (`String`, `CellAddress`), so the row can
103/// outlive the `WorkbookDiff` it was derived from. Produced by
104/// [`CellChangeRow::to_owned_row`].
105#[derive(Clone, Debug)]
106#[cfg_attr(feature = "serde", derive(serde::Serialize))]
107pub struct OwnedCellChangeRow {
108    pub anchor: ChangeAnchor,
109    pub sheet_name: String,
110    pub address: CellAddress,
111    pub change_kind: CellChangeKind,
112    pub old_display: String,
113    pub new_display: String,
114    pub formula_changed: bool,
115    pub old_formula: Option<String>,
116    pub new_formula: Option<String>,
117    pub max_severity: Option<Severity>,
118}
119
120// ---------------------------------------------------------------------------
121// Sheet summary row
122// ---------------------------------------------------------------------------
123
124/// Summary line for one sheet in the sheet-tree view.
125pub struct SheetSummaryRow<'a> {
126    pub sheet_index: usize,
127    pub name: &'a str,
128    pub change: &'a SheetChange,
129    pub cells_changed: usize,
130    pub has_diagnostics: bool,
131}
132
133// ---------------------------------------------------------------------------
134// DiffView — main adapter
135// ---------------------------------------------------------------------------
136
137/// Borrowed view over a `WorkbookDiff`, providing filtered iteration and
138/// deterministic navigation for GUI applications.
139pub struct DiffView<'a> {
140    pub workbook: &'a WorkbookDiff,
141}
142
143impl<'a> DiffView<'a> {
144    pub fn new(workbook: &'a WorkbookDiff) -> Self {
145        Self { workbook }
146    }
147
148    // ------------------------------------------------------------------
149    // Sheet tree
150    // ------------------------------------------------------------------
151
152    /// Iterate sheet summary rows in workbook display order.
153    pub fn sheets(&self) -> impl Iterator<Item = SheetSummaryRow<'a>> {
154        self.workbook.sheets.iter().enumerate().map(|(i, sd)| {
155            SheetSummaryRow {
156                sheet_index: i,
157                name: sd.new_sheet.as_ref().or(sd.old_sheet.as_ref())
158                    .map(|s| s.name.as_str()).unwrap_or("?"),
159                change: &sd.change,
160                cells_changed: sd.summary.cells_changed,
161                has_diagnostics: !sd.diagnostics.is_empty(),
162            }
163        })
164    }
165
166    // ------------------------------------------------------------------
167    // Flat change list
168    // ------------------------------------------------------------------
169
170    /// Collect all visible cell-change rows into a `Vec`, respecting the filter.
171    ///
172    /// Order is deterministic: sheet order → (row, col).
173    pub fn rows(&'a self, filter: &ViewFilter) -> Vec<CellChangeRow<'a>> {
174        let mut out = Vec::new();
175        for (sheet_idx, sd) in self.workbook.sheets.iter().enumerate() {
176            if let Some(ref allowed) = filter.sheets {
177                if !allowed.contains(&sheet_idx) { continue; }
178            }
179            let sheet_name = sd.new_sheet.as_ref().or(sd.old_sheet.as_ref())
180                .map(|s| s.name.as_str()).unwrap_or("?");
181            for cd in &sd.cell_diffs {
182                if let Some(row) = cell_to_row(cd, sheet_idx, sheet_name, filter) {
183                    out.push(row);
184                }
185            }
186        }
187        out
188    }
189
190    /// Total number of visible change rows (may iterate; not O(1)).
191    pub fn row_count(&self, filter: &ViewFilter) -> usize {
192        self.rows(filter).len()
193    }
194
195    // ------------------------------------------------------------------
196    // Navigation
197    // ------------------------------------------------------------------
198
199    /// Return the first change anchor in the view, or `None` if empty.
200    pub fn first(&self, filter: &ViewFilter) -> Option<ChangeAnchor> {
201        self.rows(filter).into_iter().next().map(|r| r.anchor)
202    }
203
204    /// Return the anchor immediately after `current`, or `None` if at end.
205    pub fn next_after(&self, current: &ChangeAnchor, filter: &ViewFilter) -> Option<ChangeAnchor> {
206        let mut past = false;
207        for row in self.rows(filter).into_iter() {
208            if past {
209                return Some(row.anchor);
210            }
211            if &row.anchor == current {
212                past = true;
213            }
214        }
215        None
216    }
217
218    /// Return the anchor immediately before `current`, or `None` if at start.
219    pub fn previous_before(&self, current: &ChangeAnchor, filter: &ViewFilter) -> Option<ChangeAnchor> {
220        let mut prev: Option<ChangeAnchor> = None;
221        for row in self.rows(filter).into_iter() {
222            if &row.anchor == current {
223                return prev;
224            }
225            prev = Some(row.anchor.clone());
226        }
227        None
228    }
229
230    // ------------------------------------------------------------------
231    // Per-sheet slice
232    // ------------------------------------------------------------------
233
234    /// All cell-change rows for one sheet (by 0-based sheet index).
235    pub fn sheet_rows(
236        &'a self,
237        sheet_index: usize,
238        filter: &ViewFilter,
239    ) -> Vec<CellChangeRow<'a>> {
240        let mut f = filter.clone();
241        f.sheets = Some(vec![sheet_index]);
242        self.rows(&f)
243    }
244}
245
246// ---------------------------------------------------------------------------
247// Helper: CellDiff → CellChangeRow
248// ---------------------------------------------------------------------------
249
250fn cell_to_row<'a>(
251    cd: &'a CellDiff,
252    sheet_index: usize,
253    sheet_name: &'a str,
254    filter: &ViewFilter,
255) -> Option<CellChangeRow<'a>> {
256    let has_value = cd.value.is_some() && filter.include_values;
257    let has_formula = cd.formula.is_some() && filter.include_formulas;
258
259    if !has_value && !has_formula {
260        return None;
261    }
262
263    let old_display = cd.value.as_ref()
264        .map(|vc| vc.old.display_string())
265        .unwrap_or_default();
266    let new_display = cd.value.as_ref()
267        .map(|vc| vc.new.display_string())
268        .unwrap_or_default();
269
270    let max_severity = cd.diagnostics.iter()
271        .map(|d| d.severity)
272        .max();
273
274    // Borrow formula text from the underlying change, if present (Q2).
275    let (old_formula, new_formula) = match &cd.formula {
276        Some(fc) => (
277            fc.old.as_ref().map(|t| t.raw.as_str()),
278            fc.new.as_ref().map(|t| t.raw.as_str()),
279        ),
280        None => (None, None),
281    };
282
283    Some(CellChangeRow {
284        anchor: ChangeAnchor {
285            sheet_index,
286            row: cd.address.row,
287            col: cd.address.col,
288        },
289        sheet_name,
290        address: &cd.address,
291        change_kind: cd.change_kind(),
292        old_display,
293        new_display,
294        formula_changed: cd.formula.is_some(),
295        old_formula,
296        new_formula,
297        max_severity,
298    })
299}