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