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