Skip to main content

visi_core/core/
pivot.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4use crate::core::engine::{CellRef, ResultData, Sheet};
5
6/// Where a `PivotTable` reads its source records from: either an existing
7/// `ExcelTable` (looked up by name at compute time, so renames/resizes of
8/// the table are picked up automatically on refresh) or a plain cell range
9/// whose first row is treated as column headers.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub enum PivotSource {
12    /// An `ExcelTable`, resolved by name on every refresh.
13    Table {
14        /// The table's name, matched case-insensitively workbook-wide.
15        name: String,
16    },
17    /// A raw rectangular range, whose first row supplies the field names.
18    Range {
19        /// Sheet the range lives on.
20        sheet_id: u64,
21        /// First row of the range, 0-based, and the header row.
22        start_row: usize,
23        /// First column of the range, 0-based.
24        start_col: usize,
25        /// Last row of the range, 0-based and inclusive.
26        end_row: usize,
27        /// Last column of the range, 0-based and inclusive.
28        end_col: usize,
29    },
30}
31
32/// Matches the "Summarize value field by" choices Excel exposes for a data
33/// field; the five most commonly used ones plus the numeric-only count.
34#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
35pub enum PivotAggregation {
36    /// Total of the numeric values.
37    Sum,
38    /// How many non-blank values there are, text included.
39    Count,
40    /// How many values are numbers.
41    CountNumbers,
42    /// Mean of the numeric values.
43    Average,
44    /// Largest numeric value.
45    Max,
46    /// Smallest numeric value.
47    Min,
48}
49
50impl PivotAggregation {
51    /// The caption Excel uses for this aggregation in a value field's default
52    /// label ("Sum of Amount").
53    ///
54    /// [`PivotAggregation::CountNumbers`] shares `Count`'s caption, which is
55    /// why two such fields on one column collide and get disambiguated by
56    /// [`value_field_labels`].
57    pub fn label(&self) -> &'static str {
58        match self {
59            PivotAggregation::Sum => "Sum",
60            PivotAggregation::Count | PivotAggregation::CountNumbers => "Count",
61            PivotAggregation::Average => "Average",
62            PivotAggregation::Max => "Max",
63            PivotAggregation::Min => "Min",
64        }
65    }
66
67    /// Parses a user-supplied aggregation name, ignoring case, spaces,
68    /// underscores and hyphens, and accepting the common short forms (`avg`,
69    /// `countnums`, `maximum`). `None` if it names nothing.
70    pub fn parse(s: &str) -> Option<Self> {
71        match s.to_ascii_lowercase().replace(['_', '-', ' '], "").as_str() {
72            "sum" => Some(Self::Sum),
73            "count" => Some(Self::Count),
74            "countnumbers" | "countnums" => Some(Self::CountNumbers),
75            "average" | "avg" => Some(Self::Average),
76            "max" | "maximum" => Some(Self::Max),
77            "min" | "minimum" => Some(Self::Min),
78            _ => None,
79        }
80    }
81}
82
83/// One field placed in the Row or Column area.
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85pub struct PivotField {
86    /// Name of the source column to group by, matched against the header row.
87    pub column: String,
88    /// Whether a subtotal line is emitted for this field when it isn't the
89    /// innermost field in its area (Excel's per-field "Subtotals" toggle).
90    pub subtotal: bool,
91}
92
93impl PivotField {
94    /// A field on `column` with subtotals enabled, Excel's default.
95    pub fn new(column: impl Into<String>) -> Self {
96        Self {
97            column: column.into(),
98            subtotal: true,
99        }
100    }
101}
102
103/// One field placed in the Values area.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
105pub struct PivotValueField {
106    /// Name of the source column to aggregate, matched against the header row.
107    pub column: String,
108    /// How the column's values are summarized.
109    pub aggregation: PivotAggregation,
110    /// Overrides the default "Sum of Amount" caption. A custom name is used
111    /// verbatim and takes no part in [`value_field_labels`]' disambiguation.
112    pub custom_name: Option<String>,
113}
114
115impl PivotValueField {
116    /// A value field on `column` with the default caption.
117    pub fn new(column: impl Into<String>, aggregation: PivotAggregation) -> Self {
118        Self {
119            column: column.into(),
120            aggregation,
121            custom_name: None,
122        }
123    }
124
125    /// This field's caption considered on its own, ignoring any collision
126    /// with the pivot's other value fields. Use [`value_field_labels`] to
127    /// caption a whole list the way Excel would.
128    pub fn label(&self) -> String {
129        self.custom_name
130            .clone()
131            .unwrap_or_else(|| format!("{} of {}", self.aggregation.label(), self.column))
132    }
133}
134
135/// Default display labels for a pivot's whole value-field list, matching
136/// Excel's own (surprisingly convoluted) disambiguation for repeated
137/// source columns -- derived empirically against real Excel via
138/// fuzz/fuzz_pivot.py plus direct probing (see the probe script referenced
139/// in the PR that added this comment), since none of it is documented.
140///
141/// Two independent mechanisms are in play, both scoped per source column:
142///
143/// 1. **The "Sum" clone.** The *first* value field for a column that uses
144///    the `Sum` aggregation causes Excel to silently clone that column
145///    into a new pseudo-field ("Amount" -> "Amount2") for every value
146///    field *after* it in the list (not before) -- regardless of their own
147///    aggregation. A *second* `Sum` on the same column clones again
148///    ("Amount2" -> "Amount3"), but non-`Sum` aggregations never trigger a
149///    further clone; they just ride whatever clone slot is already active.
150///    E.g. `[Sum, Max, Count]` on "Amount" -> `["Sum of Amount", "Max of
151///    Amount2", "Count of Amount2"]` (both non-Sum fields share slot 2);
152///    `[Sum, Sum, Count]` -> `["Sum of Amount", "Sum of Amount2", "Count
153///    of Amount3"]` (the second Sum clones again). A column with *no* Sum
154///    value field anywhere is never cloned at all.
155/// 2. **Literal caption collision.** Independent of the above, if two
156///    value fields end up wanting the exact same caption text, Excel still
157///    has to disambiguate. If neither is in a Sum-cloned slot, it appends
158///    a plain digit straight onto the column name (`"Count of Amount"`,
159///    `"Count of Amount2"`, `"Count of Amount3"`, ...) -- this is also how
160///    `CountNumbers` colliding with `Count` gets suffixed, since both
161///    share the caption label "Count" (see `PivotAggregation::label`). If
162///    the collision instead happens *inside* an already Sum-cloned slot
163///    (two non-Sum fields sharing one clone with the same aggregation),
164///    Excel instead appends an underscored counter to the *whole* already-
165///    suffixed caption (`"Max of Amount2"`, `"Max of Amount2_2"`) rather
166///    than incrementing the clone number again.
167///
168/// An explicit `custom_name` bypasses both mechanisms entirely -- it's
169/// used as-is and doesn't consume a collision slot or trigger a clone.
170pub fn value_field_labels(value_fields: &[PivotValueField]) -> Vec<String> {
171    let mut clone_suffix: HashMap<&str, usize> = HashMap::new();
172    let mut next_clone: HashMap<&str, usize> = HashMap::new();
173    let mut label_counts: HashMap<String, usize> = HashMap::new();
174
175    value_fields
176        .iter()
177        .map(|vf| {
178            if let Some(name) = &vf.custom_name {
179                return name.clone();
180            }
181            let agg_label = vf.aggregation.label();
182            let in_clone_slot = clone_suffix.contains_key(vf.column.as_str());
183            let base_column = match clone_suffix.get(vf.column.as_str()) {
184                Some(n) => format!("{}{}", vf.column, n),
185                None => vf.column.clone(),
186            };
187            let base_label = format!("{} of {}", agg_label, base_column);
188            let count = label_counts.entry(base_label.clone()).or_insert(0);
189            *count += 1;
190            let label = if *count == 1 {
191                base_label
192            } else if in_clone_slot {
193                format!("{}_{}", base_label, count)
194            } else {
195                format!("{} of {}{}", agg_label, vf.column, count)
196            };
197            if vf.aggregation == PivotAggregation::Sum {
198                let assigned = *next_clone.entry(vf.column.as_str()).or_insert(2);
199                next_clone.insert(vf.column.as_str(), assigned + 1);
200                clone_suffix.insert(vf.column.as_str(), assigned);
201            }
202            label
203        })
204        .collect()
205}
206
207/// One field placed in the Filter (Page) area.
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
209pub struct PivotFilterField {
210    /// Name of the source column to filter on, matched against the header row.
211    pub column: String,
212    /// `None` means every value is allowed (no filtering applied yet).
213    ///
214    /// **Reconstructed on xlsx import**, resolved through the cache's
215    /// `<sharedItems>` to plain value strings rather than kept as indices --
216    /// which is what makes it safe. The indices are trusted only against the
217    /// cache definition in the same file, which is self-consistent by
218    /// construction, and a value that no longer exists in changed source data
219    /// simply matches nothing.
220    ///
221    /// Two things do not survive, both because the format cannot hold them:
222    ///
223    /// - A selection covering *every* value marks nothing hidden, so it is
224    ///   indistinguishable from no filter and reads back as `None`. The
225    ///   grid is the same either way.
226    /// - A filter on a column that is *also* a row or column field is lost
227    ///   entirely: a pivot field carries one `axis`, so there is nowhere to
228    ///   record it. Excel cannot express that config at all -- a field has
229    ///   exactly one orientation there.
230    ///
231    /// Matching is case-insensitive, because the items themselves are merged
232    /// that way; a selection naming `east` picks the merged `East` item.
233    pub selected_values: Option<Vec<String>>,
234    /// Whether the field is in Excel's *multi-select* page mode
235    /// (`multipleItemSelectionAllowed` in the file) rather than its classic
236    /// single-select one.
237    ///
238    /// The two differ in what the page-field cell says, which is observable:
239    /// with one item chosen, multi-select shows `(Multiple Items)` while
240    /// single-select shows the **item's own name**. Both measured -- the
241    /// first through `PivotItems(x).Visible = False`, the second through
242    /// `PivotField.CurrentPage = "Widget"`, which is what puts a field into
243    /// single-select mode in the first place.
244    ///
245    /// Defaults to `true`, matching `set_pivot_filter` and the CLI, which
246    /// select a set of values rather than one page.
247    #[serde(default = "default_true")]
248    pub multiple_selection: bool,
249}
250
251fn default_true() -> bool {
252    true
253}
254
255impl PivotFilterField {
256    /// A filter field on `column` with nothing filtered out yet.
257    pub fn new(column: impl Into<String>) -> Self {
258        Self {
259            column: column.into(),
260            selected_values: None,
261            multiple_selection: true,
262        }
263    }
264}
265
266/// The area of a pivot table a field can be assigned to, used by the
267/// add/remove-field CRUD operations.
268#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
269pub enum PivotArea {
270    /// Groups down the left edge; adds to `PivotTable::row_fields`.
271    Row,
272    /// Groups across the top; adds to `PivotTable::col_fields`.
273    Column,
274    /// Aggregated data; adds to `PivotTable::value_fields`.
275    Value,
276    /// Restricts which source records take part; adds to
277    /// `PivotTable::filter_fields`.
278    Filter,
279}
280
281/// A pivot table definition: a summary of `source`, grouped by `row_fields`
282/// nested within `col_fields`, restricted by `filter_fields`, and
283/// aggregated per `value_fields`. This is a workbook-level object (like
284/// `Chart`) rather than sheet-scoped like `ExcelTable`, since its source and
285/// destination ranges may live on different sheets.
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
287pub struct PivotTable {
288    /// Workbook-unique identifier, stable across renames.
289    pub id: u64,
290    /// Display name, unique workbook-wide.
291    pub name: String,
292    /// Where the records come from.
293    pub source: PivotSource,
294    /// Sheet the grid is written to, which need not be the source's sheet.
295    pub dest_sheet_id: u64,
296    /// Top-left row of the output grid, 0-based.
297    pub dest_row: usize,
298    /// Top-left column of the output grid, 0-based.
299    pub dest_col: usize,
300    /// Fields grouped down the left edge, outermost first.
301    pub row_fields: Vec<PivotField>,
302    /// Fields grouped across the top, outermost first.
303    pub col_fields: Vec<PivotField>,
304    /// Fields aggregated into the body. At least one is required for
305    /// [`compute_pivot`] to succeed.
306    pub value_fields: Vec<PivotValueField>,
307    /// Fields restricting which source records take part.
308    pub filter_fields: Vec<PivotFilterField>,
309    /// Whether a grand-total row is appended below the body.
310    pub grand_totals_row: bool,
311    /// Whether a grand-total column is appended to the right of the body.
312    pub grand_totals_col: bool,
313    /// Bottom-right corner of the last rendered output grid, so a refresh
314    /// that produces a smaller grid can clear the now-stale cells.
315    #[serde(default)]
316    pub last_output_end_row: Option<usize>,
317    /// Column half of that corner; see [`PivotTable::last_output_end_row`].
318    #[serde(default)]
319    pub last_output_end_col: Option<usize>,
320}
321
322/// Width, in columns, reserved for row-field labels: one column per row
323/// field when there are any. With no row fields at all, Excel only
324/// reserves a single placeholder column when there's *exactly one* value
325/// field *and* at least one column field for it to sit to the left of --
326/// that lone cell holds the value field's own label (e.g. "Max of
327/// Amount"), the same way the header's "Row Labels | Sum of X" corner
328/// would if there were row fields. With no column fields either (the fully
329/// "flat" single-aggregate pivot) or with more than one value field (whose
330/// labels already show up elsewhere in the header), there's nothing
331/// unambiguous to put in a corner, so Excel reserves no column there at
332/// all. All three shapes verified against real Excel via
333/// fuzz/fuzz_pivot.py. Shared between `compute_pivot` (which must actually
334/// size `PivotBodyRow::row_labels` this way) and `pivot_xlsx.rs` (which
335/// needs the same number for `firstDataCol`).
336pub(crate) fn row_label_width(pivot: &PivotTable) -> usize {
337    if !pivot.row_fields.is_empty() {
338        return pivot.row_fields.len();
339    }
340    if pivot.value_fields.len() == 1 && !pivot.col_fields.is_empty() {
341        1
342    } else {
343        0
344    }
345}
346
347/// A fully computed pivot result, ready to be materialized into a sheet:
348/// `filter_rows` (if any) come first, then a blank spacer row, then
349/// `header_rows`, then one entry of `body_rows` per output row -- mirroring
350/// Excel's own report-filter placement (verified against real Excel: it
351/// always reserves one row per filter field plus a blank spacer above the
352/// row/column header grid, and captions each with a "(All)"/"(Multiple
353/// Items)" state -- never a specific value's name, since that's specific to
354/// the classic single-select page-field mode Excel no longer defaults to).
355#[derive(Debug, Clone)]
356pub struct PivotGrid {
357    /// One `(field name, state)` pair per filter field, in the order they
358    /// were added.
359    ///
360    /// The state is `"(All)"` when every value is allowed, the **item's own
361    /// name** when exactly one is selected, and `"(Multiple Items)"`
362    /// otherwise -- which is what Excel puts in the page-field cell, and what
363    /// `PivotField.CurrentPage` reports alongside it.
364    pub filter_rows: Vec<(String, String)>,
365    /// The column-header block above the body: one row per column field,
366    /// plus a value-field row when there is more than one value field.
367    pub header_rows: Vec<Vec<String>>,
368    /// The body, one entry per output row, subtotal and grand-total rows
369    /// included.
370    pub body_rows: Vec<PivotBodyRow>,
371    /// Total width in columns (row-label columns + data columns), used by
372    /// the caller to know how large a range to clear/allocate. Always >= 2,
373    /// so `filter_rows`' two columns (name, state) always fit within it.
374    pub width: usize,
375    /// The flattened row/column axis groups underlying `body_rows`/the data
376    /// columns, exposed (independent of display formatting) so an xlsx
377    /// exporter can reconstruct a native `pivotTableDefinition`'s
378    /// `rowItems`/`colItems` without re-deriving the grouping itself.
379    pub row_axis: Vec<PivotAxisItem>,
380    /// Column half of that axis pair; see [`PivotGrid::row_axis`].
381    pub col_axis: Vec<PivotAxisItem>,
382}
383
384/// One row of a computed pivot's body: its row-field labels and its
385/// aggregated values.
386#[derive(Debug, Clone)]
387pub struct PivotBodyRow {
388    /// One entry per row field (or a single "Grand Total" entry when there
389    /// are no row fields); blank entries mean "same as the row above".
390    pub row_labels: Vec<String>,
391    /// Whether this row is the grand total rather than a data or subtotal row.
392    pub is_grand_total: bool,
393    /// One entry per data column, aligned with the last `header_rows` row.
394    pub values: Vec<ResultData>,
395}
396
397/// One flattened group along a row or column axis: a label per axis field
398/// (`None` past its own depth), plus whether it's a subtotal or grand-total
399/// pseudo-group rather than a real leaf group.
400#[derive(Debug, Clone)]
401pub struct PivotAxisItem {
402    /// One entry per field in this axis, `None` past this group's own depth.
403    pub labels: Vec<Option<String>>,
404    /// Whether this is a subtotal pseudo-group rather than a leaf group.
405    pub is_subtotal: bool,
406    /// Whether this is the axis's grand-total pseudo-group.
407    pub is_grand_total: bool,
408}
409
410impl PivotGrid {
411    /// Row offset from the pivot's `dest_row` anchor to where the row/col
412    /// header + data grid actually begins: 0 with no filter fields, else
413    /// one row per filter field plus a blank spacer row.
414    pub fn grid_row_offset(&self) -> usize {
415        if self.filter_rows.is_empty() {
416            0
417        } else {
418            self.filter_rows.len() + 1
419        }
420    }
421
422    /// Total height in rows, filter rows and spacer included -- what the
423    /// caller needs to allocate or clear at the pivot's `dest_row` anchor.
424    pub fn height(&self) -> usize {
425        self.grid_row_offset() + self.header_rows.len() + self.body_rows.len()
426    }
427}
428
429/// A flattened, labeled group of source records along one axis (row or
430/// column), produced by recursively grouping by each field in that axis in
431/// turn. `record_indices` is the union of every record folded into this
432/// group -- for a leaf group that's just its own bucket, for a subtotal or
433/// grand-total pseudo-group it's every record under it.
434struct FlatGroup {
435    /// One label per field in this axis; `None` past the group's own depth
436    /// (e.g. a subtotal group has no label for deeper fields).
437    labels: Vec<Option<String>>,
438    record_indices: Vec<usize>,
439    is_subtotal: bool,
440    is_grand_total: bool,
441}
442
443struct GroupNode {
444    label: String,
445    record_indices: Vec<usize>,
446    children: Vec<GroupNode>,
447}
448
449pub(crate) fn group_key(result: &ResultData) -> String {
450    match result {
451        ResultData::None => "(blank)".to_string(),
452        ResultData::String(s) if s.is_empty() => "(blank)".to_string(),
453        other => other.to_string(),
454    }
455}
456
457/// Whether every non-blank value of `records[..][field_idx]` is a genuine
458/// number (`Integer`/`Float`), as opposed to text that merely looks
459/// numeric (e.g. a zero-padded code like `"08"`, or digits kept as text on
460/// purpose). Determines sort order for that field's pivot groups --
461/// Excel sorts a real numeric field numerically but a text field
462/// alphabetically even when its values happen to look like numbers
463/// (verified against real Excel via fuzz/fuzz_pivot.py's `NumStr` column,
464/// whose whole purpose is generating quoted numeric-looking text to probe
465/// exactly this) -- with one refinement found on Windows: a value that
466/// looks like a *negative* number sorts by its digits with the leading
467/// `-` stripped, not by the `-` character itself. See
468/// `sort_group_entries` and `text_sort_key`. Grouping already collapsed
469/// values to strings by this point (`group_key`), which can no longer
470/// tell a real `22` from a text `"22"` -- this has to be decided from the
471/// original `ResultData`s.
472pub(crate) fn field_is_numeric(records: &[Vec<ResultData>], field_idx: usize) -> bool {
473    !records.is_empty()
474        && records.iter().all(|r| {
475            matches!(
476                r.get(field_idx),
477                Some(ResultData::Integer(_)) | Some(ResultData::Float(_)) | Some(ResultData::None)
478            )
479        })
480}
481
482/// The key `sort_group_entries`'s text-field branch compares siblings by:
483/// the value itself, lowercased, *unless* it looks like a negative number
484/// (`"-7"`, `"-25"`), in which case the leading `-` is stripped first.
485/// Measured on Windows real Excel across three independent sibling sets
486/// (fuzz/fuzz_pivot.py's `NumStr` column):
487///   `{-7, .0152, 13, 34, 4}`        -> `.0152, 13, 34, 4, -7`
488///   `{-46, .097, 01, 02, 1, 10, 35}` -> `.097, 01, 02, 1, 10, 35, -46`
489///   `{-25, .0599, .0839, 01, 02, 08, 1, 12, 37}`
490///                                   -> `.0599, .0839, 01, 02, 08, 1, 12, -25, 37`
491/// A "sorts last" rule (visi's first attempt at this) fits the first two
492/// but not the third, where "-25" lands *before* "37" -- comparing "25"
493/// (the stripped digits) against the other keys fits all three: "25"
494/// falls between "12" and "37" alphabetically, exactly where Excel put
495/// "-25". Not tested (no evidence either way): two negative-looking
496/// siblings compared against each other -- both get stripped, so they
497/// fall back to comparing their digit strings.
498fn text_sort_key(s: &str) -> String {
499    let trimmed = s.trim();
500    let key = match trimmed.strip_prefix('-') {
501        Some(rest) if rest.starts_with(|c: char| c.is_ascii_digit()) => rest,
502        _ => trimmed,
503    };
504    key.to_lowercase()
505}
506
507fn sort_group_entries(pairs: &mut [(String, Vec<usize>)], numeric: bool) {
508    pairs.sort_by(|a, b| match (a.0 == "(blank)", b.0 == "(blank)") {
509        (true, true) => std::cmp::Ordering::Equal,
510        (true, false) => std::cmp::Ordering::Greater,
511        (false, true) => std::cmp::Ordering::Less,
512        (false, false) if numeric => {
513            let fa: f64 = a.0.trim().parse().unwrap_or(0.0);
514            let fb: f64 = b.0.trim().parse().unwrap_or(0.0);
515            fa.partial_cmp(&fb).unwrap_or(std::cmp::Ordering::Equal)
516        }
517        (false, false) => text_sort_key(&a.0).cmp(&text_sort_key(&b.0)),
518    });
519}
520
521fn build_group_tree(
522    indices: &[usize],
523    keys: &[Vec<String>],
524    depth: usize,
525    num_fields: usize,
526    numeric_by_depth: &[bool],
527) -> Vec<GroupNode> {
528    let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
529    for &idx in indices {
530        let key = &keys[idx][depth];
531        if let Some(entry) = groups.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(key)) {
532            entry.1.push(idx);
533        } else {
534            groups.push((key.clone(), vec![idx]));
535        }
536    }
537    sort_group_entries(&mut groups, numeric_by_depth[depth]);
538    groups
539        .into_iter()
540        .map(|(label, idxs)| {
541            let children = if depth + 1 < num_fields {
542                build_group_tree(&idxs, keys, depth + 1, num_fields, numeric_by_depth)
543            } else {
544                Vec::new()
545            };
546            GroupNode {
547                label,
548                record_indices: idxs,
549                children,
550            }
551        })
552        .collect()
553}
554
555/// Recursively flattens a group tree into a list of `FlatGroup`s: every leaf
556/// group, plus (when enabled for that field) a subtotal pseudo-group after
557/// each non-innermost group's children.
558fn flatten_groups(
559    nodes: &[GroupNode],
560    fields: &[PivotField],
561    depth: usize,
562    num_fields: usize,
563    prefix: &[Option<String>],
564    out: &mut Vec<FlatGroup>,
565) {
566    for node in nodes {
567        let mut labels = prefix.to_vec();
568        labels.push(Some(node.label.clone()));
569
570        if node.children.is_empty() {
571            let mut leaf_labels = labels.clone();
572            leaf_labels.resize(num_fields, None);
573            out.push(FlatGroup {
574                labels: leaf_labels,
575                record_indices: node.record_indices.clone(),
576                is_subtotal: false,
577                is_grand_total: false,
578            });
579        } else {
580            flatten_groups(&node.children, fields, depth + 1, num_fields, &labels, out);
581            let is_innermost = depth + 1 >= num_fields;
582            if fields[depth].subtotal && !is_innermost {
583                let mut subtotal_labels = labels.clone();
584                subtotal_labels.resize(num_fields, None);
585                out.push(FlatGroup {
586                    labels: subtotal_labels,
587                    record_indices: node.record_indices.clone(),
588                    is_subtotal: true,
589                    is_grand_total: false,
590                });
591            }
592        }
593    }
594}
595
596/// Builds the flattened axis groups for `fields` over `record_indices`,
597/// optionally appending a grand-total pseudo-group. Returns a single
598/// implicit "all records" group when `fields` is empty.
599fn build_axis(
600    record_indices: &[usize],
601    keys: &[Vec<String>],
602    fields: &[PivotField],
603    grand_total: bool,
604    numeric_by_depth: &[bool],
605) -> Vec<FlatGroup> {
606    if fields.is_empty() {
607        return vec![FlatGroup {
608            labels: Vec::new(),
609            record_indices: record_indices.to_vec(),
610            is_subtotal: false,
611            is_grand_total: false,
612        }];
613    }
614    let tree = build_group_tree(record_indices, keys, 0, fields.len(), numeric_by_depth);
615    let mut flat = Vec::new();
616    flatten_groups(&tree, fields, 0, fields.len(), &[], &mut flat);
617    if grand_total && !flat.is_empty() {
618        flat.push(FlatGroup {
619            labels: vec![None; fields.len()],
620            record_indices: record_indices.to_vec(),
621            is_subtotal: false,
622            is_grand_total: true,
623        });
624    }
625    flat
626}
627
628fn aggregate(sheet: &Sheet, values: &[ResultData], agg: PivotAggregation) -> ResultData {
629    if values.is_empty() {
630        return ResultData::None;
631    }
632    match agg {
633        PivotAggregation::Count => ResultData::Integer(
634            values
635                .iter()
636                .filter(|v| !matches!(v, ResultData::None))
637                .count() as i64,
638        ),
639        PivotAggregation::CountNumbers => ResultData::Integer(
640            values
641                .iter()
642                .filter(|v| matches!(v, ResultData::Integer(_) | ResultData::Float(_)))
643                .count() as i64,
644        ),
645        _ => {
646            let nums: Vec<f64> = values
647                .iter()
648                .filter_map(|v| match v {
649                    ResultData::Integer(_) | ResultData::Float(_) => sheet.to_f64(v),
650                    _ => None,
651                })
652                .collect();
653            match agg {
654                PivotAggregation::Sum => {
655                    if nums.is_empty() {
656                        ResultData::Integer(0)
657                    } else {
658                        ResultData::Float(Sheet::clean_float(nums.iter().sum()))
659                    }
660                }
661                PivotAggregation::Average => {
662                    if nums.is_empty() {
663                        ResultData::Error("#DIV/0!".to_string())
664                    } else {
665                        let avg = nums.iter().sum::<f64>() / nums.len() as f64;
666                        ResultData::Float(Sheet::clean_float(avg))
667                    }
668                }
669                PivotAggregation::Max => nums
670                    .into_iter()
671                    .fold(None, |acc: Option<f64>, x| {
672                        Some(acc.map_or(x, |a| a.max(x)))
673                    })
674                    .map(ResultData::Float)
675                    .unwrap_or(ResultData::None),
676                PivotAggregation::Min => nums
677                    .into_iter()
678                    .fold(None, |acc: Option<f64>, x| {
679                        Some(acc.map_or(x, |a| a.min(x)))
680                    })
681                    .map(ResultData::Float)
682                    .unwrap_or(ResultData::None),
683                PivotAggregation::Count | PivotAggregation::CountNumbers => unreachable!(),
684            }
685        }
686    }
687}
688
689/// Resolves a `PivotSource` against the workbook's sheets, returning the
690/// owning sheet, the source's column names (in source-column order), the
691/// matching absolute sheet-column indices, and the absolute sheet-row
692/// indices holding data (i.e. excluding any header/totals row).
693/// (owning sheet, source column names, absolute sheet-column indices, absolute data-row indices).
694pub(crate) type ResolvedSource<'a> = (&'a Sheet, Vec<String>, Vec<usize>, Vec<usize>);
695
696pub(crate) fn resolve_source<'a>(
697    sheets: &'a [&'a Sheet],
698    source: &PivotSource,
699) -> Result<ResolvedSource<'a>, String> {
700    match source {
701        PivotSource::Table { name } => {
702            let (sheet, table) = sheets
703                .iter()
704                .find_map(|s| s.find_table(name).map(|t| (*s, t)))
705                .ok_or_else(|| format!("Table '{}' not found", name))?;
706            let cols: Vec<usize> = (table.start_col..=table.end_col).collect();
707            let rows: Vec<usize> = (table.data_start_row()..=table.data_end_row()).collect();
708            Ok((sheet, table.columns.clone(), cols, rows))
709        }
710        PivotSource::Range {
711            sheet_id,
712            start_row,
713            start_col,
714            end_row,
715            end_col,
716        } => {
717            let sheet = *sheets
718                .iter()
719                .find(|s| s.id == *sheet_id)
720                .ok_or_else(|| "Pivot source sheet no longer exists".to_string())?;
721            if *end_row < *start_row || *end_col < *start_col {
722                return Err("Pivot source range end must not precede its start".to_string());
723            }
724            let cols: Vec<usize> = (*start_col..=*end_col).collect();
725            let names: Vec<String> = cols
726                .iter()
727                .map(|&c| {
728                    let v = sheet.get_result_data(&CellRef::new(*start_row, c));
729                    let s = v.to_string();
730                    if s.is_empty() {
731                        crate::core::parser::col_idx_to_letters(c)
732                    } else {
733                        s
734                    }
735                })
736                .collect();
737            let rows: Vec<usize> = if *end_row > *start_row {
738                (*start_row + 1..=*end_row).collect()
739            } else {
740                Vec::new()
741            };
742            Ok((sheet, names, cols, rows))
743        }
744    }
745}
746
747pub(crate) fn column_index(names: &[String], target: &str) -> Result<usize, String> {
748    names
749        .iter()
750        .position(|c| c.eq_ignore_ascii_case(target))
751        .ok_or_else(|| {
752            format!(
753                "Source column '{}' not found (columns: {})",
754                target,
755                names.join(", ")
756            )
757        })
758}
759
760/// Computes a pivot table's result grid from the current state of `sheets`.
761/// Pure and read-only: callers materialize the returned `PivotGrid` into
762/// sheet cells themselves.
763/// Computes `pivot` against `sheets`, returning a display-ready grid.
764///
765/// Pure: it reads source records, applies the filter fields, groups by the
766/// row and column fields, aggregates the value fields, and returns the
767/// result. Nothing is written -- materializing the grid into cells is
768/// `WorkbookManager::refresh_pivot_table`'s job.
769///
770/// `sheets` must include both the source's sheet and, for a
771/// [`PivotSource::Table`] source, whichever sheet carries that table.
772///
773/// # Errors
774///
775/// Returns a message if the source cannot be resolved, if a named field is
776/// not among the source's columns, or if the pivot has no value fields.
777pub fn compute_pivot(sheets: &[&Sheet], pivot: &PivotTable) -> Result<PivotGrid, String> {
778    let (sheet, col_names, sheet_cols, data_rows) = resolve_source(sheets, &pivot.source)?;
779
780    for f in pivot.row_fields.iter().chain(pivot.col_fields.iter()) {
781        column_index(&col_names, &f.column)?;
782    }
783    for vf in &pivot.value_fields {
784        column_index(&col_names, &vf.column)?;
785    }
786    for ff in &pivot.filter_fields {
787        column_index(&col_names, &ff.column)?;
788    }
789    if pivot.value_fields.is_empty() {
790        return Err("Pivot table has no value fields".to_string());
791    }
792
793    let mut all_rows: Vec<Vec<ResultData>> = Vec::with_capacity(data_rows.len());
794    for &r in &data_rows {
795        let mut row_vals = Vec::with_capacity(sheet_cols.len());
796        for &c in &sheet_cols {
797            row_vals.push(sheet.get_result_data(&CellRef::new(r, c)));
798        }
799        all_rows.push(row_vals);
800    }
801
802    let mut filter_rows: Vec<(String, String)> = Vec::new();
803    for ff in &pivot.filter_fields {
804        let idx = column_index(&col_names, &ff.column)?;
805        let distinct: std::collections::HashSet<String> = all_rows
806            .iter()
807            .map(|row| group_key(&row[idx]).to_ascii_lowercase())
808            .collect();
809        let state = match &ff.selected_values {
810            None => "(All)".to_string(),
811            Some(selected) => {
812                let selected_set: std::collections::HashSet<String> =
813                    selected.iter().map(|v| v.to_ascii_lowercase()).collect();
814                let is_all = selected_set.len() == distinct.len()
815                    && distinct.iter().all(|v| selected_set.contains(v));
816                if is_all {
817                    "(All)".to_string()
818                } else if !ff.multiple_selection && selected_set.len() == 1 {
819                    let wanted = &selected_set;
820                    all_rows
821                        .iter()
822                        .map(|row| group_key(&row[idx]))
823                        .find(|v| wanted.contains(&v.to_ascii_lowercase()))
824                        .unwrap_or_else(|| "(Multiple Items)".to_string())
825                } else {
826                    "(Multiple Items)".to_string()
827                }
828            }
829        };
830        filter_rows.push((ff.column.clone(), state));
831    }
832
833    let mut records: Vec<Vec<ResultData>> = Vec::new();
834    'row: for row_vals in &all_rows {
835        for ff in &pivot.filter_fields {
836            if let Some(selected) = &ff.selected_values {
837                let idx = column_index(&col_names, &ff.column)?;
838                let key = group_key(&row_vals[idx]);
839                if !selected.iter().any(|v| v.eq_ignore_ascii_case(&key)) {
840                    continue 'row;
841                }
842            }
843        }
844        records.push(row_vals.clone());
845    }
846
847    let record_indices: Vec<usize> = (0..records.len()).collect();
848
849    let row_field_idxs: Vec<usize> = pivot
850        .row_fields
851        .iter()
852        .map(|f| column_index(&col_names, &f.column))
853        .collect::<Result<_, _>>()?;
854    let col_field_idxs: Vec<usize> = pivot
855        .col_fields
856        .iter()
857        .map(|f| column_index(&col_names, &f.column))
858        .collect::<Result<_, _>>()?;
859    let mut case_canon: HashMap<usize, HashMap<String, String>> = HashMap::new();
860    let mut canonical_key = |field_idx: usize, raw: String| -> String {
861        let map = case_canon.entry(field_idx).or_default();
862        map.entry(raw.to_ascii_lowercase()).or_insert(raw).clone()
863    };
864    for row_vals in &all_rows {
865        for &i in row_field_idxs.iter().chain(col_field_idxs.iter()) {
866            canonical_key(i, group_key(&row_vals[i]));
867        }
868    }
869    let row_keys: Vec<Vec<String>> = if pivot.row_fields.is_empty() {
870        Vec::new()
871    } else {
872        records
873            .iter()
874            .map(|rec| {
875                row_field_idxs
876                    .iter()
877                    .map(|&i| canonical_key(i, group_key(&rec[i])))
878                    .collect()
879            })
880            .collect()
881    };
882    let col_keys: Vec<Vec<String>> = if pivot.col_fields.is_empty() {
883        Vec::new()
884    } else {
885        records
886            .iter()
887            .map(|rec| {
888                col_field_idxs
889                    .iter()
890                    .map(|&i| canonical_key(i, group_key(&rec[i])))
891                    .collect()
892            })
893            .collect()
894    };
895    let row_numeric: Vec<bool> = row_field_idxs
896        .iter()
897        .map(|&i| field_is_numeric(&records, i))
898        .collect();
899    let col_numeric: Vec<bool> = col_field_idxs
900        .iter()
901        .map(|&i| field_is_numeric(&records, i))
902        .collect();
903
904    let row_groups = build_axis(
905        &record_indices,
906        &row_keys,
907        &pivot.row_fields,
908        pivot.grand_totals_row,
909        &row_numeric,
910    );
911    let col_groups = build_axis(
912        &record_indices,
913        &col_keys,
914        &pivot.col_fields,
915        pivot.grand_totals_col,
916        &col_numeric,
917    );
918
919    let value_multiplier = if pivot.value_fields.len() > 1 {
920        pivot.value_fields.len()
921    } else {
922        1
923    };
924    let value_idxs: Vec<usize> = pivot
925        .value_fields
926        .iter()
927        .map(|vf| column_index(&col_names, &vf.column))
928        .collect::<Result<_, _>>()?;
929    let value_labels = value_field_labels(&pivot.value_fields);
930
931    let n_col_header_rows = pivot.col_fields.len().max(1);
932    let n_header_rows = if value_multiplier > 1 && !pivot.col_fields.is_empty() {
933        n_col_header_rows + 1
934    } else {
935        n_col_header_rows
936    };
937    let row_label_width = row_label_width(pivot);
938
939    let mut header_rows: Vec<Vec<String>> = Vec::new();
940    for r in 0..n_header_rows {
941        let mut row: Vec<String> = Vec::new();
942        for i in 0..row_label_width {
943            if r == n_header_rows - 1 {
944                row.push(if i == 0 && !pivot.row_fields.is_empty() {
945                    "Row Labels".to_string()
946                } else {
947                    pivot
948                        .row_fields
949                        .get(i)
950                        .map(|f| f.column.clone())
951                        .unwrap_or_default()
952                });
953            } else {
954                row.push(String::new());
955            }
956        }
957        let mut prev_group: Option<&FlatGroup> = None;
958        for group in &col_groups {
959            let subtotal_depth = group
960                .is_subtotal
961                .then(|| group.labels.iter().rposition(|l| l.is_some()))
962                .flatten();
963            for vf in 0..value_multiplier {
964                let label = if r < pivot.col_fields.len() {
965                    if group.is_grand_total {
966                        if r == 0 {
967                            if value_multiplier > 1 {
968                                format!("Total {}", value_labels[vf])
969                            } else {
970                                "Grand Total".to_string()
971                            }
972                        } else {
973                            String::new()
974                        }
975                    } else if subtotal_depth == Some(r) {
976                        let value = group.labels[r].clone().unwrap();
977                        if value_multiplier > 1 {
978                            format!("{} {}", value, value_labels[vf])
979                        } else {
980                            format!("{} Total", value)
981                        }
982                    } else {
983                        let is_repeat = if vf > 0 {
984                            true
985                        } else {
986                            prev_group.is_some_and(|pg| {
987                                (0..=r).all(|d| pg.labels.get(d) == group.labels.get(d))
988                            })
989                        };
990                        if is_repeat {
991                            String::new()
992                        } else {
993                            group
994                                .labels
995                                .get(r)
996                                .and_then(|l| l.clone())
997                                .unwrap_or_default()
998                        }
999                    }
1000                } else if group.is_grand_total || group.is_subtotal {
1001                    String::new()
1002                } else {
1003                    value_labels.get(vf).cloned().unwrap_or_default()
1004                };
1005                row.push(label);
1006            }
1007            prev_group = Some(group);
1008        }
1009        header_rows.push(row);
1010    }
1011    if pivot.col_fields.is_empty()
1012        && value_multiplier == 1
1013        && let Some(last) = header_rows.last_mut()
1014        && let Some(cell) = last.last_mut()
1015        && let Some(label) = value_labels.first()
1016    {
1017        *cell = label.clone();
1018    }
1019    if !pivot.col_fields.is_empty() {
1020        let mut row = vec![String::new(); row_label_width];
1021        if value_multiplier == 1
1022            && !pivot.row_fields.is_empty()
1023            && let Some(label) = value_labels.first()
1024        {
1025            row[0] = label.clone();
1026        }
1027        row.push("Column Labels".to_string());
1028        row.resize(
1029            row_label_width + col_groups.len() * value_multiplier,
1030            String::new(),
1031        );
1032        header_rows.insert(0, row);
1033    }
1034
1035    let mut body_rows: Vec<PivotBodyRow> = Vec::new();
1036    let mut prev_labels: Vec<Option<String>> = vec![None; row_label_width];
1037    for rg in &row_groups {
1038        let mut display_labels = vec![String::new(); row_label_width];
1039        if rg.is_grand_total {
1040            display_labels[0] = "Grand Total".to_string();
1041            for l in prev_labels.iter_mut() {
1042                *l = None;
1043            }
1044        } else {
1045            let mut changed = false;
1046            for d in 0..row_label_width {
1047                let cur = if pivot.row_fields.is_empty() {
1048                    None
1049                } else {
1050                    rg.labels.get(d).cloned().flatten()
1051                };
1052                let is_subtotal_marker =
1053                    rg.is_subtotal && rg.labels.get(d).map(|l| l.is_some()).unwrap_or(false);
1054                let show = changed || cur != prev_labels[d] || is_subtotal_marker;
1055                if show {
1056                    if let Some(ref v) = cur {
1057                        display_labels[d] = if is_subtotal_marker {
1058                            format!("{} Total", v)
1059                        } else {
1060                            v.clone()
1061                        };
1062                    }
1063                    changed = true;
1064                }
1065                prev_labels[d] = cur;
1066            }
1067            if pivot.row_fields.is_empty() && row_label_width > 0 {
1068                display_labels[0] = if !pivot.col_fields.is_empty() && value_multiplier == 1 {
1069                    value_labels.first().cloned().unwrap_or_default()
1070                } else {
1071                    "Total".to_string()
1072                };
1073            }
1074        }
1075
1076        let row_record_set: std::collections::HashSet<usize> =
1077            rg.record_indices.iter().copied().collect();
1078        let mut values: Vec<ResultData> = Vec::new();
1079        for cg in &col_groups {
1080            for (vf_pos, &vidx) in value_idxs.iter().enumerate() {
1081                if vf_pos > 0 && value_multiplier == 1 {
1082                    break;
1083                }
1084                let col_vals: Vec<ResultData> = cg
1085                    .record_indices
1086                    .iter()
1087                    .filter(|i| row_record_set.contains(i))
1088                    .map(|&i| records[i][vidx].clone())
1089                    .collect();
1090                values.push(aggregate(
1091                    sheet,
1092                    &col_vals,
1093                    pivot.value_fields[vf_pos].aggregation,
1094                ));
1095            }
1096        }
1097
1098        body_rows.push(PivotBodyRow {
1099            row_labels: display_labels,
1100            is_grand_total: rg.is_grand_total,
1101            values,
1102        });
1103    }
1104
1105    let width = row_label_width + col_groups.len() * value_multiplier;
1106    let to_axis_items = |groups: &[FlatGroup]| -> Vec<PivotAxisItem> {
1107        groups
1108            .iter()
1109            .map(|g| PivotAxisItem {
1110                labels: g.labels.clone(),
1111                is_subtotal: g.is_subtotal,
1112                is_grand_total: g.is_grand_total,
1113            })
1114            .collect()
1115    };
1116    Ok(PivotGrid {
1117        filter_rows,
1118        header_rows,
1119        body_rows,
1120        width,
1121        row_axis: to_axis_items(&row_groups),
1122        col_axis: to_axis_items(&col_groups),
1123    })
1124}
1125
1126/// Finds the unique row/col-axis group matching `criteria` -- `(field
1127/// depth, item text)` pairs restricted to one axis -- for `GETPIVOTDATA`.
1128/// Empty `criteria` means "the axis's grand total". A non-empty `criteria`
1129/// that doesn't specify every field on the axis matches the subtotal group
1130/// at that depth (mirrors Excel: naming only the outer field(s) of a nested
1131/// row/col axis returns that branch's subtotal, not an arbitrary leaf under
1132/// it); naming every field down to the innermost one matches the leaf.
1133/// Ambiguous or absent matches are both reported as `#REF!`, matching real
1134/// Excel's error for a `GETPIVOTDATA` criteria pair that doesn't resolve.
1135fn match_pivot_axis(
1136    axis: &[PivotAxisItem],
1137    criteria: &[(usize, &str)],
1138    field_count: usize,
1139) -> Result<usize, String> {
1140    if criteria.is_empty() {
1141        return axis
1142            .iter()
1143            .position(|g| g.is_grand_total)
1144            .or(if field_count == 0 && axis.len() == 1 {
1145                Some(0)
1146            } else {
1147                None
1148            })
1149            .ok_or_else(|| "#REF!".to_string());
1150    }
1151    let max_depth = criteria.iter().map(|(d, _)| *d).max().unwrap_or(0);
1152    let want_leaf = max_depth + 1 == field_count;
1153    let matches: Vec<usize> = axis
1154        .iter()
1155        .enumerate()
1156        .filter(|(_, group)| {
1157            if group.is_grand_total {
1158                return false;
1159            }
1160            if want_leaf {
1161                if group.is_subtotal {
1162                    return false;
1163                }
1164            } else {
1165                let own_depth = group.labels.iter().rposition(|l| l.is_some());
1166                if !(group.is_subtotal && own_depth == Some(max_depth)) {
1167                    return false;
1168                }
1169            }
1170            criteria.iter().all(|(depth, item)| {
1171                group
1172                    .labels
1173                    .get(*depth)
1174                    .and_then(|l| l.as_deref())
1175                    .map(|l| l.eq_ignore_ascii_case(item))
1176                    .unwrap_or(false)
1177            })
1178        })
1179        .map(|(i, _)| i)
1180        .collect();
1181    match matches.len() {
1182        1 => Ok(matches[0]),
1183        _ => Err("#REF!".to_string()),
1184    }
1185}
1186
1187/// Implements `GETPIVOTDATA`: extracts a single summarized value out of a
1188/// pivot table's computed grid by data-field name plus `(row/col field,
1189/// item)` criteria pairs, the same way real Excel's formula does when
1190/// pointed at a rendered pivot. Recomputes the grid fresh from `sheets`
1191/// rather than caching it, consistent with formulas re-evaluating from
1192/// current sheet state on every recalculation pass.
1193pub fn getpivotdata(
1194    sheets: &[&Sheet],
1195    pivot: &PivotTable,
1196    data_field: &str,
1197    criteria: &[(String, String)],
1198) -> Result<ResultData, String> {
1199    let grid = compute_pivot(sheets, pivot)?;
1200
1201    let value_labels = value_field_labels(&pivot.value_fields);
1202    let value_multiplier = if pivot.value_fields.len() > 1 {
1203        pivot.value_fields.len()
1204    } else {
1205        1
1206    };
1207    let value_field_idx = pivot
1208        .value_fields
1209        .iter()
1210        .position(|vf| vf.column.eq_ignore_ascii_case(data_field))
1211        .or_else(|| {
1212            value_labels
1213                .iter()
1214                .position(|l| l.eq_ignore_ascii_case(data_field))
1215        })
1216        .ok_or_else(|| "#VALUE!".to_string())?;
1217
1218    let mut row_criteria: Vec<(usize, &str)> = Vec::new();
1219    let mut col_criteria: Vec<(usize, &str)> = Vec::new();
1220    for (field, item) in criteria {
1221        if let Some(depth) = pivot
1222            .row_fields
1223            .iter()
1224            .position(|f| f.column.eq_ignore_ascii_case(field))
1225        {
1226            row_criteria.push((depth, item.as_str()));
1227        } else if let Some(depth) = pivot
1228            .col_fields
1229            .iter()
1230            .position(|f| f.column.eq_ignore_ascii_case(field))
1231        {
1232            col_criteria.push((depth, item.as_str()));
1233        } else {
1234            return Err("#REF!".to_string());
1235        }
1236    }
1237
1238    let row_idx = match_pivot_axis(&grid.row_axis, &row_criteria, pivot.row_fields.len())?;
1239    let col_idx = match_pivot_axis(&grid.col_axis, &col_criteria, pivot.col_fields.len())?;
1240
1241    let pos = col_idx * value_multiplier + value_field_idx;
1242    grid.body_rows
1243        .get(row_idx)
1244        .and_then(|r| r.values.get(pos))
1245        .cloned()
1246        .ok_or_else(|| "#REF!".to_string())
1247}
1248
1249/// Returns the distinct values of `values`, sorted the same way pivot
1250/// groups are (ascending numeric if every value parses as a number,
1251/// otherwise case-insensitive ascending text) -- used by the xlsx exporter
1252/// to build a pivot field's flat `<items>` enumeration.
1253pub(crate) fn sorted_distinct_strings(values: &[String], numeric: bool) -> Vec<String> {
1254    let mut pairs: Vec<(String, Vec<usize>)> = distinct_strings(values)
1255        .into_iter()
1256        .map(|s| (s, Vec::new()))
1257        .collect();
1258    sort_group_entries(&mut pairs, numeric);
1259    pairs.into_iter().map(|(s, _)| s).collect()
1260}
1261
1262/// The distinct values in **first-seen** order, which is the order a pivot
1263/// cache stores them in.
1264///
1265/// Measured: Excel's `<sharedItems>` are in source order while a pivot
1266/// field's `<items>` are sorted for display and reference sharedItems by
1267/// index, so the two orders are both needed and are different. See
1268/// `fuzz/pivot_filter_probe.py`.
1269///
1270/// Case-insensitive dedup (first-seen casing kept), matching
1271/// `build_group_tree`'s merge -- this feeds the exported pivot cache, so it
1272/// must agree with how `compute_pivot` actually groups these same values or a
1273/// reimported or refreshed pivot's item list falls out of sync with its own
1274/// displayed grouping.
1275pub(crate) fn distinct_strings(values: &[String]) -> Vec<String> {
1276    let mut seen: Vec<String> = Vec::new();
1277    for v in values {
1278        if !seen.iter().any(|s| s.eq_ignore_ascii_case(v)) {
1279            seen.push(v.clone());
1280        }
1281    }
1282    seen
1283}
1284
1285#[cfg(test)]
1286mod tests {
1287    use super::*;
1288    use crate::core::engine::SheetInit;
1289
1290    fn source_sheet() -> Sheet {
1291        let mut sheet = Sheet::new(SheetInit {
1292            name: Some("Data".to_string()),
1293            rows: 9,
1294            cols: 4,
1295            ..Default::default()
1296        });
1297        let header = ["Region", "Product", "Rep", "Amount"];
1298        for (c, h) in header.iter().enumerate() {
1299            sheet.set_cell_src(0, c, h.to_string());
1300        }
1301        let rows: [[&str; 4]; 8] = [
1302            ["East", "Widget", "Alice", "10"],
1303            ["East", "Widget", "Bob", "20"],
1304            ["East", "Gadget", "Alice", "5"],
1305            ["West", "Widget", "Carol", "30"],
1306            ["West", "Gadget", "Carol", "40"],
1307            ["West", "Gadget", "Dave", "50"],
1308            ["East", "Gadget", "Bob", "15"],
1309            ["West", "Widget", "Dave", "25"],
1310        ];
1311        for (r, row) in rows.iter().enumerate() {
1312            for (c, v) in row.iter().enumerate() {
1313                sheet.set_cell_src(r + 1, c, v.to_string());
1314            }
1315        }
1316        sheet.commit(None).unwrap();
1317        sheet
1318            .add_table("Sales".to_string(), 0, 0, 8, 3, true, false)
1319            .unwrap();
1320        sheet
1321    }
1322
1323    fn base_pivot() -> PivotTable {
1324        PivotTable {
1325            id: 1,
1326            name: "Pivot1".to_string(),
1327            source: PivotSource::Table {
1328                name: "Sales".to_string(),
1329            },
1330            dest_sheet_id: 0,
1331            dest_row: 0,
1332            dest_col: 0,
1333            row_fields: vec![PivotField::new("Region")],
1334            col_fields: vec![],
1335            value_fields: vec![PivotValueField::new("Amount", PivotAggregation::Sum)],
1336            filter_fields: vec![],
1337            grand_totals_row: true,
1338            grand_totals_col: true,
1339            last_output_end_row: None,
1340            last_output_end_col: None,
1341        }
1342    }
1343
1344    fn value_at(row: &PivotBodyRow, col: usize) -> f64 {
1345        match &row.values[col] {
1346            ResultData::Float(f) => *f,
1347            ResultData::Integer(i) => *i as f64,
1348            other => panic!("expected numeric, got {:?}", other),
1349        }
1350    }
1351
1352    #[test]
1353    fn test_single_row_field_sum_with_grand_total() {
1354        let sheet = source_sheet();
1355        let pivot = base_pivot();
1356        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1357
1358        assert_eq!(grid.body_rows.len(), 3);
1359        assert_eq!(grid.body_rows[0].row_labels[0], "East");
1360        assert_eq!(value_at(&grid.body_rows[0], 0), 50.0);
1361        assert_eq!(grid.body_rows[1].row_labels[0], "West");
1362        assert_eq!(value_at(&grid.body_rows[1], 0), 145.0);
1363        assert!(grid.body_rows[2].is_grand_total);
1364        assert_eq!(grid.body_rows[2].row_labels[0], "Grand Total");
1365        assert_eq!(value_at(&grid.body_rows[2], 0), 195.0);
1366    }
1367
1368    #[test]
1369    fn test_getpivotdata_matches_a_row_group() {
1370        let sheet = source_sheet();
1371        let pivot = base_pivot();
1372        let result = getpivotdata(
1373            &[&sheet],
1374            &pivot,
1375            "Amount",
1376            &[("Region".to_string(), "East".to_string())],
1377        )
1378        .unwrap();
1379        assert!(matches!(result, ResultData::Float(f) if f == 50.0));
1380    }
1381
1382    #[test]
1383    fn test_getpivotdata_empty_criteria_matches_grand_total() {
1384        let sheet = source_sheet();
1385        let pivot = base_pivot();
1386        let result = getpivotdata(&[&sheet], &pivot, "Amount", &[]).unwrap();
1387        assert!(matches!(result, ResultData::Float(f) if f == 195.0));
1388    }
1389
1390    #[test]
1391    fn test_getpivotdata_partial_criteria_matches_subtotal() {
1392        let sheet = source_sheet();
1393        let mut pivot = base_pivot();
1394        pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1395        let result = getpivotdata(
1396            &[&sheet],
1397            &pivot,
1398            "Amount",
1399            &[("Region".to_string(), "East".to_string())],
1400        )
1401        .unwrap();
1402        assert!(matches!(result, ResultData::Float(f) if f == 50.0));
1403    }
1404
1405    #[test]
1406    fn test_getpivotdata_full_path_matches_leaf() {
1407        let sheet = source_sheet();
1408        let mut pivot = base_pivot();
1409        pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1410        let result = getpivotdata(
1411            &[&sheet],
1412            &pivot,
1413            "Amount",
1414            &[
1415                ("Region".to_string(), "East".to_string()),
1416                ("Product".to_string(), "Widget".to_string()),
1417            ],
1418        )
1419        .unwrap();
1420        assert!(matches!(result, ResultData::Float(f) if f == 30.0));
1421    }
1422
1423    #[test]
1424    fn test_getpivotdata_unknown_field_is_ref_error() {
1425        let sheet = source_sheet();
1426        let pivot = base_pivot();
1427        let err = getpivotdata(
1428            &[&sheet],
1429            &pivot,
1430            "Amount",
1431            &[("NotAField".to_string(), "East".to_string())],
1432        )
1433        .unwrap_err();
1434        assert_eq!(err, "#REF!");
1435    }
1436
1437    #[test]
1438    fn test_getpivotdata_unknown_item_is_ref_error() {
1439        let sheet = source_sheet();
1440        let pivot = base_pivot();
1441        let err = getpivotdata(
1442            &[&sheet],
1443            &pivot,
1444            "Amount",
1445            &[("Region".to_string(), "North".to_string())],
1446        )
1447        .unwrap_err();
1448        assert_eq!(err, "#REF!");
1449    }
1450
1451    #[test]
1452    fn test_getpivotdata_unknown_data_field_is_value_error() {
1453        let sheet = source_sheet();
1454        let pivot = base_pivot();
1455        let err = getpivotdata(
1456            &[&sheet],
1457            &pivot,
1458            "NotAField",
1459            &[("Region".to_string(), "East".to_string())],
1460        )
1461        .unwrap_err();
1462        assert_eq!(err, "#VALUE!");
1463    }
1464
1465    #[test]
1466    fn test_row_and_col_fields_with_subtotals() {
1467        let sheet = source_sheet();
1468        let mut pivot = base_pivot();
1469        pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1470        pivot.col_fields = vec![PivotField::new("Rep")];
1471        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1472
1473        let subtotal_rows: Vec<&PivotBodyRow> = grid
1474            .body_rows
1475            .iter()
1476            .filter(|r| r.row_labels[0].ends_with("Total") && !r.is_grand_total)
1477            .collect();
1478        assert_eq!(subtotal_rows.len(), 2);
1479        assert!(grid.body_rows.last().unwrap().is_grand_total);
1480    }
1481
1482    #[test]
1483    fn test_nested_row_field_second_level_labels_are_not_lost() {
1484        let sheet = source_sheet();
1485        let mut pivot = base_pivot();
1486        pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1487        pivot.grand_totals_row = false;
1488        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1489
1490        let leaf_rows: Vec<&PivotBodyRow> = grid
1491            .body_rows
1492            .iter()
1493            .filter(|r| !r.row_labels[0].ends_with("Total") && !r.is_grand_total)
1494            .collect();
1495        assert_eq!(leaf_rows.len(), 4);
1496        for row in &leaf_rows {
1497            assert!(
1498                !row.row_labels[1].is_empty(),
1499                "expected a Product label on leaf row {:?}, got blank",
1500                row.row_labels
1501            );
1502        }
1503        let products: Vec<&str> = leaf_rows.iter().map(|r| r.row_labels[1].as_str()).collect();
1504        assert!(products.contains(&"Widget"));
1505        assert!(products.contains(&"Gadget"));
1506    }
1507
1508    #[test]
1509    fn test_count_aggregation() {
1510        let sheet = source_sheet();
1511        let mut pivot = base_pivot();
1512        pivot.value_fields = vec![PivotValueField::new("Rep", PivotAggregation::Count)];
1513        pivot.grand_totals_row = false;
1514        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1515        assert_eq!(grid.body_rows.len(), 2);
1516        for row in &grid.body_rows {
1517            assert_eq!(value_at(row, 0), 4.0);
1518        }
1519    }
1520
1521    #[test]
1522    fn test_filter_field_restricts_records() {
1523        let sheet = source_sheet();
1524        let mut pivot = base_pivot();
1525        pivot.filter_fields = vec![PivotFilterField {
1526            column: "Product".to_string(),
1527            selected_values: Some(vec!["Widget".to_string()]),
1528            multiple_selection: true,
1529        }];
1530        pivot.grand_totals_row = false;
1531        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1532        assert_eq!(grid.body_rows.len(), 2);
1533        assert_eq!(value_at(&grid.body_rows[0], 0), 30.0);
1534        assert_eq!(value_at(&grid.body_rows[1], 0), 55.0);
1535    }
1536
1537    #[test]
1538    fn test_filter_field_selection_matches_case_insensitively() {
1539        let mut sheet = Sheet::new(SheetInit {
1540            name: Some("Data".to_string()),
1541            rows: 4,
1542            cols: 2,
1543            ..Default::default()
1544        });
1545        for (c, h) in ["Mixed", "Amount"].iter().enumerate() {
1546            sheet.set_cell_src(0, c, h.to_string());
1547        }
1548        let rows: [[&str; 2]; 3] = [["East", "10"], ["east", "20"], ["West", "30"]];
1549        for (r, row) in rows.iter().enumerate() {
1550            for (c, v) in row.iter().enumerate() {
1551                sheet.set_cell_src(r + 1, c, v.to_string());
1552            }
1553        }
1554        sheet.commit(None).unwrap();
1555        sheet
1556            .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
1557            .unwrap();
1558
1559        let mut pivot = base_pivot();
1560        pivot.source = PivotSource::Table {
1561            name: "Sales".to_string(),
1562        };
1563        pivot.row_fields = vec![];
1564        pivot.value_fields = vec![PivotValueField::new("Amount", PivotAggregation::Sum)];
1565        pivot.filter_fields = vec![PivotFilterField {
1566            column: "Mixed".to_string(),
1567            selected_values: Some(vec!["east".to_string()]),
1568            multiple_selection: true,
1569        }];
1570        pivot.grand_totals_row = false;
1571        pivot.grand_totals_col = false;
1572        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1573        assert_eq!(value_at(&grid.body_rows[0], 0), 30.0);
1574    }
1575
1576    #[test]
1577    fn test_no_filter_fields_means_no_reserved_rows() {
1578        let sheet = source_sheet();
1579        let pivot = base_pivot();
1580        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1581        assert!(grid.filter_rows.is_empty());
1582        assert_eq!(grid.grid_row_offset(), 0);
1583        assert_eq!(grid.height(), grid.header_rows.len() + grid.body_rows.len());
1584    }
1585
1586    #[test]
1587    fn test_filter_field_state_label_all_vs_multiple_items() {
1588        let sheet = source_sheet();
1589        let mut pivot = base_pivot();
1590        pivot.filter_fields = vec![PivotFilterField {
1591            column: "Product".to_string(),
1592            selected_values: None,
1593            multiple_selection: true,
1594        }];
1595
1596        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1597        assert_eq!(
1598            grid.filter_rows,
1599            vec![("Product".to_string(), "(All)".to_string())]
1600        );
1601        assert_eq!(grid.grid_row_offset(), 2);
1602
1603        pivot.filter_fields[0].selected_values =
1604            Some(vec!["Widget".to_string(), "Gadget".to_string()]);
1605        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1606        assert_eq!(grid.filter_rows[0].1, "(All)");
1607
1608        pivot.filter_fields[0].selected_values = Some(vec!["Widget".to_string()]);
1609        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1610        assert_eq!(grid.filter_rows[0].1, "(Multiple Items)");
1611
1612        pivot.filter_fields[0].multiple_selection = false;
1613        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1614        assert_eq!(grid.filter_rows[0].1, "Widget");
1615    }
1616
1617    #[test]
1618    fn test_col_axis_subtotal_group_gets_total_caption_and_grand_total_stays_outermost() {
1619        let sheet = source_sheet();
1620        let mut pivot = base_pivot();
1621        pivot.row_fields = vec![PivotField::new("Rep")];
1622        pivot.col_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1623        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1624
1625        let region_row = &grid.header_rows[1];
1626        assert!(region_row.contains(&"East Total".to_string()));
1627        assert!(region_row.contains(&"West Total".to_string()));
1628        assert!(region_row.contains(&"Grand Total".to_string()));
1629        let product_row = &grid.header_rows[2];
1630        assert_eq!(product_row.last().unwrap(), "");
1631    }
1632
1633    #[test]
1634    fn test_col_axis_subtotal_caption_uses_value_field_label_with_multiple_value_fields() {
1635        let sheet = source_sheet();
1636        let mut pivot = base_pivot();
1637        pivot.row_fields = vec![];
1638        pivot.col_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1639        pivot.value_fields = vec![
1640            PivotValueField::new("Amount", PivotAggregation::Min),
1641            PivotValueField::new("Amount", PivotAggregation::Sum),
1642        ];
1643        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1644
1645        let region_row = &grid.header_rows[1];
1646        assert!(region_row.contains(&"East Min of Amount".to_string()));
1647        assert!(region_row.contains(&"East Sum of Amount".to_string()));
1648        assert!(region_row.contains(&"West Min of Amount".to_string()));
1649        assert!(region_row.contains(&"West Sum of Amount".to_string()));
1650        assert!(
1651            !region_row
1652                .iter()
1653                .any(|c| c == "East Total" || c == "West Total")
1654        );
1655
1656        let value_label_row = grid.header_rows.last().unwrap();
1657        assert!(value_label_row.contains(&"Min of Amount".to_string()));
1658        assert!(value_label_row.contains(&"Sum of Amount".to_string()));
1659        let east_subtotal_idx = region_row
1660            .iter()
1661            .position(|c| c == "East Min of Amount")
1662            .unwrap();
1663        assert_eq!(value_label_row[east_subtotal_idx], "");
1664        assert_eq!(value_label_row[east_subtotal_idx + 1], "");
1665    }
1666
1667    #[test]
1668    fn test_col_axis_repeated_leaf_value_under_different_parents_is_not_falsely_merged() {
1669        let mut sheet = Sheet::new(SheetInit {
1670            name: Some("Data".to_string()),
1671            rows: 3,
1672            cols: 3,
1673            ..Default::default()
1674        });
1675        for (c, h) in ["Group", "Sub", "Amount"].iter().enumerate() {
1676            sheet.set_cell_src(0, c, h.to_string());
1677        }
1678        let rows: [[&str; 3]; 2] = [["GroupA", "X", "1"], ["GroupB", "X", "2"]];
1679        for (r, row) in rows.iter().enumerate() {
1680            for (c, v) in row.iter().enumerate() {
1681                sheet.set_cell_src(r + 1, c, v.to_string());
1682            }
1683        }
1684        sheet.commit(None).unwrap();
1685        sheet
1686            .add_table("Sales".to_string(), 0, 0, 2, 2, true, false)
1687            .unwrap();
1688
1689        let mut pivot = base_pivot();
1690        pivot.row_fields = vec![];
1691        pivot.col_fields = vec![PivotField::new("Group"), PivotField::new("Sub")];
1692        pivot.grand_totals_col = false;
1693        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1694
1695        let sub_row = &grid.header_rows[2];
1696        let x_count = sub_row.iter().filter(|c| *c == "X").count();
1697        assert_eq!(
1698            x_count, 2,
1699            "expected \"X\" under both GroupA and GroupB, got {sub_row:?}"
1700        );
1701    }
1702
1703    #[test]
1704    fn test_multiple_value_fields_become_column_labels() {
1705        let sheet = source_sheet();
1706        let mut pivot = base_pivot();
1707        pivot.value_fields = vec![
1708            PivotValueField::new("Amount", PivotAggregation::Sum),
1709            PivotValueField::new("Amount", PivotAggregation::Count),
1710        ];
1711        pivot.grand_totals_row = false;
1712        pivot.grand_totals_col = false;
1713        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1714        assert_eq!(grid.header_rows.last().unwrap()[1], "Sum of Amount");
1715        assert_eq!(grid.header_rows.last().unwrap()[2], "Count of Amount2");
1716        assert_eq!(grid.body_rows[0].values.len(), 2);
1717        assert_eq!(value_at(&grid.body_rows[0], 0), 50.0);
1718        assert_eq!(value_at(&grid.body_rows[0], 1), 4.0);
1719    }
1720
1721    #[test]
1722    fn test_row_labels_caption_replaces_outermost_row_field_name() {
1723        let sheet = source_sheet();
1724        let pivot = base_pivot();
1725        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1726        assert_eq!(grid.header_rows.last().unwrap()[0], "Row Labels");
1727    }
1728
1729    #[test]
1730    fn test_column_labels_row_prepended_and_deeper_row_field_keeps_its_name() {
1731        let sheet = source_sheet();
1732        let mut pivot = base_pivot();
1733        pivot.row_fields = vec![PivotField::new("Region"), PivotField::new("Product")];
1734        pivot.col_fields = vec![PivotField::new("Rep")];
1735        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1736
1737        assert!(grid.header_rows[0].iter().any(|c| c == "Column Labels"));
1738        let last = grid.header_rows.last().unwrap();
1739        assert_eq!(last[0], "Row Labels");
1740        assert_eq!(last[1], "Product");
1741    }
1742
1743    #[test]
1744    fn test_grand_total_column_shows_total_prefixed_value_label_with_multiple_value_fields() {
1745        let sheet = source_sheet();
1746        let mut pivot = base_pivot();
1747        pivot.col_fields = vec![PivotField::new("Product")];
1748        pivot.value_fields = vec![
1749            PivotValueField::new("Amount", PivotAggregation::Sum),
1750            PivotValueField::new("Amount", PivotAggregation::Min),
1751        ];
1752        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1753
1754        let col_values_row = &grid.header_rows[1];
1755        assert!(col_values_row.contains(&"Total Sum of Amount".to_string()));
1756        assert!(col_values_row.contains(&"Total Min of Amount2".to_string()));
1757        assert_eq!(grid.header_rows.last().unwrap().last().unwrap(), "");
1758    }
1759
1760    #[test]
1761    fn test_grand_total_still_shows_with_only_one_leaf_group() {
1762        let sheet = source_sheet();
1763        let mut pivot = base_pivot();
1764        pivot.filter_fields = vec![PivotFilterField {
1765            column: "Region".to_string(),
1766            selected_values: Some(vec!["East".to_string()]),
1767            multiple_selection: true,
1768        }];
1769        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1770        assert!(grid.body_rows.iter().any(|r| r.is_grand_total));
1771    }
1772
1773    #[test]
1774    fn test_case_variant_values_merge_using_globally_first_seen_casing() {
1775        let mut sheet = Sheet::new(SheetInit {
1776            name: Some("Data".to_string()),
1777            rows: 5,
1778            cols: 3,
1779            ..Default::default()
1780        });
1781        for (c, h) in ["Group", "Mixed", "Amount"].iter().enumerate() {
1782            sheet.set_cell_src(0, c, h.to_string());
1783        }
1784        let rows: [[&str; 3]; 3] = [
1785            ["G1", "EAST", "10"],
1786            ["G1", "West", "20"],
1787            ["G2", "east", "30"],
1788        ];
1789        for (r, row) in rows.iter().enumerate() {
1790            for (c, v) in row.iter().enumerate() {
1791                sheet.set_cell_src(r + 1, c, v.to_string());
1792            }
1793        }
1794        sheet.commit(None).unwrap();
1795        sheet
1796            .add_table("Sales".to_string(), 0, 0, 3, 2, true, false)
1797            .unwrap();
1798
1799        let mut pivot = base_pivot();
1800        pivot.row_fields = vec![PivotField::new("Group"), PivotField::new("Mixed")];
1801        pivot.grand_totals_row = false;
1802        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1803
1804        let mixed_labels: Vec<&str> = grid
1805            .body_rows
1806            .iter()
1807            .map(|r| r.row_labels[1].as_str())
1808            .filter(|l| !l.is_empty())
1809            .collect();
1810        assert!(
1811            mixed_labels.contains(&"EAST") && !mixed_labels.contains(&"east"),
1812            "expected every occurrence to use the globally first-seen casing \"EAST\", got {mixed_labels:?}"
1813        );
1814    }
1815
1816    #[test]
1817    fn test_case_canonicalization_uses_first_seen_casing_from_unfiltered_source_not_just_surviving_rows()
1818     {
1819        let mut sheet = Sheet::new(SheetInit {
1820            name: Some("Data".to_string()),
1821            rows: 4,
1822            cols: 3,
1823            ..Default::default()
1824        });
1825        for (c, h) in ["Cat", "Mixed", "Amount"].iter().enumerate() {
1826            sheet.set_cell_src(0, c, h.to_string());
1827        }
1828        let rows: [[&str; 3]; 3] = [
1829            ["Alpha", "WEST", "10"],
1830            ["Beta", "East", "20"],
1831            ["Beta", "west", "30"],
1832        ];
1833        for (r, row) in rows.iter().enumerate() {
1834            for (c, v) in row.iter().enumerate() {
1835                sheet.set_cell_src(r + 1, c, v.to_string());
1836            }
1837        }
1838        sheet.commit(None).unwrap();
1839        sheet
1840            .add_table("Sales".to_string(), 0, 0, 3, 2, true, false)
1841            .unwrap();
1842
1843        let mut pivot = base_pivot();
1844        pivot.row_fields = vec![PivotField::new("Mixed")];
1845        pivot.filter_fields = vec![PivotFilterField {
1846            column: "Cat".to_string(),
1847            selected_values: Some(vec!["Beta".to_string()]),
1848            multiple_selection: true,
1849        }];
1850        pivot.grand_totals_row = false;
1851        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1852
1853        let labels: Vec<&str> = grid
1854            .body_rows
1855            .iter()
1856            .map(|r| r.row_labels[0].as_str())
1857            .collect();
1858        assert!(
1859            labels.contains(&"WEST") && !labels.contains(&"west"),
1860            "expected the filtered-out row's casing \"WEST\" to still win, got {labels:?}"
1861        );
1862    }
1863
1864    #[test]
1865    fn test_blank_group_sorts_last_even_among_numeric_siblings() {
1866        let mut sheet = Sheet::new(SheetInit {
1867            name: Some("Data".to_string()),
1868            rows: 4,
1869            cols: 2,
1870            ..Default::default()
1871        });
1872        for (c, h) in ["Code", "Amount"].iter().enumerate() {
1873            sheet.set_cell_src(0, c, h.to_string());
1874        }
1875        sheet.set_cell_src(1, 0, "30".to_string());
1876        sheet.set_cell_src(1, 1, "1".to_string());
1877        sheet.set_cell_src(3, 0, "10".to_string());
1878        sheet.set_cell_src(3, 1, "3".to_string());
1879        sheet.commit(None).unwrap();
1880        sheet
1881            .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
1882            .unwrap();
1883
1884        let mut pivot = base_pivot();
1885        pivot.row_fields = vec![PivotField::new("Code")];
1886        pivot.grand_totals_row = false;
1887        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1888
1889        let codes: Vec<&str> = grid
1890            .body_rows
1891            .iter()
1892            .map(|r| r.row_labels[0].as_str())
1893            .collect();
1894        assert_eq!(codes, vec!["10", "30", "(blank)"]);
1895    }
1896
1897    #[test]
1898    fn test_negative_looking_text_sorts_last_among_text_siblings() {
1899        let mut sheet = Sheet::new(SheetInit {
1900            name: Some("Data".to_string()),
1901            rows: 6,
1902            cols: 2,
1903            ..Default::default()
1904        });
1905        for (c, h) in ["Code", "Amount"].iter().enumerate() {
1906            sheet.set_cell_src(0, c, h.to_string());
1907        }
1908        let rows: [(&str, &str); 5] = [
1909            ("\"-7\"", "1"),
1910            ("\".0152\"", "2"),
1911            ("\"13\"", "3"),
1912            ("\"34\"", "4"),
1913            ("\"4\"", "5"),
1914        ];
1915        for (r, (code, amount)) in rows.iter().enumerate() {
1916            sheet.set_cell_src(r + 1, 0, code.to_string());
1917            sheet.set_cell_src(r + 1, 1, amount.to_string());
1918        }
1919        sheet.commit(None).unwrap();
1920        sheet
1921            .add_table("Sales".to_string(), 0, 0, 5, 1, true, false)
1922            .unwrap();
1923
1924        let mut pivot = base_pivot();
1925        pivot.row_fields = vec![PivotField::new("Code")];
1926        pivot.grand_totals_row = false;
1927        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1928
1929        let codes: Vec<&str> = grid
1930            .body_rows
1931            .iter()
1932            .map(|r| r.row_labels[0].as_str())
1933            .collect();
1934        assert_eq!(codes, vec![".0152", "13", "34", "4", "-7"]);
1935    }
1936
1937    #[test]
1938    fn test_negative_looking_text_sorts_by_stripped_digits_not_last() {
1939        let mut sheet = Sheet::new(SheetInit {
1940            name: Some("Data".to_string()),
1941            rows: 4,
1942            cols: 2,
1943            ..Default::default()
1944        });
1945        for (c, h) in ["Code", "Amount"].iter().enumerate() {
1946            sheet.set_cell_src(0, c, h.to_string());
1947        }
1948        let rows: [(&str, &str); 3] = [("\"12\"", "1"), ("\"37\"", "2"), ("\"-25\"", "3")];
1949        for (r, (code, amount)) in rows.iter().enumerate() {
1950            sheet.set_cell_src(r + 1, 0, code.to_string());
1951            sheet.set_cell_src(r + 1, 1, amount.to_string());
1952        }
1953        sheet.commit(None).unwrap();
1954        sheet
1955            .add_table("Sales".to_string(), 0, 0, 3, 1, true, false)
1956            .unwrap();
1957
1958        let mut pivot = base_pivot();
1959        pivot.row_fields = vec![PivotField::new("Code")];
1960        pivot.grand_totals_row = false;
1961        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
1962
1963        let codes: Vec<&str> = grid
1964            .body_rows
1965            .iter()
1966            .map(|r| r.row_labels[0].as_str())
1967            .collect();
1968        assert_eq!(codes, vec!["12", "-25", "37"]);
1969    }
1970
1971    #[test]
1972    fn test_empty_row_col_intersection_renders_blank_not_zero_or_error() {
1973        let mut sheet = Sheet::new(SheetInit {
1974            name: Some("Data".to_string()),
1975            rows: 3,
1976            cols: 3,
1977            ..Default::default()
1978        });
1979        for (c, h) in ["Region", "Product", "Amount"].iter().enumerate() {
1980            sheet.set_cell_src(0, c, h.to_string());
1981        }
1982        let rows: [[&str; 3]; 2] = [["East", "Widget", "10"], ["West", "Gadget", "20"]];
1983        for (r, row) in rows.iter().enumerate() {
1984            for (c, v) in row.iter().enumerate() {
1985                sheet.set_cell_src(r + 1, c, v.to_string());
1986            }
1987        }
1988        sheet.commit(None).unwrap();
1989        sheet
1990            .add_table("Sales".to_string(), 0, 0, 2, 2, true, false)
1991            .unwrap();
1992
1993        let mut pivot = base_pivot();
1994        pivot.col_fields = vec![PivotField::new("Product")];
1995        pivot.value_fields = vec![
1996            PivotValueField::new("Amount", PivotAggregation::Sum),
1997            PivotValueField::new("Amount", PivotAggregation::Average),
1998        ];
1999        pivot.grand_totals_row = false;
2000        pivot.grand_totals_col = false;
2001        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2002
2003        let east_row = grid
2004            .body_rows
2005            .iter()
2006            .find(|r| r.row_labels[0] == "East")
2007            .unwrap();
2008        for v in &east_row.values[..2] {
2009            assert!(
2010                matches!(v, ResultData::None),
2011                "expected blank for an empty intersection, got {v:?}"
2012            );
2013        }
2014    }
2015
2016    #[test]
2017    fn test_value_field_labels_distinct_aggregations_without_sum_stay_unsuffixed() {
2018        let fields = vec![
2019            PivotValueField::new("Amount", PivotAggregation::Count),
2020            PivotValueField::new("Amount", PivotAggregation::Max),
2021        ];
2022        assert_eq!(
2023            value_field_labels(&fields),
2024            vec!["Count of Amount".to_string(), "Max of Amount".to_string()]
2025        );
2026    }
2027
2028    #[test]
2029    fn test_value_field_labels_sum_clones_column_for_later_fields() {
2030        let fields = vec![
2031            PivotValueField::new("Amount", PivotAggregation::Sum),
2032            PivotValueField::new("Rate", PivotAggregation::Average),
2033            PivotValueField::new("Amount", PivotAggregation::Min),
2034            PivotValueField::new("Amount", PivotAggregation::Max),
2035        ];
2036        assert_eq!(
2037            value_field_labels(&fields),
2038            vec![
2039                "Sum of Amount".to_string(),
2040                "Average of Rate".to_string(),
2041                "Min of Amount2".to_string(),
2042                "Max of Amount2".to_string(),
2043            ]
2044        );
2045    }
2046
2047    #[test]
2048    fn test_value_field_labels_second_sum_clones_again() {
2049        let fields = vec![
2050            PivotValueField::new("Amount", PivotAggregation::Sum),
2051            PivotValueField::new("Amount", PivotAggregation::Sum),
2052            PivotValueField::new("Amount", PivotAggregation::Count),
2053        ];
2054        assert_eq!(
2055            value_field_labels(&fields),
2056            vec![
2057                "Sum of Amount".to_string(),
2058                "Sum of Amount2".to_string(),
2059                "Count of Amount3".to_string(),
2060            ]
2061        );
2062    }
2063
2064    #[test]
2065    fn test_value_field_labels_disambiguates_identical_aggregation_and_column() {
2066        let fields = vec![
2067            PivotValueField::new("Amount", PivotAggregation::Sum),
2068            PivotValueField::new("Amount", PivotAggregation::Sum),
2069            PivotValueField::new("Amount", PivotAggregation::Sum),
2070        ];
2071        assert_eq!(
2072            value_field_labels(&fields),
2073            vec![
2074                "Sum of Amount".to_string(),
2075                "Sum of Amount2".to_string(),
2076                "Sum of Amount3".to_string(),
2077            ]
2078        );
2079    }
2080
2081    #[test]
2082    fn test_value_field_labels_collision_within_sum_clone_uses_underscore_suffix() {
2083        let fields = vec![
2084            PivotValueField::new("Amount", PivotAggregation::Sum),
2085            PivotValueField::new("Amount", PivotAggregation::Max),
2086            PivotValueField::new("Amount", PivotAggregation::Max),
2087        ];
2088        assert_eq!(
2089            value_field_labels(&fields),
2090            vec![
2091                "Sum of Amount".to_string(),
2092                "Max of Amount2".to_string(),
2093                "Max of Amount2_2".to_string(),
2094            ]
2095        );
2096    }
2097
2098    #[test]
2099    fn test_value_field_labels_count_numbers_shares_plain_count_caption() {
2100        let fields = vec![
2101            PivotValueField::new("Rate", PivotAggregation::CountNumbers),
2102            PivotValueField::new("Rate", PivotAggregation::Count),
2103        ];
2104        assert_eq!(
2105            value_field_labels(&fields),
2106            vec!["Count of Rate".to_string(), "Count of Rate2".to_string()]
2107        );
2108    }
2109
2110    #[test]
2111    fn test_value_field_labels_leaves_custom_name_untouched() {
2112        let mut fields = vec![
2113            PivotValueField::new("Amount", PivotAggregation::Sum),
2114            PivotValueField::new("Amount", PivotAggregation::Min),
2115        ];
2116        fields[1].custom_name = Some("Lowest Amount".to_string());
2117        assert_eq!(
2118            value_field_labels(&fields),
2119            vec!["Sum of Amount".to_string(), "Lowest Amount".to_string()]
2120        );
2121    }
2122
2123    #[test]
2124    fn test_flat_pivot_with_no_row_or_col_fields_has_no_reserved_label_column() {
2125        let sheet = source_sheet();
2126        let mut pivot = base_pivot();
2127        pivot.row_fields = vec![];
2128        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2129
2130        assert_eq!(grid.width, 1);
2131        assert_eq!(
2132            grid.header_rows.last().unwrap(),
2133            &vec!["Sum of Amount".to_string()]
2134        );
2135        assert_eq!(grid.body_rows.len(), 1);
2136        assert!(grid.body_rows[0].row_labels.is_empty());
2137        assert_eq!(value_at(&grid.body_rows[0], 0), 195.0);
2138    }
2139
2140    #[test]
2141    fn test_no_row_fields_with_multiple_value_fields_has_no_reserved_label_column_either() {
2142        let sheet = source_sheet();
2143        let mut pivot = base_pivot();
2144        pivot.row_fields = vec![];
2145        pivot.col_fields = vec![PivotField::new("Product")];
2146        pivot.value_fields = vec![
2147            PivotValueField::new("Amount", PivotAggregation::Sum),
2148            PivotValueField::new("Amount", PivotAggregation::Count),
2149        ];
2150        pivot.grand_totals_col = false;
2151        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2152
2153        assert_eq!(grid.width, 4);
2154        assert_eq!(grid.body_rows.len(), 1);
2155        assert!(grid.body_rows[0].row_labels.is_empty());
2156    }
2157
2158    #[test]
2159    fn test_multiple_value_fields_with_no_column_fields_share_one_header_row() {
2160        let sheet = source_sheet();
2161        let mut pivot = base_pivot();
2162        pivot.value_fields = vec![
2163            PivotValueField::new("Amount", PivotAggregation::Sum),
2164            PivotValueField::new("Amount", PivotAggregation::Count),
2165        ];
2166        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2167
2168        assert_eq!(grid.header_rows.len(), 1);
2169        assert_eq!(
2170            grid.header_rows[0],
2171            vec![
2172                "Row Labels".to_string(),
2173                "Sum of Amount".to_string(),
2174                "Count of Amount2".to_string(),
2175            ]
2176        );
2177    }
2178
2179    #[test]
2180    fn test_missing_column_errors() {
2181        let sheet = source_sheet();
2182        let mut pivot = base_pivot();
2183        pivot.row_fields = vec![PivotField::new("Nope")];
2184        let err = compute_pivot(&[&sheet], &pivot).unwrap_err();
2185        assert!(err.contains("not found"));
2186    }
2187
2188    #[test]
2189    fn test_range_source_matches_table_source() {
2190        let sheet = source_sheet();
2191        let mut pivot = base_pivot();
2192        pivot.source = PivotSource::Range {
2193            sheet_id: sheet.id,
2194            start_row: 0,
2195            start_col: 0,
2196            end_row: 8,
2197            end_col: 3,
2198        };
2199        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2200        assert_eq!(grid.body_rows.len(), 3);
2201        assert_eq!(value_at(&grid.body_rows[0], 0), 50.0);
2202        assert_eq!(value_at(&grid.body_rows[1], 0), 145.0);
2203        assert_eq!(value_at(&grid.body_rows[2], 0), 195.0);
2204    }
2205
2206    #[test]
2207    fn test_zero_data_rows_produces_empty_grid_without_panicking() {
2208        let mut sheet = Sheet::new(SheetInit {
2209            name: Some("Empty".to_string()),
2210            rows: 1,
2211            cols: 2,
2212            ..Default::default()
2213        });
2214        sheet.set_cell_src(0, 0, "Region".to_string());
2215        sheet.set_cell_src(0, 1, "Amount".to_string());
2216        sheet.commit(None).unwrap();
2217        sheet
2218            .add_table("Empty".to_string(), 0, 0, 0, 1, true, false)
2219            .unwrap();
2220
2221        let pivot = PivotTable {
2222            id: 1,
2223            name: "EmptyPivot".to_string(),
2224            source: PivotSource::Table {
2225                name: "Empty".to_string(),
2226            },
2227            dest_sheet_id: sheet.id,
2228            dest_row: 0,
2229            dest_col: 0,
2230            row_fields: vec![PivotField::new("Region")],
2231            col_fields: vec![],
2232            value_fields: vec![PivotValueField::new("Amount", PivotAggregation::Sum)],
2233            filter_fields: vec![],
2234            grand_totals_row: true,
2235            grand_totals_col: true,
2236            last_output_end_row: None,
2237            last_output_end_col: None,
2238        };
2239        let grid = compute_pivot(&[&sheet], &pivot).unwrap();
2240        assert!(grid.body_rows.is_empty());
2241        assert!(grid.row_axis.is_empty());
2242    }
2243
2244    use rand::rngs::StdRng;
2245    use rand::{Rng, SeedableRng};
2246
2247    const FUZZ_COLS: [&str; 6] = ["Cat", "Mixed", "NumStr", "Amount", "Rate", "Flag"];
2248    const FUZZ_CATEGORIES: [&str; 5] = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
2249    const FUZZ_CASE_VARIANTS: [&str; 5] = ["East", "east", "WEST", "west", "North"];
2250
2251    /// Builds a random source sheet with columns chosen to exercise
2252    /// grouping edge cases: a low-cardinality category column with
2253    /// occasional blanks, a case-variant category column (case-insensitive
2254    /// grouping parity), a quoted numeric-looking-string column (the
2255    /// numeric-vs-text sort ambiguity `sort_group_entries` has to resolve),
2256    /// two numeric columns (ints and floats, including negative/zero), and
2257    /// a boolean column (ignored by Sum/Average/Max/Min).
2258    fn fuzz_source_sheet(rng: &mut StdRng, num_rows: usize) -> (Sheet, Vec<String>) {
2259        let mut sheet = Sheet::new(SheetInit {
2260            name: Some("FuzzData".to_string()),
2261            rows: num_rows + 1,
2262            cols: FUZZ_COLS.len(),
2263            ..Default::default()
2264        });
2265        for (c, h) in FUZZ_COLS.iter().enumerate() {
2266            sheet.set_cell_src(0, c, h.to_string());
2267        }
2268        for r in 0..num_rows {
2269            let cat = if rng.gen_bool(0.1) {
2270                String::new()
2271            } else {
2272                FUZZ_CATEGORIES[rng.gen_range(0..FUZZ_CATEGORIES.len())].to_string()
2273            };
2274            sheet.set_cell_src(r + 1, 0, cat);
2275
2276            let mixed = FUZZ_CASE_VARIANTS[rng.gen_range(0..FUZZ_CASE_VARIANTS.len())].to_string();
2277            sheet.set_cell_src(r + 1, 1, mixed);
2278
2279            let numstr = match rng.gen_range(0u8..4u8) {
2280                0 => String::new(),
2281                1 => format!("\"0{}\"", rng.gen_range(0u32..10u32)),
2282                2 => format!("\".0{}\"", rng.gen_range(0u32..1000u32)),
2283                _ => format!("\"{}\"", rng.gen_range(-50i64..50i64)),
2284            };
2285            sheet.set_cell_src(r + 1, 2, numstr);
2286
2287            sheet.set_cell_src(r + 1, 3, rng.gen_range(-100i64..=100i64).to_string());
2288
2289            let rate =
2290                (rng.gen_range(-500i64..=500i64) as f64) / (rng.gen_range(1i64..=100i64) as f64);
2291            sheet.set_cell_src(r + 1, 4, format!("{:.4}", rate));
2292
2293            sheet.set_cell_src(r + 1, 5, rng.gen_bool(0.5).to_string());
2294        }
2295        sheet.commit(None).unwrap();
2296        (sheet, FUZZ_COLS.iter().map(|s| s.to_string()).collect())
2297    }
2298
2299    fn random_aggregation(rng: &mut StdRng) -> PivotAggregation {
2300        match rng.gen_range(0u8..6u8) {
2301            0 => PivotAggregation::Sum,
2302            1 => PivotAggregation::Count,
2303            2 => PivotAggregation::CountNumbers,
2304            3 => PivotAggregation::Average,
2305            4 => PivotAggregation::Max,
2306            _ => PivotAggregation::Min,
2307        }
2308    }
2309
2310    /// Builds a random, always-valid `PivotTable` config over `sheet`:
2311    /// 0-2 row fields and 0-2 col fields (drawn without replacement from
2312    /// the categorical columns), 1-2 value fields (from the numeric
2313    /// columns), an optional filter field with a random subset of its
2314    /// actual distinct values selected (including the all-excluded case),
2315    /// and random per-field subtotal / grand-total toggles.
2316    fn fuzz_pivot_config(
2317        rng: &mut StdRng,
2318        sheet: &Sheet,
2319        col_names: &[String],
2320        num_rows: usize,
2321        use_table: bool,
2322    ) -> PivotTable {
2323        let mut pool: Vec<usize> = vec![0, 1, 2];
2324        let numeric: [usize; 2] = [3, 4];
2325
2326        let n_row = rng.gen_range(0..=pool.len().min(2));
2327        let row_cols: Vec<usize> = (0..n_row)
2328            .map(|_| pool.remove(rng.gen_range(0..pool.len())))
2329            .collect();
2330        let n_col = rng.gen_range(0..=pool.len().min(2));
2331        let col_cols: Vec<usize> = (0..n_col)
2332            .map(|_| pool.remove(rng.gen_range(0..pool.len())))
2333            .collect();
2334
2335        let row_fields: Vec<PivotField> = row_cols
2336            .iter()
2337            .map(|&i| PivotField {
2338                column: col_names[i].clone(),
2339                subtotal: rng.gen_bool(0.7),
2340            })
2341            .collect();
2342        let col_fields: Vec<PivotField> = col_cols
2343            .iter()
2344            .map(|&i| PivotField {
2345                column: col_names[i].clone(),
2346                subtotal: rng.gen_bool(0.7),
2347            })
2348            .collect();
2349
2350        let n_value = rng.gen_range(1..=2);
2351        let value_fields: Vec<PivotValueField> = (0..n_value)
2352            .map(|_| {
2353                let col = numeric[rng.gen_range(0..numeric.len())];
2354                PivotValueField::new(col_names[col].clone(), random_aggregation(rng))
2355            })
2356            .collect();
2357
2358        let mut filter_fields = Vec::new();
2359        if rng.gen_bool(0.5) {
2360            let candidates = [0usize, 1, 2, 5];
2361            let fcol = candidates[rng.gen_range(0..candidates.len())];
2362            let mut distinct: Vec<String> = (1..=num_rows)
2363                .map(|r| group_key(&sheet.get_result_data(&CellRef::new(r, fcol))))
2364                .collect();
2365            distinct.sort();
2366            distinct.dedup();
2367            let selected = if distinct.is_empty() || rng.gen_bool(0.2) {
2368                None
2369            } else {
2370                Some(distinct.into_iter().filter(|_| rng.gen_bool(0.5)).collect())
2371            };
2372            filter_fields.push(PivotFilterField {
2373                column: col_names[fcol].clone(),
2374                selected_values: selected,
2375                multiple_selection: true,
2376            });
2377        }
2378
2379        let source = if use_table {
2380            PivotSource::Table {
2381                name: "FuzzTable".to_string(),
2382            }
2383        } else {
2384            PivotSource::Range {
2385                sheet_id: sheet.id,
2386                start_row: 0,
2387                start_col: 0,
2388                end_row: num_rows,
2389                end_col: col_names.len() - 1,
2390            }
2391        };
2392
2393        PivotTable {
2394            id: 1,
2395            name: "FuzzPivot".to_string(),
2396            source,
2397            dest_sheet_id: sheet.id,
2398            dest_row: num_rows + 20,
2399            dest_col: 0,
2400            row_fields,
2401            col_fields,
2402            value_fields,
2403            filter_fields,
2404            grand_totals_row: rng.gen_bool(0.7),
2405            grand_totals_col: rng.gen_bool(0.7),
2406            last_output_end_row: None,
2407            last_output_end_col: None,
2408        }
2409    }
2410
2411    fn results_close(a: &ResultData, b: &ResultData) -> bool {
2412        match (a, b) {
2413            (ResultData::Integer(x), ResultData::Integer(y)) => x == y,
2414            (ResultData::Float(x), ResultData::Float(y)) => (x - y).abs() < 1e-6,
2415            (ResultData::Integer(x), ResultData::Float(y))
2416            | (ResultData::Float(y), ResultData::Integer(x)) => (*x as f64 - y).abs() < 1e-6,
2417            (ResultData::None, ResultData::None) => true,
2418            (ResultData::Error(x), ResultData::Error(y)) => x == y,
2419            (ResultData::String(x), ResultData::String(y)) => x == y,
2420            (ResultData::Boolean(x), ResultData::Boolean(y)) => x == y,
2421            _ => false,
2422        }
2423    }
2424
2425    /// A row/col axis label vector (`Some` per own depth, `None` past it --
2426    /// see `FlatGroup`) is a *partial key*: `None` positions are wildcards.
2427    /// This is exactly what a subtotal or grand-total group represents, so
2428    /// the same matcher works uniformly for leaf, subtotal, and grand-total
2429    /// groups.
2430    fn matches_partial(key: &[String], labels: &[Option<String>]) -> bool {
2431        key.iter()
2432            .zip(labels)
2433            .all(|(k, want)| want.as_ref().is_none_or(|w| w.eq_ignore_ascii_case(k)))
2434    }
2435
2436    /// Cross-checks every cell of `grid` against an aggregate computed by a
2437    /// structurally independent path: instead of `compute_pivot`'s
2438    /// recursive group-tree + flatten, this filters the same record set by
2439    /// simple partial-key matching against each axis item's labels. Catches
2440    /// bugs in the tree-based grouping/flattening/subtotal-insertion logic
2441    /// specifically, since the aggregation math itself (`aggregate`) is
2442    /// shared and already covered by the fixed-data tests above.
2443    fn verify_grid_matches_records(sheet: &Sheet, pivot: &PivotTable, grid: &PivotGrid) {
2444        let (_, col_names, sheet_cols, data_rows) =
2445            resolve_source(&[sheet], &pivot.source).unwrap();
2446        let row_idxs: Vec<usize> = pivot
2447            .row_fields
2448            .iter()
2449            .map(|f| column_index(&col_names, &f.column).unwrap())
2450            .collect();
2451        let col_idxs: Vec<usize> = pivot
2452            .col_fields
2453            .iter()
2454            .map(|f| column_index(&col_names, &f.column).unwrap())
2455            .collect();
2456
2457        let mut records: Vec<(Vec<String>, Vec<String>, Vec<ResultData>)> = Vec::new();
2458        'row: for &r in &data_rows {
2459            let row_vals: Vec<ResultData> = sheet_cols
2460                .iter()
2461                .map(|&c| sheet.get_result_data(&CellRef::new(r, c)))
2462                .collect();
2463            for ff in &pivot.filter_fields {
2464                if let Some(selected) = &ff.selected_values {
2465                    let idx = column_index(&col_names, &ff.column).unwrap();
2466                    let key = group_key(&row_vals[idx]);
2467                    if !selected.iter().any(|v| v.eq_ignore_ascii_case(&key)) {
2468                        continue 'row;
2469                    }
2470                }
2471            }
2472            let row_key: Vec<String> = row_idxs.iter().map(|&i| group_key(&row_vals[i])).collect();
2473            let col_key: Vec<String> = col_idxs.iter().map(|&i| group_key(&row_vals[i])).collect();
2474            records.push((row_key, col_key, row_vals));
2475        }
2476
2477        let value_idxs: Vec<usize> = pivot
2478            .value_fields
2479            .iter()
2480            .map(|vf| column_index(&col_names, &vf.column).unwrap())
2481            .collect();
2482        let value_multiplier = if pivot.value_fields.len() > 1 {
2483            pivot.value_fields.len()
2484        } else {
2485            1
2486        };
2487        let width = row_label_width(pivot);
2488
2489        assert_eq!(grid.body_rows.len(), grid.row_axis.len());
2490        assert_eq!(grid.width, width + grid.col_axis.len() * value_multiplier);
2491        for hrow in &grid.header_rows {
2492            assert_eq!(hrow.len(), grid.width);
2493        }
2494
2495        for (i, (body_row, row_axis)) in grid.body_rows.iter().zip(grid.row_axis.iter()).enumerate()
2496        {
2497            assert_eq!(
2498                body_row.is_grand_total, row_axis.is_grand_total,
2499                "row {i} grand-total flag mismatch"
2500            );
2501            assert_eq!(body_row.row_labels.len(), width, "row {i} label width");
2502            assert_eq!(
2503                body_row.values.len(),
2504                grid.col_axis.len() * value_multiplier,
2505                "row {i} value count"
2506            );
2507
2508            for (j, col_axis) in grid.col_axis.iter().enumerate() {
2509                let matching: Vec<&Vec<ResultData>> = records
2510                    .iter()
2511                    .filter(|(rk, ck, _)| {
2512                        matches_partial(rk, &row_axis.labels)
2513                            && matches_partial(ck, &col_axis.labels)
2514                    })
2515                    .map(|(_, _, row)| row)
2516                    .collect();
2517
2518                for (vf_pos, &vidx) in value_idxs.iter().enumerate() {
2519                    if vf_pos > 0 && value_multiplier == 1 {
2520                        break;
2521                    }
2522                    let col_vals: Vec<ResultData> =
2523                        matching.iter().map(|row| row[vidx].clone()).collect();
2524                    let expected =
2525                        aggregate(sheet, &col_vals, pivot.value_fields[vf_pos].aggregation);
2526                    let actual = &body_row.values[j * value_multiplier + vf_pos];
2527                    assert!(
2528                        results_close(&expected, actual),
2529                        "row {i} col {j} value-field {vf_pos}: expected {expected:?}, got {actual:?} \
2530                         (row_labels={:?}, col_labels={:?})",
2531                        row_axis.labels,
2532                        col_axis.labels,
2533                    );
2534                }
2535            }
2536        }
2537    }
2538
2539    /// A grand-total pseudo-group is appended whenever the toggle is on,
2540    /// *except* when the axis has no fields at all (`build_axis`'s
2541    /// no-fields early return never adds one -- there's no separate
2542    /// grouping to total distinctly from the single implicit group).
2543    /// Otherwise Excel shows it regardless of how many real groups exist,
2544    /// even just one (confirmed against real Excel via fuzz/fuzz_pivot.py).
2545    fn verify_grand_total_placement(
2546        axis: &[PivotAxisItem],
2547        grand_total_requested: bool,
2548        axis_has_fields: bool,
2549        label: &str,
2550    ) {
2551        let grand_count = axis.iter().filter(|a| a.is_grand_total).count();
2552        let has_any_real_group = axis.iter().any(|a| !a.is_grand_total);
2553        assert!(grand_count <= 1, "{label}: more than one grand-total group");
2554        if grand_total_requested && axis_has_fields && has_any_real_group {
2555            assert_eq!(
2556                grand_count, 1,
2557                "{label}: expected a grand total to be appended"
2558            );
2559        } else {
2560            assert_eq!(grand_count, 0, "{label}: did not expect a grand total");
2561        }
2562    }
2563
2564    #[test]
2565    fn test_fuzz_pivot_random_invariants() {
2566        for seed in 0u64..300 {
2567            let mut rng: StdRng = SeedableRng::seed_from_u64(seed);
2568            let use_table = seed % 2 == 0;
2569            let num_rows = rng.gen_range(0..=40usize);
2570            let (mut sheet, col_names) = fuzz_source_sheet(&mut rng, num_rows);
2571            if use_table {
2572                sheet
2573                    .add_table(
2574                        "FuzzTable".to_string(),
2575                        0,
2576                        0,
2577                        num_rows,
2578                        col_names.len() - 1,
2579                        true,
2580                        false,
2581                    )
2582                    .unwrap();
2583            }
2584            let pivot = fuzz_pivot_config(&mut rng, &sheet, &col_names, num_rows, use_table);
2585
2586            let grid = compute_pivot(&[&sheet], &pivot)
2587                .unwrap_or_else(|e| panic!("seed {seed}: compute_pivot failed: {e}"));
2588
2589            verify_grid_matches_records(&sheet, &pivot, &grid);
2590            verify_grand_total_placement(
2591                &grid.row_axis,
2592                pivot.grand_totals_row,
2593                !pivot.row_fields.is_empty(),
2594                "row axis",
2595            );
2596            verify_grand_total_placement(
2597                &grid.col_axis,
2598                pivot.grand_totals_col,
2599                !pivot.col_fields.is_empty(),
2600                "col axis",
2601            );
2602
2603            let xlsx = crate::core::xlsx::export_xlsx_data(
2604                std::slice::from_ref(&sheet),
2605                &[],
2606                std::slice::from_ref(&pivot),
2607                None,
2608            )
2609            .unwrap_or_else(|e| panic!("seed {seed}: export failed: {e}"));
2610            let (imported_sheets, _, imported_pivots, _) =
2611                crate::core::xlsx::import_xlsx_data(&xlsx, &[], |_, _, _| {})
2612                    .unwrap_or_else(|e| panic!("seed {seed}: import failed: {e}"));
2613            assert_eq!(
2614                imported_pivots.len(),
2615                1,
2616                "seed {seed}: pivot lost on round-trip"
2617            );
2618            let reimported = &imported_pivots[0];
2619
2620            assert_eq!(
2621                reimported
2622                    .row_fields
2623                    .iter()
2624                    .map(|f| &f.column)
2625                    .collect::<Vec<_>>(),
2626                pivot
2627                    .row_fields
2628                    .iter()
2629                    .map(|f| &f.column)
2630                    .collect::<Vec<_>>(),
2631                "seed {seed}: row field columns changed on round-trip"
2632            );
2633            assert_eq!(
2634                reimported
2635                    .col_fields
2636                    .iter()
2637                    .map(|f| &f.column)
2638                    .collect::<Vec<_>>(),
2639                pivot
2640                    .col_fields
2641                    .iter()
2642                    .map(|f| &f.column)
2643                    .collect::<Vec<_>>(),
2644                "seed {seed}: col field columns changed on round-trip"
2645            );
2646            assert_eq!(
2647                reimported
2648                    .value_fields
2649                    .iter()
2650                    .map(|f| (&f.column, f.aggregation))
2651                    .collect::<Vec<_>>(),
2652                pivot
2653                    .value_fields
2654                    .iter()
2655                    .map(|f| (&f.column, f.aggregation))
2656                    .collect::<Vec<_>>(),
2657                "seed {seed}: value fields changed on round-trip"
2658            );
2659            assert_eq!(reimported.grand_totals_row, pivot.grand_totals_row);
2660            assert_eq!(reimported.grand_totals_col, pivot.grand_totals_col);
2661            assert_eq!(
2662                reimported
2663                    .row_fields
2664                    .iter()
2665                    .map(|f| f.subtotal)
2666                    .collect::<Vec<_>>(),
2667                pivot
2668                    .row_fields
2669                    .iter()
2670                    .map(|f| f.subtotal)
2671                    .collect::<Vec<_>>(),
2672                "seed {seed}: row field subtotal toggle should round-trip"
2673            );
2674            assert_eq!(
2675                reimported
2676                    .col_fields
2677                    .iter()
2678                    .map(|f| f.subtotal)
2679                    .collect::<Vec<_>>(),
2680                pivot
2681                    .col_fields
2682                    .iter()
2683                    .map(|f| f.subtotal)
2684                    .collect::<Vec<_>>(),
2685                "seed {seed}: col field subtotal toggle should round-trip"
2686            );
2687            let nothing_lossy = true;
2688            let any_filter_is_also_an_axis_field = pivot.filter_fields.iter().any(|ff| {
2689                pivot
2690                    .row_fields
2691                    .iter()
2692                    .chain(pivot.col_fields.iter())
2693                    .any(|f| f.column.eq_ignore_ascii_case(&ff.column))
2694            });
2695            let reimported_sheets: Vec<Sheet> =
2696                imported_sheets.into_iter().map(|s| s.sheet).collect();
2697            let reimported_sheet_refs: Vec<&Sheet> = reimported_sheets.iter().collect();
2698            let reimported_grid = compute_pivot(&reimported_sheet_refs, reimported)
2699                .unwrap_or_else(|e| panic!("seed {seed}: reimported compute_pivot failed: {e}"));
2700            if nothing_lossy && !any_filter_is_also_an_axis_field {
2701                assert_eq!(
2702                    reimported_grid.body_rows.len(),
2703                    grid.body_rows.len(),
2704                    "seed {seed}: grid shape changed on lossless round-trip"
2705                );
2706            }
2707
2708            let sorted = |f: &PivotFilterField| {
2709                f.selected_values.as_ref().map(|v| {
2710                    let mut v: Vec<String> = v.iter().map(|s| s.to_lowercase()).collect();
2711                    v.sort();
2712                    v.dedup();
2713                    v
2714                })
2715            };
2716            let axis_bound = |column: &str| {
2717                pivot
2718                    .row_fields
2719                    .iter()
2720                    .chain(pivot.col_fields.iter())
2721                    .any(|f| f.column.eq_ignore_ascii_case(column))
2722            };
2723            for (before, after) in pivot
2724                .filter_fields
2725                .iter()
2726                .zip(reimported.filter_fields.iter())
2727            {
2728                if axis_bound(&before.column) {
2729                    continue;
2730                }
2731                if before.selected_values.is_some() && after.selected_values.is_none() {
2732                    assert_eq!(
2733                        reimported_grid.body_rows.len(),
2734                        grid.body_rows.len(),
2735                        "seed {seed}: filter on '{}' was dropped and it mattered",
2736                        before.column
2737                    );
2738                } else {
2739                    assert_eq!(
2740                        sorted(before),
2741                        sorted(after),
2742                        "seed {seed}: filter selection should round-trip for '{}'",
2743                        before.column
2744                    );
2745                }
2746            }
2747        }
2748    }
2749}