1use crate::config::NumberFormat;
10use crate::pivot::aggregation::AggregationFn;
11use std::collections::HashMap;
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15pub enum PivotZone {
16 Rows,
18 Columns,
20 Values,
22 Filters,
24}
25
26#[derive(Clone, Debug, PartialEq)]
31pub struct PivotConfig {
32 pub row_fields: Vec<usize>,
34 pub column_fields: Vec<usize>,
36 pub value_field: Option<usize>,
39 pub aggregation: AggregationFn,
41 pub filter_fields: Vec<usize>,
44 pub flat_rows: bool,
50 pub show_row_subtotals: bool,
53 pub show_column_subtotals: bool,
55 pub show_row_grand_total: bool,
57 pub show_column_grand_total: bool,
59 pub blank_label: String,
61 pub value_format: Option<NumberFormat>,
64 pub field_formats: HashMap<usize, NumberFormat>,
71}
72
73impl Default for PivotConfig {
74 fn default() -> Self {
75 Self {
76 row_fields: vec![],
77 column_fields: vec![],
78 value_field: None,
79 aggregation: AggregationFn::default(),
80 filter_fields: vec![],
81 flat_rows: false,
82 show_row_subtotals: true,
83 show_column_subtotals: false,
84 show_row_grand_total: true,
85 show_column_grand_total: true,
86 blank_label: "(blank)".into(),
87 value_format: None,
88 field_formats: HashMap::new(),
89 }
90 }
91}
92
93impl PivotConfig {
94 #[must_use]
97 pub fn is_ready(&self) -> bool {
98 self.value_field.is_some()
99 && (!self.row_fields.is_empty() || !self.column_fields.is_empty())
100 }
101
102 #[must_use]
104 pub fn zone_of(&self, field: usize) -> Option<PivotZone> {
105 if self.row_fields.contains(&field) {
106 Some(PivotZone::Rows)
107 } else if self.column_fields.contains(&field) {
108 Some(PivotZone::Columns)
109 } else if self.value_field == Some(field) {
110 Some(PivotZone::Values)
111 } else if self.filter_fields.contains(&field) {
112 Some(PivotZone::Filters)
113 } else {
114 None
115 }
116 }
117
118 #[must_use]
120 pub fn fields_in(&self, zone: PivotZone) -> Vec<usize> {
121 match zone {
122 PivotZone::Rows => self.row_fields.clone(),
123 PivotZone::Columns => self.column_fields.clone(),
124 PivotZone::Values => self.value_field.into_iter().collect(),
125 PivotZone::Filters => self.filter_fields.clone(),
126 }
127 }
128
129 pub fn move_field(&mut self, field: usize, zone: PivotZone, index: Option<usize>) {
135 self.remove_field(field);
136 match zone {
137 PivotZone::Rows => insert_clamped(&mut self.row_fields, field, index),
138 PivotZone::Columns => insert_clamped(&mut self.column_fields, field, index),
139 PivotZone::Values => self.value_field = Some(field),
140 PivotZone::Filters => insert_clamped(&mut self.filter_fields, field, index),
141 }
142 }
143
144 pub fn remove_field(&mut self, field: usize) {
146 self.row_fields.retain(|&f| f != field);
147 self.column_fields.retain(|&f| f != field);
148 self.filter_fields.retain(|&f| f != field);
149 if self.value_field == Some(field) {
150 self.value_field = None;
151 }
152 }
153
154 pub fn clamp_to_columns(&mut self, column_count: usize) {
157 self.row_fields.retain(|&f| f < column_count);
158 self.column_fields.retain(|&f| f < column_count);
159 self.filter_fields.retain(|&f| f < column_count);
160 if self.value_field.is_some_and(|f| f >= column_count) {
161 self.value_field = None;
162 }
163 self.field_formats.retain(|&f, _| f < column_count);
164 }
165}
166
167fn insert_clamped(list: &mut Vec<usize>, field: usize, index: Option<usize>) {
168 let at = index.unwrap_or(list.len()).min(list.len());
169 list.insert(at, field);
170}
171
172#[cfg(test)]
173#[allow(clippy::field_reassign_with_default)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn default_is_not_ready() {
179 let cfg = PivotConfig::default();
180 assert!(!cfg.is_ready());
181 assert_eq!(cfg.zone_of(0), None);
182 }
183
184 #[test]
185 fn ready_needs_value_and_one_axis() {
186 let mut cfg = PivotConfig::default();
187 cfg.value_field = Some(2);
188 assert!(!cfg.is_ready(), "value alone is not enough");
189 cfg.row_fields = vec![0];
190 assert!(cfg.is_ready());
191 cfg.row_fields.clear();
192 cfg.column_fields = vec![1];
193 assert!(cfg.is_ready());
194 }
195
196 #[test]
197 fn move_field_between_zones_is_exclusive() {
198 let mut cfg = PivotConfig::default();
199 cfg.move_field(3, PivotZone::Rows, None);
200 assert_eq!(cfg.zone_of(3), Some(PivotZone::Rows));
201 cfg.move_field(3, PivotZone::Columns, None);
202 assert_eq!(cfg.zone_of(3), Some(PivotZone::Columns));
203 assert!(cfg.row_fields.is_empty());
204 cfg.move_field(3, PivotZone::Filters, None);
205 assert_eq!(cfg.zone_of(3), Some(PivotZone::Filters));
206 assert!(cfg.column_fields.is_empty());
207 }
208
209 #[test]
210 fn values_zone_holds_exactly_one() {
211 let mut cfg = PivotConfig::default();
212 cfg.move_field(1, PivotZone::Values, None);
213 cfg.move_field(2, PivotZone::Values, None);
214 assert_eq!(cfg.value_field, Some(2));
215 assert_eq!(cfg.zone_of(1), None);
217 }
218
219 #[test]
220 fn move_field_respects_insertion_index() {
221 let mut cfg = PivotConfig::default();
222 cfg.move_field(1, PivotZone::Rows, None);
223 cfg.move_field(2, PivotZone::Rows, None);
224 cfg.move_field(3, PivotZone::Rows, Some(0));
225 assert_eq!(cfg.row_fields, vec![3, 1, 2]);
226 cfg.move_field(4, PivotZone::Rows, Some(99));
228 assert_eq!(cfg.row_fields, vec![3, 1, 2, 4]);
229 }
230
231 #[test]
232 fn reorder_within_same_zone() {
233 let mut cfg = PivotConfig::default();
234 cfg.row_fields = vec![1, 2, 3];
235 cfg.move_field(3, PivotZone::Rows, Some(0));
236 assert_eq!(cfg.row_fields, vec![3, 1, 2]);
237 }
238
239 #[test]
240 fn remove_field_clears_every_zone() {
241 let mut cfg = PivotConfig::default();
242 cfg.row_fields = vec![1];
243 cfg.value_field = Some(2);
244 cfg.remove_field(1);
245 cfg.remove_field(2);
246 assert!(cfg.row_fields.is_empty());
247 assert_eq!(cfg.value_field, None);
248 }
249
250 #[test]
251 fn clamp_to_columns_drops_out_of_range() {
252 let mut cfg = PivotConfig::default();
253 cfg.row_fields = vec![0, 5];
254 cfg.column_fields = vec![9];
255 cfg.filter_fields = vec![2, 7];
256 cfg.value_field = Some(6);
257 cfg.field_formats.insert(0, NumberFormat::default());
258 cfg.field_formats.insert(8, NumberFormat::default());
259 cfg.clamp_to_columns(4);
260 assert_eq!(cfg.row_fields, vec![0]);
261 assert!(cfg.column_fields.is_empty());
262 assert_eq!(cfg.filter_fields, vec![2]);
263 assert_eq!(cfg.value_field, None);
264 assert!(cfg.field_formats.contains_key(&0));
265 assert!(!cfg.field_formats.contains_key(&8));
266 }
267
268 #[test]
269 fn fields_in_reports_zone_contents() {
270 let mut cfg = PivotConfig::default();
271 cfg.row_fields = vec![4, 1];
272 cfg.value_field = Some(0);
273 assert_eq!(cfg.fields_in(PivotZone::Rows), vec![4, 1]);
274 assert_eq!(cfg.fields_in(PivotZone::Values), vec![0]);
275 assert!(cfg.fields_in(PivotZone::Columns).is_empty());
276 }
277}