Skip to main content

spreadsheet_to_json/
headers.rs

1use heck::ToSnakeCase;
2
3use crate::{Column, FieldNameMode};
4
5pub fn to_a1_col_key(index: usize) -> String {
6    let mut result = String::new();
7    let mut n = index as i32; // Work with i32 to handle potential negative values
8
9    while n >= 0 {
10        let remainder = (n % 26) as u8;
11        result.push((b'a' + remainder) as char);
12        n = (n / 26) - 1;
13    }
14    result.chars().rev().collect()
15}
16
17pub fn to_padded_col_key(prefix: &str, index: usize, num_cols: usize) -> String {
18    build_padded_col_key(prefix, false, index, num_cols)
19}
20
21pub fn to_padded_col_suffix(prefix: &str, index: usize, num_cols: usize) -> String {
22    build_padded_col_key(prefix, true, index, num_cols)
23}
24
25fn build_padded_col_key(prefix: &str, underscore: bool, index: usize, num_cols: usize) -> String {
26    let width = if num_cols < 100 {
27        2
28    } else if num_cols < 1000 {
29        3
30    } else if num_cols < 10000 {
31        4
32    } else {
33        5
34    };
35    let num = index + 1;
36    let separator = if underscore { "_" } else { "" };
37    format!("{}{}{:0width$}", prefix, separator, num, width = width)
38}
39
40pub fn to_c01_col_key(index: usize, num_cols: usize) -> String {
41    to_padded_col_key("c", index, num_cols)
42}
43
44pub fn to_head_key(index: usize, field_mode: &FieldNameMode, num_cols: usize) -> String {
45    if field_mode.use_c01() {
46        to_c01_col_key(index, num_cols)
47    } else {
48        to_a1_col_key(index)
49    }
50}
51
52pub fn to_head_key_default(index: usize) -> String {
53    to_c01_col_key(index, 1000)
54}
55
56/// Build header keys from the first row of a CSV file or headers captured from a spreadsheet
57pub fn build_header_keys(
58    first_row: &[String],
59    columns: &[Column],
60    field_mode: &FieldNameMode,
61) -> Vec<String> {
62    let mut headers: Vec<String> = vec![];
63    let num_cols = first_row.len();
64    let keep_headers = field_mode.keep_headers();
65    for (h_index, h_row) in first_row.iter().enumerate() {
66        let sn = h_row.to_snake_case();
67        let mut has_override = false;
68        if let Some(col) = columns.get(h_index) {
69            // only apply override if key is not empty
70            if let Some(segment) = &col.key {
71                let k_str = segment.to_string();
72                let h_key = if headers.contains(&k_str) {
73                    to_padded_col_suffix(&k_str, h_index, num_cols)
74                } else {
75                    k_str
76                };
77                headers.push(h_key);
78                has_override = true;
79            }
80        }
81        if !has_override {
82            if keep_headers && !sn.is_empty() {
83                let sn_key = if headers.contains(&sn) {
84                    to_padded_col_suffix(&sn, h_index, num_cols)
85                } else {
86                    sn
87                };
88                headers.push(sn_key);
89            } else {
90                headers.push(to_head_key(h_index, field_mode, num_cols));
91            }
92        }
93    }
94    headers
95}
96
97/// Combines `header_row_span` consecutive raw header rows into the single effective
98/// header row `build_header_keys`/`natural_column_keys` expect, for spreadsheets whose
99/// header spans more than one row (e.g. a merged "2015"/"2025" year row with a "Female"/
100/// "Male" sub-label row underneath).
101///
102/// Each row is forward-filled *independently* first: a blank cell inherits the nearest
103/// non-blank value to its left within that same row, which is what makes a merged cell
104/// work without needing any merge-range metadata from calamine at all -- calamine (like
105/// the underlying xlsx/CSV data) only ever reports a merged cell's value in its top-left
106/// position, leaving the rest of the merge blank, and that's indistinguishable from an
107/// ordinary blank cell that just happens to be empty. Forward-fill produces the correct
108/// result either way: a genuine merge "spreads" its one value across the columns it
109/// visually spans, and an incidentally-blank cell (nothing to its left either) stays
110/// blank rather than inheriting something from a different, unrelated column.
111///
112/// Then, down each column, every row's (now filled) value is joined with `_` -- blank
113/// values (a leading blank with nothing to its left to inherit) are skipped entirely
114/// rather than leaving a stray separator, so a column where only one row actually
115/// contributes text still gets a clean single-segment key.
116pub fn combine_header_rows(rows: &[Vec<String>]) -> Vec<String> {
117    let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
118    let filled: Vec<Vec<String>> = rows.iter().map(|row| forward_fill_row(row, num_cols)).collect();
119    (0..num_cols)
120        .map(|col_index| {
121            filled
122                .iter()
123                .filter_map(|row| row.get(col_index))
124                .filter(|v| !v.is_empty())
125                .cloned()
126                .collect::<Vec<String>>()
127                .join("_")
128        })
129        .collect()
130}
131
132fn forward_fill_row(row: &[String], num_cols: usize) -> Vec<String> {
133    let mut result = Vec::with_capacity(num_cols);
134    let mut carry: Option<&str> = None;
135    for i in 0..num_cols {
136        let cell = row.get(i).map(|s| s.trim()).unwrap_or("");
137        if !cell.is_empty() {
138            carry = Some(cell);
139            result.push(cell.to_string());
140        } else {
141            result.push(carry.unwrap_or("").to_string());
142        }
143    }
144    result
145}
146
147/// The natural (un-overridden) key for each column, exactly as build_header_keys would
148/// derive it with no column overrides at all. Used as the matching target for column
149/// overrides that reference a column by its source_key rather than by position.
150pub fn natural_column_keys(first_row: &[String], field_mode: &FieldNameMode) -> Vec<String> {
151    build_header_keys(first_row, &[], field_mode)
152}
153
154/// Resolve a (possibly unordered, possibly sparse) list of column overrides against a
155/// sheet's natural header keys, producing one Column per natural column, aligned by index.
156///
157/// Overrides with a `source_key` are matched by name against the natural keys wherever
158/// that column actually is, regardless of the override's position in `columns` — this is
159/// what lets a caller override just one field out of many (e.g. `weight_kg -> weight`)
160/// without needing to enumerate every column ahead of it. Overrides with no `source_key`
161/// keep applying positionally instead, exactly as before, for backward compatibility with
162/// direct library use.
163pub fn resolve_columns(columns: &[Column], natural_keys: &[String]) -> Vec<Column> {
164    let mut resolved: Vec<Column> = natural_keys.iter().map(|_| Column::new(None)).collect();
165    for (i, col) in columns.iter().enumerate() {
166        if col.source_key.is_none() {
167            if let Some(slot) = resolved.get_mut(i) {
168                *slot = col.clone();
169            }
170        }
171    }
172    for col in columns {
173        if let Some(src) = &col.source_key {
174            let target = src.to_snake_case();
175            if let Some(idx) = natural_keys.iter().position(|k| k.to_snake_case() == target) {
176                resolved[idx] = col.clone();
177            }
178        }
179    }
180    resolved
181}
182
183/// Assign keys with A1+ notation
184pub fn build_a1_headers(first_row: &[String]) -> Vec<String> {
185    build_header_keys(first_row, &[], &FieldNameMode::A1)
186}
187
188/// Assign keys as c + zero-padded number
189pub fn build_c01_headers(first_row: &[String]) -> Vec<String> {
190    build_header_keys(first_row, &[], &FieldNameMode::NumPadded)
191}
192
193/// Check if the row is not a header row. Always returns true if row_index is greater than 0.
194///
195/// Compares the row's *raw*, un-coerced cell text against the raw header text -- not the
196/// row's already-formatted values. Comparing post-format values used to break this check
197/// whenever a column had a non-Auto Format: coercing the header row's own text through that
198/// format (e.g. a decimal parse, or a date parse) commonly turns it into `null` or some other
199/// value that no longer equals the header text, so the header row would be misclassified as
200/// real data and leak into the output.
201pub(crate) fn is_not_header_row(
202    raw_values: &[String],
203    row_index: usize,
204    headers: &[String],
205) -> bool {
206    if row_index > 0 {
207        return true;
208    }
209    let mut num_matched: usize = 0;
210    for (h_index, hk) in headers.iter().enumerate() {
211        let sn = hk.to_snake_case();
212        if let Some(val) = raw_values.get(h_index) {
213            if val.to_snake_case() == sn || sn.is_empty() {
214                num_matched += 1;
215            }
216        }
217    }
218    num_matched < headers.len()
219}
220
221#[cfg(test)]
222mod tests {
223
224    use crate::{DateTimeMode, Format};
225
226    use super::*;
227
228    #[test]
229    fn test_cell_letters_1() {
230        assert_eq!(to_a1_col_key(26), "aa");
231    }
232
233    fn strs(vals: &[&str]) -> Vec<String> {
234        vals.iter().map(|s| s.to_string()).collect()
235    }
236
237    #[test]
238    fn test_combine_header_rows_forward_fills_a_single_merged_block() {
239        // A1:C1 merged "2015" -- calamine reports it only in A1, B1/C1 blank.
240        let rows = vec![strs(&["2015", "", ""]), strs(&["North", "Midlands", "South"])];
241        assert_eq!(
242            combine_header_rows(&rows),
243            strs(&["2015_North", "2015_Midlands", "2015_South"])
244        );
245    }
246
247    #[test]
248    fn test_combine_header_rows_handles_two_merged_blocks_in_the_same_row() {
249        let rows = vec![
250            strs(&["2015", "", "", "2025", "", ""]),
251            strs(&["North", "Midlands", "South", "North", "Midlands", "South"]),
252        ];
253        assert_eq!(
254            combine_header_rows(&rows),
255            strs(&[
256                "2015_North", "2015_Midlands", "2015_South",
257                "2025_North", "2025_Midlands", "2025_South"
258            ])
259        );
260    }
261
262    #[test]
263    fn test_combine_header_rows_leaves_a_leading_blank_with_nothing_to_inherit_empty() {
264        // country_code has no row-1 label at all (nothing merged over it) and no row-2
265        // sub-label either -- both rows contribute nothing, not a stray "_" separator.
266        let rows = vec![
267            strs(&["", "2015", ""]),
268            strs(&["", "North", "South"]),
269        ];
270        assert_eq!(combine_header_rows(&rows), strs(&["", "2015_North", "2015_South"]));
271    }
272
273    #[test]
274    fn test_combine_header_rows_skips_a_row_that_contributes_nothing_for_one_column() {
275        // country_code (column 0) only ever gets a value from row 1 -- row 2 is blank
276        // for it and shouldn't leave a trailing "_".
277        let rows = vec![strs(&["country code", "2015", ""]), strs(&["", "North", "South"])];
278        assert_eq!(combine_header_rows(&rows), strs(&["country code", "2015_North", "2015_South"]));
279    }
280
281    #[test]
282    fn test_combine_header_rows_with_a_single_row_is_a_no_op() {
283        // header_row_span == 1 (the default) -- output is identical to the input.
284        let rows = vec![strs(&["id", "name", "score"])];
285        assert_eq!(combine_header_rows(&rows), strs(&["id", "name", "score"]));
286    }
287
288    #[test]
289    fn test_cell_letters_2() {
290        assert_eq!(to_a1_col_key(701), "zz");
291    }
292
293    #[test]
294    fn test_cell_letters_3() {
295        assert_eq!(to_a1_col_key(702), "aaa");
296    }
297
298    #[test]
299    fn test_cell_letters_4() {
300        assert_eq!(to_c01_col_key(8, 60), "c09");
301    }
302
303    #[test]
304    fn test_cell_letters_5() {
305        assert_eq!(to_c01_col_key(20, 750), "c021");
306    }
307
308    #[test]
309    fn test_cell_letters_6() {
310        assert_eq!(to_c01_col_key(20, 2000), "c0021");
311    }
312
313    #[test]
314    fn test_is_not_header_row_uses_raw_text_not_coerced_values() {
315        // Regression test: comparing against a row's *coerced* Format-applied values used to
316        // misclassify the header row as real data whenever a column had a non-Auto format,
317        // because coercing the header row's own text (e.g. "weight_kg") through that format
318        // (e.g. Format::Decimal) turned it into null/something else that no longer matched
319        // the header text. Comparing raw, un-coerced cell text sidesteps that entirely.
320        let headers = vec!["sku".to_string(), "weight".to_string()];
321        // the header row repeated verbatim as "data" -- should be detected and excluded
322        let header_row_raw = vec!["sku".to_string(), "weight".to_string()];
323        assert!(!is_not_header_row(&header_row_raw, 0, &headers));
324
325        // genuine data at row 0 (e.g. a headerless sheet) is not excluded
326        let data_row_raw = vec!["SKU001".to_string(), "58.2".to_string()];
327        assert!(is_not_header_row(&data_row_raw, 0, &headers));
328
329        // row_index > 0 is always real data, regardless of content
330        assert!(is_not_header_row(&header_row_raw, 1, &headers));
331    }
332
333    #[test]
334    fn test_resolve_columns_matches_by_source_key_regardless_of_position() {
335        // "full_name,height_cm,weight_kg" -- override only weight_kg, out of order and
336        // without needing to pad the other two columns with empty entries.
337        let first_row = ["full_name", "height_cm", "weight_kg"].map(|s| s.to_string());
338        let natural_keys = natural_column_keys(&first_row, &FieldNameMode::AutoA1);
339        assert_eq!(natural_keys, vec!["full_name", "height_cm", "weight_kg"]);
340
341        let overrides = vec![
342            Column::from_source_key_with_format("weight_kg", Some("weight"), Format::Integer, None, DateTimeMode::Full, false),
343        ];
344        let resolved = resolve_columns(&overrides, &natural_keys);
345        assert_eq!(resolved.len(), 3);
346        // untouched columns keep their natural key and Format::Auto
347        assert!(resolved[0].key.is_none());
348        assert!(resolved[1].key.is_none());
349        // the matched column picked up the override regardless of its position in `overrides`
350        assert_eq!(resolved[2].key_name(), "weight");
351        assert_eq!(resolved[2].format.to_string(), "integer");
352
353        let headers = build_header_keys(&first_row, &resolved, &FieldNameMode::AutoA1);
354        assert_eq!(headers, vec!["full_name", "height_cm", "weight"]);
355    }
356
357    #[test]
358    fn test_resolve_columns_source_key_match_is_snake_cased() {
359        // The source key is matched against the natural snake_cased header, so it
360        // doesn't need to be typed in exactly the same casing/spacing as the header.
361        let first_row = ["Weight (Kg)".to_string()];
362        let natural_keys = natural_column_keys(&first_row, &FieldNameMode::AutoA1);
363        assert_eq!(natural_keys, vec!["weight_kg"]);
364
365        let overrides = vec![
366            Column::from_source_key_with_format("Weight Kg", Some("weight"), Format::Auto, None, DateTimeMode::Full, false),
367        ];
368        let resolved = resolve_columns(&overrides, &natural_keys);
369        assert_eq!(resolved[0].key_name(), "weight");
370    }
371
372    #[test]
373    fn test_resolve_columns_unmatched_source_key_is_a_no_op() {
374        let first_row = ["full_name", "height_cm"].map(|s| s.to_string());
375        let natural_keys = natural_column_keys(&first_row, &FieldNameMode::AutoA1);
376        let overrides = vec![
377            Column::from_source_key_with_format("nonexistent_field", Some("oops"), Format::Auto, None, DateTimeMode::Full, false),
378        ];
379        let resolved = resolve_columns(&overrides, &natural_keys);
380        // no column matched "nonexistent_field", so nothing changes -- silently ignored
381        assert!(resolved[0].key.is_none());
382        assert!(resolved[1].key.is_none());
383    }
384
385    #[test]
386    fn test_resolve_columns_still_supports_positional_overrides() {
387        // Columns with no source_key keep applying by position, for backward
388        // compatibility with direct library use.
389        let first_row = ["a", "b", "c"].map(|s| s.to_string());
390        let natural_keys = natural_column_keys(&first_row, &FieldNameMode::AutoA1);
391        let overrides = vec![
392            Column::new(Some("first")),
393            Column::new(Some("second")),
394        ];
395        let resolved = resolve_columns(&overrides, &natural_keys);
396        assert_eq!(resolved[0].key_name(), "first");
397        assert_eq!(resolved[1].key_name(), "second");
398        assert!(resolved[2].key.is_none());
399    }
400
401    #[test]
402    fn test_first_row() {
403        // header labels as captured from the top row
404        let first_row = ["Viscosity", "Rating", "", ""].map(|s| s.to_string());
405        let cols = vec![
406            Column::from_key_ref_with_format(None, Format::Float, None, DateTimeMode::Full, false),
407            Column::from_key_ref_with_format(
408                Some("points"),
409                Format::Decimal(3),
410                None,
411                DateTimeMode::Full,
412                false,
413            ),
414            Column::from_key_ref_with_format(Some("adjusted"), Format::Float, None, DateTimeMode::Full, false),
415        ];
416        let headers = build_header_keys(&first_row, &cols, &FieldNameMode::AutoA1);
417        // should be lower-cased as `viscosity`
418        assert_eq!(headers.first().unwrap(), "viscosity");
419        // should be overridden as `points`
420        assert_eq!(headers.get(1).unwrap(), "points");
421        // should be labelled `adjusted`
422        assert_eq!(headers.get(2).unwrap(), "adjusted");
423        // fourth column  with empty heading should be assigned an A1-style key of `d`
424        assert_eq!(headers.get(3).unwrap(), "d");
425    }
426
427    #[test]
428    fn test_headers_a1_override() {
429        // header labels as captured from the top row
430        let first_row = ["Viscosity", "Rating", "Weighted", "Class"].map(|s| s.to_string());
431
432        let headers = build_a1_headers(&first_row);
433        // should be lower-cased as `viscosity`
434        assert_eq!(headers.first().unwrap(), "a");
435        // the column should be d.
436        assert_eq!(headers.get(3).unwrap(), "d");
437    }
438
439    #[test]
440    fn test_headers_c01_override() {
441        // build header row with 200 sequential alphanumeric values
442        let first_row: Vec<String> = (0..200)
443            .map(|x| {
444                [
445                    char::from_u32(65 + (x % 26)).unwrap_or('_').to_string(),
446                    (x * 3).to_string(),
447                ]
448                .concat()
449            })
450            .collect();
451
452        let headers = build_c01_headers(&first_row);
453        // the column should be c0001
454        assert_eq!(headers.first().unwrap(), "c001");
455        // the column should be c0004
456        assert_eq!(headers.get(3).unwrap(), "c004");
457    }
458}