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