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