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 h_index = 0;
63    let mut headers: Vec<String> = vec![];
64    let num_cols = first_row.len();
65    let keep_headers = field_mode.keep_headers();
66    for h_row in first_row.to_owned() {
67        let sn = h_row.to_snake_case();
68        let mut has_override = false;
69        if let Some(col) = columns.get(h_index) {
70            // only apply override if key is not empty
71            if let Some(k_str) = &col.key {
72                let h_key = if headers.contains(&k_str.to_string()) {
73                    to_padded_col_suffix(k_str, h_index, num_cols)
74                } else {
75                    k_str.to_string()
76                };
77                headers.push(h_key);
78                has_override = true;
79            }
80        }
81        if !has_override {
82            if keep_headers && sn.len() > 0 {
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        h_index += 1;
94    }
95    headers
96}
97
98/// The natural (un-overridden) key for each column, exactly as build_header_keys would
99/// derive it with no column overrides at all. Used as the matching target for column
100/// overrides that reference a column by its source_key rather than by position.
101pub fn natural_column_keys(first_row: &[String], field_mode: &FieldNameMode) -> Vec<String> {
102    build_header_keys(first_row, &[], field_mode)
103}
104
105/// Resolve a (possibly unordered, possibly sparse) list of column overrides against a
106/// sheet's natural header keys, producing one Column per natural column, aligned by index.
107///
108/// Overrides with a `source_key` are matched by name against the natural keys wherever
109/// that column actually is, regardless of the override's position in `columns` — this is
110/// what lets a caller override just one field out of many (e.g. `weight_kg -> weight`)
111/// without needing to enumerate every column ahead of it. Overrides with no `source_key`
112/// keep applying positionally instead, exactly as before, for backward compatibility with
113/// direct library use.
114pub fn resolve_columns(columns: &[Column], natural_keys: &[String]) -> Vec<Column> {
115    let mut resolved: Vec<Column> = natural_keys.iter().map(|_| Column::new(None)).collect();
116    for (i, col) in columns.iter().enumerate() {
117        if col.source_key.is_none() {
118            if let Some(slot) = resolved.get_mut(i) {
119                *slot = col.clone();
120            }
121        }
122    }
123    for col in columns {
124        if let Some(src) = &col.source_key {
125            let target = src.to_snake_case();
126            if let Some(idx) = natural_keys.iter().position(|k| k.to_snake_case() == target) {
127                resolved[idx] = col.clone();
128            }
129        }
130    }
131    resolved
132}
133
134/// Assign keys with A1+ notation
135pub fn build_a1_headers(first_row: &[String]) -> Vec<String> {
136    build_header_keys(first_row, &[], &FieldNameMode::A1)
137}
138
139/// Assign keys as c + zero-padded number
140pub fn build_c01_headers(first_row: &[String]) -> Vec<String> {
141    build_header_keys(first_row, &[], &FieldNameMode::NumPadded)
142}
143
144/// Check if the row is not a header row. Always returns true if row_index is greater than 0.
145///
146/// Compares the row's *raw*, un-coerced cell text against the raw header text -- not the
147/// row's already-formatted values. Comparing post-format values used to break this check
148/// whenever a column had a non-Auto Format: coercing the header row's own text through that
149/// format (e.g. a decimal parse, or a date parse) commonly turns it into `null` or some other
150/// value that no longer equals the header text, so the header row would be misclassified as
151/// real data and leak into the output.
152pub(crate) fn is_not_header_row(
153    raw_values: &[String],
154    row_index: usize,
155    headers: &[String],
156) -> bool {
157    if row_index > 0 {
158        return true;
159    }
160    let mut num_matched: usize = 0;
161    for (h_index, hk) in headers.iter().enumerate() {
162        let sn = hk.to_snake_case();
163        if let Some(val) = raw_values.get(h_index) {
164            if val.to_snake_case() == sn || sn.len() == 0 {
165                num_matched += 1;
166            }
167        }
168    }
169    num_matched < headers.len()
170}
171
172#[cfg(test)]
173mod tests {
174
175    use crate::Format;
176
177    use super::*;
178
179    #[test]
180    fn test_cell_letters_1() {
181        assert_eq!(to_a1_col_key(26), "aa");
182    }
183
184    #[test]
185    fn test_cell_letters_2() {
186        assert_eq!(to_a1_col_key(701), "zz");
187    }
188
189    #[test]
190    fn test_cell_letters_3() {
191        assert_eq!(to_a1_col_key(702), "aaa");
192    }
193
194    #[test]
195    fn test_cell_letters_4() {
196        assert_eq!(to_c01_col_key(8, 60), "c09");
197    }
198
199    #[test]
200    fn test_cell_letters_5() {
201        assert_eq!(to_c01_col_key(20, 750), "c021");
202    }
203
204    #[test]
205    fn test_cell_letters_6() {
206        assert_eq!(to_c01_col_key(20, 2000), "c0021");
207    }
208
209    #[test]
210    fn test_is_not_header_row_uses_raw_text_not_coerced_values() {
211        // Regression test: comparing against a row's *coerced* Format-applied values used to
212        // misclassify the header row as real data whenever a column had a non-Auto format,
213        // because coercing the header row's own text (e.g. "weight_kg") through that format
214        // (e.g. Format::Decimal) turned it into null/something else that no longer matched
215        // the header text. Comparing raw, un-coerced cell text sidesteps that entirely.
216        let headers = vec!["sku".to_string(), "weight".to_string()];
217        // the header row repeated verbatim as "data" -- should be detected and excluded
218        let header_row_raw = vec!["sku".to_string(), "weight".to_string()];
219        assert_eq!(is_not_header_row(&header_row_raw, 0, &headers), false);
220
221        // genuine data at row 0 (e.g. a headerless sheet) is not excluded
222        let data_row_raw = vec!["SKU001".to_string(), "58.2".to_string()];
223        assert_eq!(is_not_header_row(&data_row_raw, 0, &headers), true);
224
225        // row_index > 0 is always real data, regardless of content
226        assert_eq!(is_not_header_row(&header_row_raw, 1, &headers), true);
227    }
228
229    #[test]
230    fn test_resolve_columns_matches_by_source_key_regardless_of_position() {
231        // "full_name,height_cm,weight_kg" -- override only weight_kg, out of order and
232        // without needing to pad the other two columns with empty entries.
233        let first_row = ["full_name", "height_cm", "weight_kg"].map(|s| s.to_string());
234        let natural_keys = natural_column_keys(&first_row, &FieldNameMode::AutoA1);
235        assert_eq!(natural_keys, vec!["full_name", "height_cm", "weight_kg"]);
236
237        let overrides = vec![
238            Column::from_source_key_with_format("weight_kg", Some("weight"), Format::Integer, None, false, false),
239        ];
240        let resolved = resolve_columns(&overrides, &natural_keys);
241        assert_eq!(resolved.len(), 3);
242        // untouched columns keep their natural key and Format::Auto
243        assert!(resolved[0].key.is_none());
244        assert!(resolved[1].key.is_none());
245        // the matched column picked up the override regardless of its position in `overrides`
246        assert_eq!(resolved[2].key_name(), "weight");
247        assert_eq!(resolved[2].format.to_string(), "integer");
248
249        let headers = build_header_keys(&first_row, &resolved, &FieldNameMode::AutoA1);
250        assert_eq!(headers, vec!["full_name", "height_cm", "weight"]);
251    }
252
253    #[test]
254    fn test_resolve_columns_source_key_match_is_snake_cased() {
255        // The source key is matched against the natural snake_cased header, so it
256        // doesn't need to be typed in exactly the same casing/spacing as the header.
257        let first_row = ["Weight (Kg)".to_string()];
258        let natural_keys = natural_column_keys(&first_row, &FieldNameMode::AutoA1);
259        assert_eq!(natural_keys, vec!["weight_kg"]);
260
261        let overrides = vec![
262            Column::from_source_key_with_format("Weight Kg", Some("weight"), Format::Auto, None, false, false),
263        ];
264        let resolved = resolve_columns(&overrides, &natural_keys);
265        assert_eq!(resolved[0].key_name(), "weight");
266    }
267
268    #[test]
269    fn test_resolve_columns_unmatched_source_key_is_a_no_op() {
270        let first_row = ["full_name", "height_cm"].map(|s| s.to_string());
271        let natural_keys = natural_column_keys(&first_row, &FieldNameMode::AutoA1);
272        let overrides = vec![
273            Column::from_source_key_with_format("nonexistent_field", Some("oops"), Format::Auto, None, false, false),
274        ];
275        let resolved = resolve_columns(&overrides, &natural_keys);
276        // no column matched "nonexistent_field", so nothing changes -- silently ignored
277        assert!(resolved[0].key.is_none());
278        assert!(resolved[1].key.is_none());
279    }
280
281    #[test]
282    fn test_resolve_columns_still_supports_positional_overrides() {
283        // Columns with no source_key keep applying by position, for backward
284        // compatibility with direct library use.
285        let first_row = ["a", "b", "c"].map(|s| s.to_string());
286        let natural_keys = natural_column_keys(&first_row, &FieldNameMode::AutoA1);
287        let overrides = vec![
288            Column::new(Some("first")),
289            Column::new(Some("second")),
290        ];
291        let resolved = resolve_columns(&overrides, &natural_keys);
292        assert_eq!(resolved[0].key_name(), "first");
293        assert_eq!(resolved[1].key_name(), "second");
294        assert!(resolved[2].key.is_none());
295    }
296
297    #[test]
298    fn test_first_row() {
299        // header labels as captured from the top row
300        let first_row = ["Viscosity", "Rating", "", ""].map(|s| s.to_string());
301        let cols = vec![
302            Column::from_key_ref_with_format(None, Format::Float, None, false, false),
303            Column::from_key_ref_with_format(
304                Some("points"),
305                Format::Decimal(3),
306                None,
307                false,
308                false,
309            ),
310            Column::from_key_ref_with_format(Some("adjusted"), Format::Float, None, false, false),
311        ];
312        let headers = build_header_keys(&first_row, &cols, &FieldNameMode::AutoA1);
313        // should be lower-cased as `viscosity`
314        assert_eq!(headers.get(0).unwrap(), "viscosity");
315        // should be overridden as `points`
316        assert_eq!(headers.get(1).unwrap(), "points");
317        // should be labelled `adjusted`
318        assert_eq!(headers.get(2).unwrap(), "adjusted");
319        // fourth column  with empty heading should be assigned an A1-style key of `d`
320        assert_eq!(headers.get(3).unwrap(), "d");
321    }
322
323    #[test]
324    fn test_headers_a1_override() {
325        // header labels as captured from the top row
326        let first_row = ["Viscosity", "Rating", "Weighted", "Class"].map(|s| s.to_string());
327
328        let headers = build_a1_headers(&first_row);
329        // should be lower-cased as `viscosity`
330        assert_eq!(headers.get(0).unwrap(), "a");
331        // the column should be d.
332        assert_eq!(headers.get(3).unwrap(), "d");
333    }
334
335    #[test]
336    fn test_headers_c01_override() {
337        // build header row with 200 sequential alphanumeric values
338        let first_row: Vec<String> = (0..200)
339            .map(|x| {
340                [
341                    char::from_u32(65 + (x % 26)).unwrap_or('_').to_string(),
342                    (x * 3 * 1).to_string(),
343                ]
344                .concat()
345            })
346            .collect();
347
348        let headers = build_c01_headers(&first_row);
349        // the column should be c0001
350        assert_eq!(headers.get(0).unwrap(), "c001");
351        // the column should be c0004
352        assert_eq!(headers.get(3).unwrap(), "c004");
353    }
354}