Skip to main content

spreadsheet_to_json/
reader.rs

1use calamine::{open_workbook_auto, Data, Reader, Sheets};
2use csv::{ReaderBuilder, StringRecord};
3use heck::ToSnakeCase;
4use indexmap::IndexMap;
5use serde_json::{Number, Value};
6use std::fs::File;
7use std::io::BufReader;
8use std::path::Path;
9use std::str::FromStr;
10
11use crate::data_set::*;
12use crate::detect::{resolve_header_and_data_rows, DETECT_SAMPLE_SIZE};
13use crate::error::GenericError;
14use alphanumeric::*;
15use simple_string_patterns::SimpleMatch;
16use crate::headers::*;
17use crate::helpers::float_value;
18use crate::helpers::string_value;
19use is_truthy::*;
20use crate::round_decimal::RoundDecimal;
21use crate::DateTimeMode;
22use crate::Extension;
23use to_segments::ToSegments;
24use crate::Format;
25use crate::OptionSet;
26use crate::PathData;
27use crate::RowOptionSet;
28use fuzzy_datetime::{fuzzy_to_date_string, fuzzy_to_datetime_string_opts, iso_fuzzy_to_date_string, iso_fuzzy_to_datetime_string};
29
30/// Callback invoked once per row when saving asynchronously (e.g. --deferred mode)
31pub type SaveRowFn = Box<dyn Fn(IndexMap<String, Value>) -> Result<(), GenericError> + Send + Sync>;
32
33/// Output the result set with captured rows (up to the maximum allowed) directly.
34/// This is now synchronous and calls the asynchronous function using a runtime.
35pub fn process_spreadsheet_direct(opts: &OptionSet) -> Result<ResultSet, GenericError> {
36    let rt = tokio::runtime::Runtime::new().unwrap();
37    rt.block_on(process_spreadsheet_core(opts, None, None))
38}
39
40/// Output the result set with captured rows (up to the maximum allowed) immediately.
41/// Use this in an async function using the tokio runtime if you direct results
42/// without a save callback
43pub async fn process_spreadsheet_immediate(opts: &OptionSet) -> Result<ResultSet, GenericError> {
44    process_spreadsheet_core(opts, None, None).await
45}
46
47#[deprecated(
48    since = "1.0.6",
49    note = "This function is a wrapper for the renamed function `process_spreadsheet_inline`"
50)]
51pub async fn render_spreadsheet_direct(opts: &OptionSet) -> Result<ResultSet, GenericError> {
52    process_spreadsheet_core(opts, None, None).await
53}
54
55/// Output the result set with deferred row saving and optional output reference
56pub async fn process_spreadsheet_async(
57    opts: &OptionSet,
58    save_func: SaveRowFn,
59    out_ref: Option<&str>,
60) -> Result<ResultSet, GenericError> {
61    process_spreadsheet_core(opts, Some(save_func), out_ref).await
62}
63
64/// Output the result set with captured rows (up to the maximum allowed) directly.
65/// with optional asynchronous row save method and output reference
66pub async fn process_spreadsheet_core(
67    opts: &OptionSet,
68    save_opt: Option<SaveRowFn>,
69    out_ref: Option<&str>,
70) -> Result<ResultSet, GenericError> {
71    if let Some(filepath) = opts.path.clone() {
72        let path = Path::new(&filepath);
73        if !path.exists() {
74            #[allow(dead_code)]
75            return Err(GenericError("file_unavailable"));
76        }
77        let path_data = PathData::new(path);
78        if path_data.is_valid() {
79            if path_data.use_calamine() {
80                read_workbook_core(&path_data, opts, save_opt, out_ref).await
81            } else {
82                read_csv_core(&path_data, opts, save_opt, out_ref).await
83            }
84        } else {
85            Err(GenericError("unsupported_format"))
86        }
87    } else {
88        Err(GenericError("no_filepath_specified"))
89    }
90}
91
92#[deprecated(
93    since = "1.0.6",
94    note = "This function is a wrapper for the renamed function `process_spreadsheet_core`"
95)]
96pub async fn render_spreadsheet_core(
97    opts: &OptionSet,
98    save_opt: Option<SaveRowFn>,
99    out_ref: Option<&str>,
100) -> Result<ResultSet, GenericError> {
101    process_spreadsheet_core(opts, save_opt, out_ref).await
102}
103
104/// Parse spreadsheets with an optional callback method to save rows asynchronously and an optional output reference
105/// that may be a file name or database identifier
106pub async fn read_workbook_core<'a>(
107    path_data: &PathData<'a>,
108    opts: &OptionSet,
109    save_opt: Option<SaveRowFn>,
110    out_ref: Option<&str>,
111) -> Result<ResultSet, GenericError> {
112    if let Ok(mut workbook) = open_workbook_auto(path_data.path()) {
113        let max_rows = opts.max_rows();
114        let (selected_names, sheet_names, _sheet_indices) =
115            match_sheet_name_and_index(&mut workbook, opts);
116
117        if !selected_names.is_empty() {
118            let info = WorkbookInfo::new(path_data, &selected_names, &sheet_names);
119
120            if opts.multimode() {
121                read_multiple_worksheets(&mut workbook, &sheet_names, opts, &info, max_rows).await
122            } else {
123                let sheet_ref = &selected_names[0];
124                read_single_worksheet(workbook, sheet_ref, opts, &info, save_opt, out_ref).await
125            }
126        } else {
127            Err(GenericError("workbook_with_no_sheets"))
128        }
129    } else {
130        Err(GenericError("cannot_open_workbook"))
131    }
132}
133
134/// Read multiple worksheets from a workbook in preview mode.
135async fn read_multiple_worksheets(
136    workbook: &mut Sheets<BufReader<File>>,
137    sheet_names: &[String],
138    opts: &OptionSet,
139    info: &WorkbookInfo,
140    max_rows: usize,
141) -> Result<ResultSet, GenericError> {
142    let mut sheets: Vec<SheetDataSet> = vec![];
143    let capture_rows = opts.capture_rows();
144    for (sheet_index, sheet_ref) in sheet_names.iter().enumerate() {
145        let range = workbook.worksheet_range(&sheet_ref.clone())?;
146        let mut headers: Vec<String> = vec![];
147        let mut has_headers = false;
148        let mut rows: Vec<IndexMap<String, Value>> =
149            Vec::with_capacity(if capture_rows { max_rows } else { 0 });
150        let mut row_index = 0;
151        let detected = resolve_header_and_data_rows(opts, || {
152            range.rows().take(DETECT_SAMPLE_SIZE)
153                .map(|row| row.iter().map(|c| c.to_string()).collect())
154                .collect()
155        });
156        let first_data_row_index = detected.data_index;
157        let capture_headers = detected.header_index.is_some();
158        let header_row_index = detected.header_index.unwrap_or(0);
159        let mut col_keys: Vec<String> = vec![];
160        let columns = if sheet_index == 0 {
161            opts.rows.columns.clone()
162        } else {
163            vec![]
164        };
165        let mut resolved_row_opts = opts.rows.clone();
166        let match_header_row_below = capture_headers && header_row_index > 0;
167        if capture_headers {
168            if let Some(first_row) = range.headers() {
169                let natural_keys = natural_column_keys(&first_row, &opts.field_mode);
170                let resolved_columns = resolve_columns(&columns, &natural_keys);
171                headers = build_header_keys(&first_row, &resolved_columns, &opts.field_mode);
172                resolved_row_opts.columns = resolved_columns;
173                has_headers = !match_header_row_below;
174                col_keys = first_row.to_vec();
175            }
176        } else {
177            let num_cols = range.get_size().1;
178            let blank = vec![String::new(); num_cols];
179            let natural_keys = natural_column_keys(&blank, &opts.field_mode);
180            let resolved_columns = resolve_columns(&columns, &natural_keys);
181            headers = build_header_keys(&blank, &resolved_columns, &opts.field_mode.forced_fallback());
182            resolved_row_opts.columns = resolved_columns;
183            has_headers = true;
184        }
185        let total = range.get_size().0;
186        if capture_rows || match_header_row_below {
187            let max_row_count = if capture_rows {
188                max_rows
189            } else {
190                header_row_index + 2
191            };
192            let max_take = if total < max_row_count {
193                total
194            } else {
195                max_row_count + 1
196            };
197            for row in range.rows().take(max_take) {
198                if row_index > max_row_count {
199                    break;
200                }
201                if match_header_row_below && row_index == header_row_index {
202                    let h_row = row
203                        .iter()
204                        .map(|c| c.to_string().to_snake_case())
205                        .collect::<Vec<String>>();
206                    let natural_keys = natural_column_keys(&h_row, &opts.field_mode);
207                    let resolved_columns = resolve_columns(&columns, &natural_keys);
208                    headers = build_header_keys(&h_row, &resolved_columns, &opts.field_mode);
209                    resolved_row_opts.columns = resolved_columns;
210                    has_headers = true;
211                } else if (has_headers || !capture_headers) && capture_rows
212                    && row_index >= first_data_row_index {
213                    let is_real_data = if capture_headers {
214                        let raw_values: Vec<String> = row.iter().map(|c| c.to_string()).collect();
215                        is_not_header_row(&raw_values, row_index, &col_keys)
216                    } else {
217                        true
218                    };
219                    if is_real_data {
220                        let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
221                        rows.push(row_map);
222                    }
223                }
224                row_index += 1;
225            }
226        }
227        sheets.push(SheetDataSet::new(sheet_ref, &headers, &rows, total));
228    }
229    Ok(ResultSet::from_multiple(&sheets, info, opts))
230}
231
232/// Read a single worksheet from a workbook in immediate (sync) or asycnhronous modes
233pub async fn read_single_worksheet(
234    mut workbook: Sheets<BufReader<File>>,
235    sheet_ref: &str,
236    opts: &OptionSet,
237    info: &WorkbookInfo,
238    save_opt: Option<SaveRowFn>,
239    out_ref: Option<&str>,
240) -> Result<ResultSet, GenericError> {
241    let range = workbook.worksheet_range(sheet_ref)?;
242    let capture_rows = opts.capture_rows();
243    let columns = opts.rows.columns.clone();
244    let max_rows = opts.max_rows();
245    let mut headers: Vec<String> = vec![];
246    let mut col_keys: Vec<String> = vec![];
247    let mut has_headers = false;
248    let mut rows: Vec<IndexMap<String, Value>> =
249        Vec::with_capacity(if capture_rows { max_rows } else { 0 });
250    let mut row_index = 0;
251    let detected = resolve_header_and_data_rows(opts, || {
252        range.rows().take(DETECT_SAMPLE_SIZE)
253            .map(|row| row.iter().map(|c| c.to_string()).collect())
254            .collect()
255    });
256    let first_data_row_index = detected.data_index;
257    // No row is consumed as a header-text source for --omit-header, *or* when detection
258    // found no confident header row at all (see DetectedRows::header_index) -- both
259    // cases fall back to A1/C01-style names instead of deriving them from row text.
260    let capture_headers = detected.header_index.is_some();
261    let header_row_index = detected.header_index.unwrap_or(0);
262    let header_row_span = opts.effective_header_row_span();
263    // The old single-row-below scan only fires for span == 1 -- a span > 1 header is
264    // always fully resolved upfront below, regardless of header_row_index, so this
265    // legacy per-row detection never needs to (and mustn't) run for it.
266    let match_header_row_below = capture_headers && header_row_index > 0 && header_row_span <= 1;
267    let mut resolved_row_opts = opts.rows.clone();
268
269    if capture_headers && header_row_span > 1 {
270        // Multi-row header: read `header_row_span` consecutive rows starting at
271        // header_row_index (works the same whether that's 0 or further down the sheet,
272        // unlike the single-row branches below which split on that) and forward-fill/
273        // join them into one effective header row via combine_header_rows.
274        let raw_header_rows: Vec<Vec<String>> = range
275            .rows()
276            .skip(header_row_index)
277            .take(header_row_span)
278            .map(|row| row.iter().map(|c| c.to_string()).collect())
279            .collect();
280        let combined = combine_header_rows(&raw_header_rows);
281        let natural_keys = natural_column_keys(&combined, &opts.field_mode);
282        let resolved_columns = resolve_columns(&columns, &natural_keys);
283        headers = build_header_keys(&combined, &resolved_columns, &opts.field_mode);
284        resolved_row_opts.columns = resolved_columns;
285        has_headers = true;
286        col_keys = combined;
287    } else if capture_headers {
288        if let Some(first_row) = range.headers() {
289            let natural_keys = natural_column_keys(&first_row, &opts.field_mode);
290            let resolved_columns = resolve_columns(&columns, &natural_keys);
291            headers = build_header_keys(&first_row, &resolved_columns, &opts.field_mode);
292            resolved_row_opts.columns = resolved_columns;
293            has_headers = !match_header_row_below;
294            col_keys = first_row.to_vec();
295        }
296    } else {
297        let num_cols = range.get_size().1;
298        let blank = vec![String::new(); num_cols];
299        let natural_keys = natural_column_keys(&blank, &opts.field_mode);
300        let resolved_columns = resolve_columns(&columns, &natural_keys);
301        headers = build_header_keys(&blank, &resolved_columns, &opts.field_mode.forced_fallback());
302        resolved_row_opts.columns = resolved_columns;
303        has_headers = true;
304    }
305    let total = range.get_size().0;
306    if capture_rows || match_header_row_below {
307        let max_row_count = if capture_rows {
308            max_rows
309        } else {
310            header_row_index + 2
311        };
312        let max_take = if total < max_row_count {
313            total
314        } else {
315            max_row_count + 1
316        };
317        for row in range.rows().take(max_take) {
318            if row_index > max_row_count {
319                break;
320            }
321            if match_header_row_below && row_index == header_row_index {
322                let h_row = row
323                    .iter()
324                    .map(|c| c.to_string().to_snake_case())
325                    .collect::<Vec<String>>();
326                let natural_keys = natural_column_keys(&h_row, &opts.field_mode);
327                let resolved_columns = resolve_columns(&columns, &natural_keys);
328                headers = build_header_keys(&h_row, &resolved_columns, &opts.field_mode);
329                resolved_row_opts.columns = resolved_columns;
330                has_headers = true;
331            } else if (has_headers || !capture_headers) && capture_rows
332                && row_index >= first_data_row_index {
333                // only capture rows if headers are either omitted or have already been captured
334                let is_real_data = if capture_headers {
335                    let raw_values: Vec<String> = row.iter().map(|c| c.to_string()).collect();
336                    is_not_header_row(&raw_values, row_index, &col_keys)
337                } else {
338                    // no header row was consumed, so there's no header text to
339                    // self-exclude a duplicate row against
340                    true
341                };
342                if is_real_data {
343                    let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
344                    rows.push(row_map);
345                }
346            }
347            row_index += 1;
348        }
349    }
350    if let Some(save_method) = save_opt {
351        // Skip everything before first_data_row_index (the header row itself, and any
352        // title/notes/gap rows above it) -- this used to just stream from the true start
353        // of the sheet regardless of header_row_index/data_row_index, silently exporting
354        // notes rows as bogus data records.
355        let mut save_count: usize = 0;
356        for (idx, row) in range.rows().enumerate() {
357            if save_count >= max_rows {
358                break;
359            }
360            if idx < first_data_row_index {
361                continue;
362            }
363            let is_real_data = if capture_headers {
364                let raw_values: Vec<String> = row.iter().map(|c| c.to_string()).collect();
365                is_not_header_row(&raw_values, idx, &col_keys)
366            } else {
367                true
368            };
369            if is_real_data {
370                let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
371                save_method(row_map)?;
372                save_count += 1;
373            }
374        }
375    }
376
377    let ds = DataSet::from_count_and_rows(total, rows, opts);
378    Ok(ResultSet::new(info, &headers, ds, opts, out_ref, detected.header_index, first_data_row_index))
379}
380
381/// Process a CSV/TSV file asynchronously with an optional row save method
382/// and output reference (file or database table reference)
383///
384/// Reads with `has_headers(false)` and drives header/gap/data classification manually by
385/// 0-based line index (mirroring the calamine path in `read_single_worksheet`) rather than
386/// relying on the `csv` crate's own implicit "always skip the first line" behavior --
387/// needed to honor `header_row`/`data_row_index` (including the case where they're equal:
388/// a CSV with predefined/external headers where no line is actually consumed as a header,
389/// e.g. `omit_header` with a fixed schema via `--keys`).
390pub async fn read_csv_core<'a>(
391    path_data: &PathData<'a>,
392    opts: &OptionSet,
393    save_opt: Option<SaveRowFn>,
394    out_ref: Option<&str>,
395) -> Result<ResultSet, GenericError> {
396    let separator = match path_data.mode() {
397        Extension::Tsv => b't',
398        _ => b',',
399    };
400    if let Ok(mut rdr) = ReaderBuilder::new()
401        .delimiter(separator)
402        .has_headers(false)
403        // Notes/title rows before the real header (header_row > 0) commonly have a
404        // different field count than the data rows below them -- without this, the
405        // csv crate rejects every record as malformed once row 0's width doesn't match
406        // the rest of the file.
407        .flexible(true)
408        .from_path(path_data.path())
409    {
410        let capture_rows = opts.capture_rows();
411        let max_line_usize = opts.max_rows();
412        // Sampling (when actually needed for detection) opens a fresh, short-lived reader
413        // rather than reusing `rdr` -- csv::Reader is a moving cursor, so peeking ahead on
414        // the same reader would consume records the main pass below still needs.
415        let detected = resolve_header_and_data_rows(opts, || {
416            let mut sample_rows = Vec::new();
417            if let Ok(mut sample_rdr) = ReaderBuilder::new()
418                .delimiter(separator)
419                .has_headers(false)
420                .flexible(true)
421                .from_path(path_data.path())
422            {
423                for record in sample_rdr.records().take(DETECT_SAMPLE_SIZE).flatten() {
424                    sample_rows.push(record.iter().map(|s| s.to_string()).collect());
425                }
426            }
427            sample_rows
428        });
429        let first_data_row_index = detected.data_index;
430        // No line is a header source for --omit-header, *or* when detection found no
431        // confident header row at all (see DetectedRows::header_index) -- both fall
432        // back to lazily-built A1/C01-style names below.
433        let capture_header = detected.header_index.is_some();
434        let header_row_index = detected.header_index.unwrap_or(0);
435        let header_row_span = opts.effective_header_row_span();
436
437        let mut rows: Vec<IndexMap<String, Value>> =
438            Vec::with_capacity(if capture_rows { max_line_usize } else { 0 });
439        let mut headers: Vec<String> = vec![];
440        let mut resolved_row_opts = opts.rows.clone();
441        let mut header_row_buffer: Vec<Vec<String>> = Vec::new();
442        // With omit_header, no line is ever a header source -- fallback (A1/C01) keys are
443        // derived once, lazily, from the first eligible data row's column count.
444        let mut fallback_keys_built = false;
445
446        let mut total: usize = 0;
447        let mut line_count: usize = 0;
448        let mut row_index: usize = 0;
449
450        for result in rdr.records() {
451            let Ok(record) = result else {
452                row_index += 1;
453                continue;
454            };
455            // "total"/num_rows is a structural line count for the whole file, matching
456            // the calamine path's range.get_size().0 -- it includes the header row (and
457            // any skipped gap rows), not just rows that end up classified as data.
458            total += 1;
459
460            if capture_header && header_row_span > 1 && row_index >= header_row_index && row_index < header_row_index + header_row_span {
461                let raw: Vec<String> = record.iter().map(|s| s.to_string()).collect();
462                header_row_buffer.push(raw);
463                if row_index == header_row_index + header_row_span - 1 {
464                    let combined = combine_header_rows(&header_row_buffer);
465                    let natural_keys = natural_column_keys(&combined, &opts.field_mode);
466                    let resolved_columns = resolve_columns(&opts.rows.columns, &natural_keys);
467                    headers = build_header_keys(&combined, &resolved_columns, &opts.field_mode);
468                    resolved_row_opts.columns = resolved_columns;
469                }
470                row_index += 1;
471                continue;
472            }
473
474            if capture_header && header_row_span <= 1 && row_index == header_row_index {
475                let raw: Vec<String> = record.iter().map(|s| s.to_string()).collect();
476                let natural_keys = natural_column_keys(&raw, &opts.field_mode);
477                let resolved_columns = resolve_columns(&opts.rows.columns, &natural_keys);
478                headers = build_header_keys(&raw, &resolved_columns, &opts.field_mode);
479                resolved_row_opts.columns = resolved_columns;
480                row_index += 1;
481                continue;
482            }
483
484            if row_index < first_data_row_index {
485                row_index += 1;
486                continue;
487            }
488
489            if !capture_header && !fallback_keys_built {
490                let blank: Vec<String> = record.iter().map(|_| String::new()).collect();
491                let resolved_columns = resolve_columns(&opts.rows.columns, &natural_column_keys(&blank, &opts.field_mode));
492                headers = build_header_keys(&blank, &resolved_columns, &opts.field_mode.forced_fallback());
493                resolved_row_opts.columns = resolved_columns;
494                fallback_keys_built = true;
495            }
496
497            if capture_rows {
498                if line_count < max_line_usize {
499                    if let Some(row) = csv_row_result_to_values(Ok(record), &resolved_row_opts) {
500                        let mut row_map = to_index_map(&row, &headers, Some(&resolved_row_opts.columns));
501                        if resolved_row_opts.omit_null_values {
502                            omit_null_values(&mut row_map);
503                        }
504                        rows.push(row_map);
505                        line_count += 1;
506                    }
507                }
508            } else if let Some(save_method) = save_opt.as_ref() {
509                if let Some(row) = csv_row_result_to_values(Ok(record), &resolved_row_opts) {
510                    let mut row_map = to_index_map(&row, &headers, Some(&resolved_row_opts.columns));
511                    if resolved_row_opts.omit_null_values {
512                        omit_null_values(&mut row_map);
513                    }
514                    save_method(row_map)?;
515                }
516            }
517            row_index += 1;
518        }
519        let info = WorkbookInfo::simple(path_data);
520        let ds = DataSet::from_count_and_rows(total, rows, opts);
521        Ok(ResultSet::new(&info, &headers, ds, opts, out_ref, detected.header_index, first_data_row_index))
522    } else {
523        let error_msg = match path_data.ext() {
524            Extension::Tsv => "unreadable_tsv_file",
525            _ => "unreadable_csv_file",
526        };
527        Err(GenericError(error_msg))
528    }
529}
530
531// Convert an array of row data to an IndexMap of serde_json::Value objects
532fn workbook_row_to_map(
533    row: &[Data],
534    opts: &RowOptionSet,
535    headers: &[String],
536) -> IndexMap<String, Value> {
537    let mut row_map = to_index_map(&workbook_row_to_values(row, opts), headers, Some(&opts.columns));
538    if opts.omit_null_values {
539        omit_null_values(&mut row_map);
540    }
541    row_map
542}
543
544// Convert an array of row data to a vector of serde_json::Value objects
545fn workbook_row_to_values(row: &[Data], opts: &RowOptionSet) -> Vec<Value> {
546    row.iter()
547        .enumerate()
548        .map(|(c_index, cell)| workbook_cell_to_value(cell, opts, c_index))
549        .collect()
550}
551
552/// Convert a spreadsheet data cell to a polymorphic serde_json::Value object
553fn workbook_cell_to_value(cell: &Data, opts: &RowOptionSet, c_index: usize) -> Value {
554    let col = opts.column(c_index);
555    let format = col.map_or(Format::Auto, |c| c.format.to_owned());
556    let def_val = col.and_then(|c| c.default.clone());
557    let col_mode = col.map_or(DateTimeMode::Full, |c| c.datetime_mode);
558
559    let mode = resolve_datetime_mode(&format, col_mode, opts.datetime_mode);
560
561    match cell {
562        Data::Int(i) => Value::Number(Number::from_i128(*i as i128).unwrap()),
563        Data::Float(f) => process_float_value(*f, format, def_val),
564        Data::DateTimeIso(d) => process_iso_datetime_value(d, def_val, mode),
565        Data::DateTime(d) => process_excel_datetime_value(d, def_val, mode),
566        Data::Bool(b) => Value::Bool(*b),
567        Data::String(s) => process_string_value(s, format, def_val),
568        Data::Empty => def_val.unwrap_or(Value::Null),
569        _ => Value::String(cell.to_string()),
570    }
571}
572
573/// A column's own Format::Date/Format::Time/Format::Hm/Format::DateTime/
574/// Format::DateTimeSimple override takes precedence over everything else, since it
575/// forces date/time interpretation regardless of the cell's native type. Next is the
576/// column's own `datetime_mode` (only meaningful on a Format::Auto column, restricted to
577/// cells that are already genuine datetimes -- see `Column::datetime_mode`'s doc
578/// comment). Anything else falls back to the row-wide default. No override anywhere
579/// means the full datetime.
580fn resolve_datetime_mode(format: &Format, col_mode: DateTimeMode, row_mode: DateTimeMode) -> DateTimeMode {
581    match format {
582        Format::Date => DateTimeMode::DateOnly,
583        Format::Time => DateTimeMode::TimeOnly,
584        Format::Hm => DateTimeMode::HmOnly,
585        Format::DateTime => DateTimeMode::Full,
586        Format::DateTimeSimple => DateTimeMode::Simple,
587        _ if col_mode != DateTimeMode::Full => col_mode,
588        _ => row_mode,
589    }
590}
591
592fn process_float_value(value: f64, format: Format, def_val: Option<Value>) -> Value {
593    match format {
594        Format::Integer => Value::Number(Number::from_i128(value as i128).unwrap()),
595        Format::Boolean => Value::Bool(value >= 1.0),
596        Format::Text => Value::String(value.to_string()),
597        Format::Decimal(places) => float_value(value.round_decimal(places)),
598        Format::Time | Format::Hm => decimal_to_hm_string(value)
599            .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
600        _ => Value::Number(Number::from_f64(value).unwrap()),
601    }
602}
603
604fn process_excel_datetime_value(
605    datetime: &calamine::ExcelDateTime,
606    def_val: Option<Value>,
607    mode: DateTimeMode,
608) -> Value {
609    // Excel has no true time-only type -- a cell formatted as plain "hh:mm" (not the
610    // bracketed "[h]:mm:ss" duration format) is really a full datetime serial with zero
611    // elapsed days, which calamine converts by landing on its epoch ("1899-12-31" in the
612    // 1900 date system). Carrying that placeholder date through to a full ISO datetime
613    // string would misrepresent a genuine time-of-day value as if it were a real date,
614    // so a cell with no real date component (serial < 1.0) is auto-rendered as a bare
615    // time even without an explicit Format::Time/--time-only request -- for both Full
616    // and Simple modes, since Simple is still "the whole datetime", just reformatted.
617    let auto_time_only = matches!(mode, DateTimeMode::Full | DateTimeMode::Simple) && datetime.as_f64() < 1.0;
618    datetime.as_datetime().map_or_else(
619        || def_val.unwrap_or(Value::Null),
620        |dt| {
621            // Milliseconds are only meaningful in the default Full mode's genuine
622            // full-datetime output, kept for JS-interop compatibility; every other mode
623            // -- including Full/Simple's own bare-time fallback above -- renders plain
624            // "HH:MM:SS", since a value that's already been reduced to seconds-only
625            // precision (or came from an Excel "hh:mm"-formatted cell with no seconds
626            // at all) gains nothing from a trailing ".000".
627            let formatted_date = match mode {
628                DateTimeMode::DateOnly => dt.format("%Y-%m-%d").to_string(),
629                DateTimeMode::TimeOnly => dt.format("%H:%M:%S").to_string(),
630                DateTimeMode::HmOnly => dt.format("%H:%M").to_string(),
631                DateTimeMode::Simple if auto_time_only => dt.format("%H:%M:%S").to_string(),
632                DateTimeMode::Simple => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
633                DateTimeMode::Full if auto_time_only => dt.format("%H:%M:%S").to_string(),
634                DateTimeMode::Full => dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
635            };
636            Value::String(formatted_date)
637        },
638    )
639}
640
641fn process_iso_datetime_value(dt_str: &str, def_val: Option<Value>, mode: DateTimeMode) -> Value {
642    match mode {
643        DateTimeMode::DateOnly => iso_fuzzy_to_date_string(dt_str)
644            .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
645        DateTimeMode::TimeOnly => iso_fuzzy_to_datetime_string(dt_str)
646            .and_then(|full| extract_time_portion(&full, false))
647            .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
648        DateTimeMode::HmOnly => iso_fuzzy_to_datetime_string(dt_str)
649            .and_then(|full| extract_time_portion(&full, true))
650            .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
651        DateTimeMode::Simple => iso_fuzzy_to_datetime_string(dt_str)
652            .map(|full| simplify_datetime_string(&full))
653            .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
654        DateTimeMode::Full => iso_fuzzy_to_datetime_string(dt_str)
655            .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
656    }
657}
658
659/// Extracts just the time-of-day portion from a full ISO-8601 datetime string like
660/// "2023-06-15T10:17:00.000Z" -- the shape iso_fuzzy_to_datetime_string always produces
661/// -- as "HH:MM:SS" or, when `hm_only`, just "HH:MM"; milliseconds are always dropped,
662/// matching process_excel_datetime_value's native-cell equivalent. fuzzy-datetime itself
663/// has no time-only concept by design (it stays scoped to date/datetime completion and
664/// order-guessing); this is purely string surgery on its output for
665/// Format::Time/Format::Hm/--time-only/--hm-only, not a new parsing capability.
666fn extract_time_portion(full_datetime_str: &str, hm_only: bool) -> Option<String> {
667    let time_part = full_datetime_str.split('T').nth(1)?;
668    let time_part = time_part.trim_end_matches('Z');
669    let len = if hm_only { 5 } else { 8 };
670    time_part.get(0..len).map(str::to_string)
671}
672
673/// Strips the trailing ".mmmZ" milliseconds-and-Z suffix from a full ISO-8601 datetime
674/// string like "2023-06-15T10:17:00.000Z", leaving "2023-06-15T10:17:00" -- for
675/// Format::DateTimeSimple/--simple, where the millisecond precision and JS-interop-style
676/// trailing Z (both there for the default Full mode) are unwanted noise.
677fn simplify_datetime_string(full_datetime_str: &str) -> String {
678    let trimmed = full_datetime_str.trim_end_matches('Z');
679    match trimmed.split_once('.') {
680        Some((base, _)) => base.to_string(),
681        None => trimmed.to_string(),
682    }
683}
684
685/// Parses a plain-text date string with fuzzy-datetime's own order/separator guessing
686/// enabled (`date_opts: None`), rather than `iso_fuzzy_to_date_string`'s forced YMD + '-'
687/// assumption. Text cells and CSV values commonly use DMY or MDY order with '/' as the
688/// separator -- e.g. "19/07/2026" -- which the forced function rejects outright (it never
689/// even reaches order-guessing, since an explicit splitter was already given). The output
690/// shape is always the same canonical "YYYY-MM-DD" regardless of the input's order or
691/// separator -- only what's *accepted* as input is more flexible, not what's produced.
692fn guess_date_string(value: &str) -> Option<String> {
693    fuzzy_to_date_string(value, None)
694}
695
696/// Datetime equivalent of `guess_date_string` -- see its doc comment. Always produces the
697/// canonical "YYYY-MM-DDTHH:MM:SS.mmmZ" shape `iso_fuzzy_to_datetime_string` also
698/// produces, just with flexible input-side order/separator guessing instead of a forced
699/// YMD + '-' assumption.
700fn guess_datetime_string(value: &str) -> Option<String> {
701    fuzzy_to_datetime_string_opts(value, 'T', None, Some(':'), true)
702}
703
704/// Recognizes a string that's already plausibly just a bare time -- colon-separated
705/// ("11:39") or dot-separated ("12.30", the common case where a user typed a time with
706/// '.' instead of ':' and the cell was explicitly text-formatted, so Excel/Sheets never
707/// got the chance to convert it to a float; a string never loses the trailing-zero
708/// information a float would, so unlike decimal_to_hm_string below, no numeric
709/// reconstruction is needed here, just a separator swap). Only ever consulted as a
710/// fallback once iso_fuzzy_to_datetime_string has already failed to parse the string as
711/// a full datetime (see the callers below), so a genuine full datetime string -- which
712/// always succeeds there first -- never reaches this at all; there's no risk of
713/// misclassifying one.
714///
715/// '.' is swapped for ':' first (a no-op if there wasn't one), then the individual
716/// components are extracted directly as integers via `to_numbers::<u8>()` -- no regex,
717/// no manual splitting -- and accepted as a plausible 2- or 3-part hh:mm(:ss) shape,
718/// reformatted with zero-padding. Minutes/seconds are bounded to < 60 (always true
719/// regardless of what the hours represent), but hours are *not* capped at < 24: a "time"
720/// column is just as often a duration (elapsed hours, a race/video/timesheet total) as a
721/// time-of-day, and durations routinely exceed 23 hours (e.g. "27:45:00") -- there's no
722/// way to tell which one a given column means from the value alone, so this doesn't
723/// guess and reject perfectly valid durations. An explicit Format::Time/Format::Hm
724/// override is still "I know this is a time" -- a well-formed hh:mm(:ss) shape is
725/// trusted -- but a clearly-implausible one ("11:60") or anything that isn't 2-3 numeric
726/// parts is rejected rather than passed through as a bogus result.
727///
728/// A trailing AM/PM marker is the one exception: it's detected up front via
729/// `ends_with_ci`, and once detected the hour is reinterpreted as 12-hour-clock (1-12)
730/// rather than rejected outright -- "12:45am" is 00:45, "2:30pm" is 14:30, "12:00pm" (noon)
731/// stays 12. An hour outside 1-12 alongside an am/pm marker ("14:00pm") isn't genuine
732/// 12-hour notation at all -- the 12-hour clock never produces those values -- so rather
733/// than treating it as contradictory and rejecting it, the hour is trusted as already
734/// being literal 24-hour and the (redundant) suffix is simply dropped: "14:00pm" is
735/// "14:00". This is consistent, if non-standard, data entry (some spreadsheet sources
736/// append am/pm out of habit regardless of hour), not the malformed/mistaken-type case
737/// this function otherwise sanitizes against.
738///
739/// Any OTHER letters -- a duration-unit suffix ("2h45m"), stray text -- still reject
740/// outright before ever calling to_numbers(). `to_numbers()` silently discards non-digit
741/// characters, so without this guard "2h45m" would extract as [2, 45] and format as
742/// "02:45" -- not a rejection, a *wrong* answer indistinguishable from a correct one.
743fn parse_bare_time_string(value: &str) -> Option<String> {
744    let has_am_suffix = value.ends_with_ci("am");
745    let has_pm_suffix = !has_am_suffix && value.ends_with_ci("pm");
746    let is_12_hour = has_am_suffix || has_pm_suffix;
747
748    if !is_12_hour && value.has_alphabetic() {
749        return None;
750    }
751
752    // Strip the am/pm suffix itself before extraction -- to_numbers() happens to treat
753    // any letter as a boundary and skip it anyway, but stripping explicitly here doesn't
754    // depend on that being true of every input shape or future version of to_numbers().
755    let numeric_part = if is_12_hour {
756        value[..value.len() - 2].trim_end()
757    } else {
758        value
759    };
760
761    let numbers: Vec<u8> = numeric_part.replace('.', ":").to_numbers();
762
763    let resolve_hour = |hrs: u8| -> u8 {
764        if !is_12_hour || !(1..=12).contains(&hrs) {
765            // Not ambiguous 12-hour notation -- either no am/pm marker at all, or an
766            // hour a real 12-hour clock could never produce -- so the hour is used as
767            // literally given and any am/pm marker present is redundant, not corrective.
768            return hrs;
769        }
770        match (has_am_suffix, hrs) {
771            (true, 12) => 0,        // 12am -> midnight
772            (true, _) => hrs,       // 1am-11am unchanged
773            (false, 12) => 12,      // 12pm -> noon, unchanged
774            (false, _) => hrs + 12, // 1pm-11pm -> +12
775        }
776    };
777
778    match numbers.as_slice() {
779        [hrs, mins] if *mins < 60 => Some(format!("{:02}:{:02}", resolve_hour(*hrs), mins)),
780        [hrs, mins, secs] if *mins < 60 && *secs < 60 => {
781            Some(format!("{:02}:{:02}:{:02}", resolve_hour(*hrs), mins, secs))
782        }
783        _ => None,
784    }
785}
786
787/// Reconstructs a sexagesimal "HH:MM" time from a plain decimal number, for the common
788/// real-world case where a spreadsheet app silently turned a user's dot-typed time entry
789/// (e.g. typing "12.30", meaning 12:30) into a bare float with the trailing zero
790/// stripped ("12.3") -- Excel/Sheets have no dedicated time type for a dot-typed value,
791/// so it's read as a plain decimal number instead. Used by process_float_value for
792/// Format::Time/Format::Hm on a genuine (native, non-string) numeric cell, which is
793/// presumed to mean exactly this, not a generic decimal-to-time conversion (0.3 of an
794/// hour is not 30 minutes -- the digits after the point are read literally as MM, never
795/// scaled by 60). The string-cell equivalent is simpler and doesn't need this: see
796/// parse_bare_time_string's doc comment for why.
797///
798/// The fractional part is formatted to a fixed 2 decimal places first, since ".3" and
799/// ".30" are otherwise indistinguishable by the time this runs -- the trailing zero, if
800/// there ever was one, is already gone -- then those two digits are read directly as MM.
801/// Only succeeds when that reads as a plausible minute value (< 60); the whole part
802/// (hours) is *not* capped at < 24, matching parse_bare_time_string's own reasoning --
803/// a "time" column is just as often a duration (elapsed hours) as a time-of-day, and
804/// durations routinely exceed 23 hours. Negative values are rejected (not a time or a
805/// duration either way), and the hour is read as a `u8`, so it naturally caps at 255
806/// rather than growing unbounded. A genuine decimal quantity like a price or percentage
807/// (e.g. 12.75) is still correctly left alone by the minutes check. HH:MM only,
808/// deliberately -- there's no way to recover seconds from a single decimal fraction.
809fn decimal_to_hm_string(value: f64) -> Option<String> {
810    if value < 0.0 {
811        return None;
812    }
813    let hours = value.trunc() as u8;
814    let frac_digits = format!("{:.2}", value.fract().abs());
815    let minutes: u32 = frac_digits.split('.').nth(1)?.parse().ok()?;
816    if minutes >= 60 {
817        return None;
818    }
819    Some(format!("{:02}:{:02}", hours, minutes))
820}
821
822fn process_string_value(value: &str, format: Format, def_val: Option<Value>) -> Value {
823    match format {
824        Format::Boolean => process_truthy_value(value, def_val, |v, ef| v.is_truthy_core(ef)),
825        Format::Truthy => process_truthy_value(value, def_val, |v, ef| v.is_truthy_standard(ef)),
826        Format::TruthyCustom(opts) => process_truthy_value(value, def_val, |v, _| {
827            v.is_truthy_custom(&opts)
828        }),
829        Format::Decimal(places) => {
830            process_numeric_value(value, def_val, |n| float_value(n.round_decimal(places)))
831        }
832        Format::Float => process_numeric_value(value, def_val, float_value),
833        // Pre-existing gap, not new to Format::Array: process_string_value had no
834        // Format::Integer arm at all, unlike csv_cell_to_json_value's own separate
835        // numeric-sniffing logic which already handles it correctly for a whole cell --
836        // a native xlsx/ods *string* cell (or, now, any Format::Array element) under
837        // Format::Integer silently passed through as a plain string. Same truncate-not-
838        // round behavior as the existing Integer handling elsewhere (58.2 -> 58).
839        Format::Integer => process_numeric_value(value, def_val, |n| {
840            Number::from_i128(n as i128).map(Value::Number).unwrap_or(Value::Null)
841        }),
842        Format::Date => process_date_value(value, def_val, guess_date_string),
843        Format::DateTime => process_date_value(value, def_val, guess_datetime_string),
844        Format::DateTimeSimple => process_date_value(value, def_val, |s| {
845            guess_datetime_string(s).map(|full| simplify_datetime_string(&full))
846        }),
847        Format::Time => process_date_value(value, def_val, |s| {
848            guess_datetime_string(s)
849                .and_then(|full| extract_time_portion(&full, false))
850                .or_else(|| parse_bare_time_string(s))
851        }),
852        Format::Hm => process_date_value(value, def_val, |s| {
853            guess_datetime_string(s)
854                .and_then(|full| extract_time_portion(&full, true))
855                .or_else(|| parse_bare_time_string(s))
856        }),
857        Format::Array(element_format, separator) => process_array_value(value, &element_format, &separator),
858        _ => Value::String(value.to_owned()),
859    }
860}
861
862fn process_truthy_value<F>(value: &str, def_val: Option<Value>, truthy_fn: F) -> Value
863where
864    F: Fn(&str, bool) -> Option<bool>,
865{
866    if let Some(is_true) = truthy_fn(value, false) {
867        Value::Bool(is_true)
868    } else {
869        def_val.unwrap_or(Value::Null)
870    }
871}
872
873fn process_numeric_value<F>(value: &str, def_val: Option<Value>, numeric_fn: F) -> Value
874where
875    F: Fn(f64) -> Value,
876{
877    if let Some(n) = value.to_first_number::<f64>() {
878        numeric_fn(n)
879    } else {
880        def_val.unwrap_or(Value::Null)
881    }
882}
883
884fn process_date_value<F>(value: &str, def_val: Option<Value>, date_fn: F) -> Value
885where
886    F: Fn(&str) -> Option<String>,
887{
888    if let Some(date_str) = date_fn(value) {
889        string_value(&date_str)
890    } else {
891        def_val.unwrap_or(Value::Null)
892    }
893}
894
895/// Splits `value` on `separator` via `to_segments` (no hand-rolled `.split()`), trims
896/// each piece, and formats it as `element_format` by recursing into process_string_value
897/// -- reusing the exact same per-format parsing every ordinary scalar cell already goes
898/// through, rather than reimplementing it per element. Each element's own def_val is
899/// always None: a single unparseable piece becomes null in place (visible, positional)
900/// rather than substituting some row/column-wide default into the middle of an array.
901fn process_array_value(value: &str, element_format: &Format, separator: &str) -> Value {
902    Value::Array(
903        value
904            .to_segments(separator)
905            .iter()
906            .map(|segment| process_string_value(segment.trim(), element_format.clone(), None))
907            .collect(),
908    )
909}
910
911// Convert csv rows to value
912fn csv_row_result_to_values(
913    result: Result<StringRecord, csv::Error>,
914    opts: &RowOptionSet,
915) -> Option<Vec<Value>> {
916    if let Ok(record) = result {
917        let row = record
918            .into_iter()
919            .enumerate()
920            .map(|(ci, cell)| csv_cell_to_json_value(cell, opts, ci))
921            .collect();
922        return Some(row);
923    }
924    None
925}
926
927// convert CSV cell &str value to a polymorphic serde_json::VALUE
928fn csv_cell_to_json_value(cell: &str, opts: &RowOptionSet, index: usize) -> Value {
929    // clean cell to check if it's numeric
930    let col = opts.column(index);
931    let (fmt, euro_num_mode) = if let Some(c) = col {
932        (c.format.clone(), c.decimal_comma)
933    } else {
934        (Format::Auto, opts.decimal_comma)
935    };
936    // A column's own Format::Date/Format::Time/Format::Hm/Format::DateTime/
937    // Format::DateTimeSimple override is checked before any numeric parsing, since a
938    // date-like string ("2023-06-15") can
939    // otherwise be misread as starting with a plain number ("2023") and fall through to
940    // Value::Number instead of going through fuzzy-datetime at all.
941    let def_val = col.and_then(|c| c.default.clone());
942    match fmt {
943        Format::Date => return process_date_value(cell, def_val, guess_date_string),
944        Format::DateTime => return process_date_value(cell, def_val, guess_datetime_string),
945        Format::DateTimeSimple => {
946            return process_date_value(cell, def_val, |s| {
947                guess_datetime_string(s).map(|full| simplify_datetime_string(&full))
948            })
949        }
950        Format::Time => {
951            return process_date_value(cell, def_val, |s| {
952                guess_datetime_string(s)
953                    .and_then(|full| extract_time_portion(&full, false))
954                    .or_else(|| parse_bare_time_string(s))
955            })
956        }
957        Format::Hm => {
958            return process_date_value(cell, def_val, |s| {
959                guess_datetime_string(s)
960                    .and_then(|full| extract_time_portion(&full, true))
961                    .or_else(|| parse_bare_time_string(s))
962            })
963        }
964        // Checked early, same as the date/time formats above and for the same reason:
965        // a multi-value cell like "34.8,78.3" would otherwise be misread by the
966        // numeric-looks-like sniffing below, which only ever sees to_first_number's
967        // *first* match and would silently drop everything after the first separator.
968        Format::Array(ref element_format, ref separator) => {
969            return process_array_value(cell, element_format, separator)
970        }
971        _ => {}
972    }
973    let has_number = cell.to_first_number::<f64>().is_some();
974    // Strips a leading/trailing currency symbol or other decoration ("£1,999.99",
975    // "$45.50") that to_first_number already looks past to detect a number, but which
976    // the is_numeric() check below (and Number::from_str after it) would otherwise
977    // reject outright. Trims only non-alphanumeric symbol characters -- never a letter --
978    // so a genuine alphanumeric identifier like "SKU001" is left completely untouched
979    // (an earlier version of this fix trimmed by "not a digit", which also ate the "SKU"
980    // prefix, turning it into the bare digits "001" and misreading it as a real number).
981    let trimmed_cell = cell.trim_matches(|c: char| !c.is_alphanumeric() && c != ',' && c != '.' && c != '-');
982    let num_cell = if has_number {
983        let euro_num_mode = uses_decimal_comma(trimmed_cell, euro_num_mode);
984        if euro_num_mode {
985            trimmed_cell.replace(",", ".").replace(",", ".")
986        } else {
987            trimmed_cell.replace(",", "")
988        }
989    } else {
990        cell.to_owned()
991    };
992    let mut new_cell = Value::Null;
993    if !num_cell.is_empty() && num_cell.is_numeric() {
994        if let Ok(float_val) = serde_json::Number::from_str(&num_cell) {
995            match fmt {
996                Format::Integer => {
997                    // as_i128() only succeeds for Numbers that are already integer-valued
998                    // internally, so it silently yields 0 for any decimal value (e.g. "58.2")
999                    // via unwrap_or(0). Go through as_f64() and truncate instead, matching
1000                    // the equivalent xlsx/ods cell conversion in process_float_value above.
1001                    if let Some(f) = float_val.as_f64() {
1002                        if let Some(int_val) = Number::from_i128(f as i128) {
1003                            new_cell = Value::Number(int_val);
1004                        }
1005                    }
1006                }
1007                Format::Boolean => {
1008                    // only 1.0 or more will evaluate as true
1009                    new_cell = Value::Bool(float_val.as_f64().unwrap_or(0f64) >= 1.0);
1010                }
1011                Format::Decimal(places) => {
1012                    if let Some(f) = float_val.as_f64() {
1013                        new_cell = float_value(f.round_decimal(places));
1014                    }
1015                }
1016                _ => {
1017                    new_cell = Value::Number(float_val);
1018                }
1019            }
1020        }
1021    } else if let Some(is_true) = cell.is_truthy_core(false) {
1022        new_cell = Value::Bool(is_true);
1023    } else {
1024        new_cell = match fmt {
1025            Format::Truthy => {
1026                if let Some(is_true) = cell.is_truthy_standard(false) {
1027                    Value::Bool(is_true)
1028                } else {
1029                    Value::Null
1030                }
1031            }
1032            _ => Value::String(cell.to_string()),
1033        };
1034    }
1035    new_cell
1036}
1037
1038pub async fn read_workbook_sheet_info<'a>(
1039    path_data: &PathData<'a>,
1040) -> Result<IndexMap<String, usize>, GenericError> {
1041    if let Ok(mut workbook) = open_workbook_auto(path_data.path()) {
1042        let mut im: IndexMap<String, usize> = IndexMap::new();
1043        for name in workbook.sheet_names() {
1044            if let Ok(range) = workbook.worksheet_range(&name) {
1045                im.insert(name, range.rows().count());
1046            }
1047        }
1048        Ok(im)
1049    } else {
1050        Err(GenericError("cannot_open_workbook"))
1051    }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057    use crate::{helpers::*, Column, KeySegment};
1058    use serde_json::json;
1059    use std::path;
1060    use std::sync::Arc;
1061
1062    /// Generates a workbook laid out the way many real-world spreadsheets are: a title
1063    /// row, a notes row, the real header row, a blank gap row, then the actual data --
1064    /// for testing header_row/data_row_index against a realistic gap scenario. Row 0
1065    /// (0-based) "Report Title", row 1 "Generated 2026-01-01", row 2 header ("sku",
1066    /// "qty"), row 3 blank, rows 4-5 data (SKU001/10, SKU002/20).
1067    fn gen_header_gap_fixture(filename: &str) -> String {
1068        use rust_xlsxwriter::Workbook;
1069        let mut workbook = Workbook::new();
1070        let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1071        sheet.write_string(0, 0, "Report Title").unwrap();
1072        sheet.write_string(1, 0, "Generated 2026-01-01").unwrap();
1073        sheet.write_string(2, 0, "sku").unwrap();
1074        sheet.write_string(2, 1, "qty").unwrap();
1075        // row 3 intentionally left blank
1076        sheet.write_string(4, 0, "SKU001").unwrap();
1077        sheet.write_number(4, 1, 10.0).unwrap();
1078        sheet.write_string(5, 0, "SKU002").unwrap();
1079        sheet.write_number(5, 1, 20.0).unwrap();
1080        let path = std::env::temp_dir().join(filename);
1081        workbook.save(&path).unwrap();
1082        path.to_string_lossy().to_string()
1083    }
1084
1085    /// The user-supplied example that motivated auto-detection: a title row, a proper
1086    /// 3-column header, an explanatory-text row that only fills one cell, then real
1087    /// data. header_row=1, data_row_index=3 (both 0-based) is the expected guess.
1088    fn gen_auto_detect_fixture(filename: &str) -> String {
1089        use rust_xlsxwriter::Workbook;
1090        let mut workbook = Workbook::new();
1091        let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1092        sheet.write_string(0, 0, "Sales 2025").unwrap();
1093        sheet.write_string(1, 0, "region").unwrap();
1094        sheet.write_string(1, 1, "team size").unwrap();
1095        sheet.write_string(1, 2, "revenue").unwrap();
1096        sheet.write_string(2, 0, "long explanation about the data").unwrap();
1097        sheet.write_string(3, 0, "west").unwrap();
1098        sheet.write_number(3, 1, 12.0).unwrap();
1099        sheet.write_number(3, 2, 923456.0).unwrap();
1100        sheet.write_string(4, 0, "east").unwrap();
1101        sheet.write_number(4, 1, 7.0).unwrap();
1102        sheet.write_number(4, 2, 817285.0).unwrap();
1103        let path = std::env::temp_dir().join(filename);
1104        workbook.save(&path).unwrap();
1105        path.to_string_lossy().to_string()
1106    }
1107
1108    /// The user's own motivating example for header_row_span: a merged "2015"/"2025"
1109    /// year row (calamine only ever reports a merge's value in its top-left cell,
1110    /// leaving the rest blank -- reproduced here by simply not writing anything to
1111    /// those cells, no real xlsx merge syntax needed since combine_header_rows can't
1112    /// tell the difference anyway) over a "North"/"Midlands"/"South" sub-label row,
1113    /// then one data row.
1114    fn gen_multi_row_header_fixture(filename: &str) -> String {
1115        use rust_xlsxwriter::Workbook;
1116        let mut workbook = Workbook::new();
1117        let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1118        sheet.write_string(0, 0, "2015").unwrap();
1119        // (0,1) and (0,2) intentionally left blank -- the merge shadow
1120        sheet.write_string(0, 3, "2025").unwrap();
1121        // (0,4) and (0,5) intentionally left blank
1122        sheet.write_string(1, 0, "North").unwrap();
1123        sheet.write_string(1, 1, "Midlands").unwrap();
1124        sheet.write_string(1, 2, "South").unwrap();
1125        sheet.write_string(1, 3, "North").unwrap();
1126        sheet.write_string(1, 4, "Midlands").unwrap();
1127        sheet.write_string(1, 5, "South").unwrap();
1128        sheet.write_number(2, 0, 100.0).unwrap();
1129        sheet.write_number(2, 1, 101.0).unwrap();
1130        sheet.write_number(2, 2, 102.0).unwrap();
1131        sheet.write_number(2, 3, 200.0).unwrap();
1132        sheet.write_number(2, 4, 201.0).unwrap();
1133        sheet.write_number(2, 5, 202.0).unwrap();
1134        let path = std::env::temp_dir().join(filename);
1135        workbook.save(&path).unwrap();
1136        path.to_string_lossy().to_string()
1137    }
1138
1139    #[test]
1140    fn test_header_row_span_combines_a_merged_year_row_with_a_region_row_xlsx() {
1141        let path = gen_multi_row_header_fixture("multi_row_header.xlsx");
1142        let opts = OptionSet::new(&path).header_row(0).header_row_span(2);
1143        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1144        assert_eq!(rows.len(), 1, "both header rows must be consumed, not leak through as data");
1145        let row = &rows[0];
1146        // genuine Data::Float xlsx cells (written via write_number)
1147        assert_eq!(row.get("2015_north"), Some(&json!(100.0)));
1148        assert_eq!(row.get("2015_midlands"), Some(&json!(101.0)));
1149        assert_eq!(row.get("2015_south"), Some(&json!(102.0)));
1150        assert_eq!(row.get("2025_north"), Some(&json!(200.0)));
1151        assert_eq!(row.get("2025_midlands"), Some(&json!(201.0)));
1152        assert_eq!(row.get("2025_south"), Some(&json!(202.0)));
1153    }
1154
1155    #[test]
1156    fn test_header_row_span_default_of_one_is_unchanged_from_today_xlsx() {
1157        // header_row_span left at its default (1) -- must behave byte-for-byte like the
1158        // existing single-header-row path, using the same fixture as the gap-row test.
1159        let path = gen_header_gap_fixture("single_row_header_unchanged.xlsx");
1160        let opts = OptionSet::new(&path).header_row(2).data_row_index(4);
1161        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1162        assert_eq!(rows.len(), 2);
1163        assert_eq!(rows[0].get("sku"), Some(&json!("SKU001")));
1164        assert_eq!(rows[0].get("qty"), Some(&json!(10.0)));
1165    }
1166
1167    #[test]
1168    fn test_detect_header_opt_in_finds_header_and_data_row_xlsx() {
1169        // detect_header is off by default for direct library use (see the field's own
1170        // doc) -- callers that want auto-detection opt in explicitly via .detect_header().
1171        let path = gen_auto_detect_fixture("auto_detect.xlsx");
1172        let opts = OptionSet::new(&path).detect_header();
1173        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1174        assert_eq!(rows.len(), 2, "the explanation row must not leak through as data");
1175        assert_eq!(rows[0].get("region"), Some(&json!("west")));
1176        assert_eq!(rows[0].get("team_size"), Some(&json!(12.0)));
1177        assert_eq!(rows[0].get("revenue"), Some(&json!(923456.0)));
1178        assert_eq!(rows[1].get("region"), Some(&json!("east")));
1179    }
1180
1181    #[test]
1182    fn test_detect_header_opt_in_finds_header_and_data_row_csv() {
1183        let path = write_csv_fixture(
1184            "auto_detect.csv",
1185            "Sales 2025\nregion,team size,revenue\nlong explanation about the data\nwest,12,923456\neast,7,817285\n",
1186        );
1187        let opts = OptionSet::new(&path).detect_header();
1188        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1189        assert_eq!(rows.len(), 2, "the explanation row must not leak through as data");
1190        assert_eq!(rows[0].get("region"), Some(&json!("west")));
1191        assert_eq!(rows[0].get("team_size"), Some(&json!(12)));
1192        assert_eq!(rows[1].get("region"), Some(&json!("east")));
1193    }
1194
1195    #[test]
1196    fn test_detect_header_falls_back_to_fallback_naming_for_headerless_text_csv() {
1197        // No header row exists at all, and there's no numeric/boolean/date signal
1198        // anywhere (a content-migration file) -- detection must not consume the first
1199        // row as a bogus header, losing it as data. Field names fall back to A1-style
1200        // letters, same as --omit-header, since there's no header text to derive from.
1201        let path = write_csv_fixture(
1202            "headerless_migration.csv",
1203            "welcome_msg,Welcome to our store,Bienvenue dans notre magasin\ngoodbye_msg,Thank you for visiting,Merci de votre visite\n",
1204        );
1205        let opts = OptionSet::new(&path).detect_header();
1206        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1207        assert_eq!(rows.len(), 2, "both rows must be present, none consumed as a header");
1208        assert_eq!(rows[0].get("a"), Some(&json!("welcome_msg")));
1209        assert_eq!(rows[0].get("b"), Some(&json!("Welcome to our store")));
1210        assert_eq!(rows[1].get("a"), Some(&json!("goodbye_msg")));
1211    }
1212
1213    #[test]
1214    fn test_detect_header_falls_back_to_fallback_naming_for_headerless_text_xlsx() {
1215        use rust_xlsxwriter::Workbook;
1216        let mut workbook = Workbook::new();
1217        let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1218        sheet.write_string(0, 0, "welcome_msg").unwrap();
1219        sheet.write_string(0, 1, "Welcome to our store").unwrap();
1220        sheet.write_string(0, 2, "Bienvenue dans notre magasin").unwrap();
1221        sheet.write_string(1, 0, "goodbye_msg").unwrap();
1222        sheet.write_string(1, 1, "Thank you for visiting").unwrap();
1223        sheet.write_string(1, 2, "Merci de votre visite").unwrap();
1224        let path = std::env::temp_dir().join("headerless_migration.xlsx");
1225        workbook.save(&path).unwrap();
1226
1227        let opts = OptionSet::new(path.to_str().unwrap()).detect_header();
1228        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1229        assert_eq!(rows.len(), 2, "both rows must be present, none consumed as a header");
1230        assert_eq!(rows[0].get("a"), Some(&json!("welcome_msg")));
1231        assert_eq!(rows[1].get("a"), Some(&json!("goodbye_msg")));
1232    }
1233
1234    #[test]
1235    fn test_omit_header_uses_fallback_names_for_xlsx_not_real_header_text() {
1236        // Regression: --omit-header used to be a no-op for xlsx/ods -- real header text
1237        // was still used for field names regardless of the flag, because the row-0
1238        // shortcut (range.headers()) ran unconditionally. Now gated on capture_headers,
1239        // shared with the detect_header fallback-naming path above.
1240        let sample_path = "data/sample-data-1.xlsx";
1241        let opts = OptionSet::new(sample_path).omit_header();
1242        let result = process_spreadsheet_direct(&opts).unwrap();
1243        assert!(result.keys.contains(&"a".to_string()), "got: {:?}", result.keys);
1244        assert!(!result.keys.contains(&"id".to_string()), "got: {:?}", result.keys);
1245        let rows = result.to_vec();
1246        // the literal header row's own text is now the first "data" row, since no row
1247        // is consumed for headers at all
1248        assert_eq!(rows[0].get("a"), Some(&json!("id")));
1249    }
1250
1251    #[test]
1252    fn test_detect_header_off_by_default_leaves_notes_rows_uncleaned() {
1253        // Without .detect_header(), the library falls back to its old, simple default
1254        // (row 0 is the header) even on a file that has title/notes rows -- this is the
1255        // behavior direct library consumers should see unless they opt in.
1256        let path = gen_auto_detect_fixture("auto_detect_no_opt_in.xlsx");
1257        let opts = OptionSet::new(&path);
1258        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1259        // row 0 ("Sales 2025") is treated as the header; every subsequent row (including
1260        // the real header and the notes row) is captured as data instead of being cleaned up
1261        assert_eq!(rows.len(), 4);
1262    }
1263
1264    #[test]
1265    fn test_detect_header_does_not_change_output_for_a_normal_well_formed_file() {
1266        // Regression guard: with .detect_header() opted in, a file with no title/notes
1267        // rows at all (row 0 is already a perfectly good header) must produce identical
1268        // output to the plain default -- detection should just confirm row 0, not guess
1269        // something else.
1270        let sample_path = "data/sample-data-1.xlsx";
1271        let opts = OptionSet::new(sample_path).max_row_count(1_000).detect_header();
1272        let result = process_spreadsheet_direct(&opts).unwrap();
1273        assert_eq!(result.num_rows, 401);
1274        assert_eq!(result.to_vec()[0].get("first_name"), Some(&json!("Dulce")));
1275    }
1276
1277    #[test]
1278    fn test_header_row_without_data_row_index_captures_the_gap_row_as_data() {
1279        // Baseline: with only header_row set (no data_row_index), the row directly
1280        // below the header is treated as data, even though it's actually a blank gap
1281        // row here -- this is the pre-existing behavior data_row_index exists to fix.
1282        let path = gen_header_gap_fixture("header_gap_baseline.xlsx");
1283        let opts = OptionSet::new(&path).header_row(2);
1284        let result = process_spreadsheet_direct(&opts).unwrap();
1285        let rows = result.to_vec();
1286        assert_eq!(rows.len(), 3, "gap row, SKU001, SKU002");
1287        assert_eq!(rows[0].get("sku"), Some(&Value::Null));
1288        assert_eq!(rows[1].get("sku"), Some(&json!("SKU001")));
1289        assert_eq!(rows[2].get("sku"), Some(&json!("SKU002")));
1290    }
1291
1292    #[test]
1293    fn test_data_row_index_skips_the_gap_between_header_and_real_data() {
1294        // header_row=2 (the "sku"/"qty" row), data_row_index=4 (the SKU001 row), both
1295        // 0-based -- row 3, the blank gap row, is skipped entirely rather than captured
1296        // as a null row.
1297        let path = gen_header_gap_fixture("header_gap_with_data_row.xlsx");
1298        let opts = OptionSet::new(&path).header_row(2).data_row_index(4);
1299        let result = process_spreadsheet_direct(&opts).unwrap();
1300        let rows = result.to_vec();
1301        assert_eq!(rows.len(), 2, "just SKU001 and SKU002, no gap row");
1302        assert_eq!(rows[0].get("sku"), Some(&json!("SKU001")));
1303        assert_eq!(rows[0].get("qty"), Some(&json!(10.0)));
1304        assert_eq!(rows[1].get("sku"), Some(&json!("SKU002")));
1305        assert_eq!(rows[1].get("qty"), Some(&json!(20.0)));
1306    }
1307
1308    #[test]
1309    fn test_result_set_reports_the_resolved_header_and_body_start_indices() {
1310        // Regression: ResultSet.header_row_index/body_start_index used to not exist at
1311        // all -- callers had no way to learn what row indices a read actually used,
1312        // whether from an explicit override (this test) or auto-detection (the next
1313        // one), since OptionSet.header_row/.data_row_index only ever reflect an
1314        // explicit override and stay None otherwise.
1315        let path = gen_header_gap_fixture("header_gap_reports_resolved_indices.xlsx");
1316        let opts = OptionSet::new(&path).header_row(2).data_row_index(4);
1317        let result = process_spreadsheet_direct(&opts).unwrap();
1318        assert_eq!(result.header_row_index, Some(2));
1319        assert_eq!(result.body_start_index, 4);
1320    }
1321
1322    #[test]
1323    fn test_result_set_reports_resolved_indices_from_auto_detection_too() {
1324        // Same as above, but with no explicit header_row/data_row_index at all -- the
1325        // resolved indices must reflect what auto-detection actually found, not just
1326        // echo back the (unset) explicit-override fields.
1327        let path = gen_header_gap_fixture("header_gap_reports_detected_indices.xlsx");
1328        let opts = OptionSet::new(&path).detect_header();
1329        let result = process_spreadsheet_direct(&opts).unwrap();
1330        assert_eq!(result.header_row_index, Some(2));
1331        assert_eq!(result.body_start_index, 4);
1332    }
1333
1334    #[test]
1335    fn test_data_row_index_in_preview_multimode_skips_the_gap_too() {
1336        // Same fixture, read via --preview (multimode) -- the gap-skipping logic is
1337        // duplicated in read_multiple_worksheets, so it needs its own regression test.
1338        let path = gen_header_gap_fixture("header_gap_preview.xlsx");
1339        let opts = OptionSet::new(&path).header_row(2).data_row_index(4).read_mode_preview();
1340        let result = process_spreadsheet_direct(&opts).unwrap();
1341        let sheet_rows = result.data.first_sheet();
1342        assert_eq!(sheet_rows.len(), 2, "just SKU001 and SKU002, no gap row");
1343        assert_eq!(sheet_rows[0].get("sku"), Some(&json!("SKU001")));
1344        assert_eq!(sheet_rows[1].get("sku"), Some(&json!("SKU002")));
1345    }
1346
1347    #[test]
1348    fn test_direct_processing_xlsx() {
1349        let sample_path = "data/sample-data-1.xlsx";
1350
1351        // instantiate the OptionSet with a sample path and a maximum row count of 1000 rows as the source file has 401 rows
1352        // (although )the default max is 10,000)
1353        let opts = OptionSet::new(sample_path).max_row_count(1_000);
1354
1355        let result = process_spreadsheet_direct(&opts);
1356
1357        // The source file should have 1 header row and 400 data rows
1358        assert_eq!(result.unwrap().num_rows, 401);
1359    }
1360
1361    #[test]
1362    fn test_source_key_override_renames_and_reformats_without_position() {
1363        // End-to-end: overriding just one field out of many by its natural key,
1364        // without needing to enumerate/pad the columns ahead of it.
1365        let sample_path = "data/sample-data-1.csv";
1366        let mut opts = OptionSet::new(sample_path).max_row_count(2);
1367        opts.rows.columns = vec![
1368            Column::from_source_key_with_format("weight", Some("weight_lbs"), Format::Integer, None, DateTimeMode::Full, false),
1369        ];
1370
1371        let result = process_spreadsheet_direct(&opts).unwrap();
1372        // every other column keeps its natural, auto-detected name
1373        assert!(result.keys.contains(&"id".to_string()));
1374        assert!(result.keys.contains(&"first_name".to_string()));
1375        assert!(result.keys.contains(&"weight_lbs".to_string()));
1376        assert!(!result.keys.contains(&"weight".to_string()));
1377
1378        let rows = result.to_vec();
1379        let first = rows.first().expect("at least one row");
1380        assert!(first.get("weight_lbs").is_some());
1381        assert!(first.get("weight").is_none());
1382        assert_eq!(first.get("id").unwrap(), 1);
1383    }
1384
1385    #[test]
1386    fn test_column_key_as_key_segment_nests_two_columns_into_one_object_end_to_end() {
1387        // Integration coverage for Column.key: Option<KeySegment> actually being
1388        // consulted through the real process_spreadsheet_direct pipeline, not just the
1389        // isolated insert_key_segment unit tests in key_segment.rs -- "weight" and
1390        // "height" (two ordinary flat columns in the sample CSV) are remapped to nest
1391        // under one "measurements" object instead of staying flat siblings.
1392        let sample_path = "data/sample-data-1.csv";
1393        let mut opts = OptionSet::new(sample_path).max_row_count(1);
1394
1395        let mut weight_col = Column::from_source_key_with_format("weight", None, Format::Integer, None, DateTimeMode::Full, false);
1396        weight_col.key = Some(KeySegment::Object(Arc::from("measurements"), Arc::new(KeySegment::Simple(Arc::from("weight")))));
1397
1398        let mut height_col = Column::from_source_key_with_format("height", None, Format::Integer, None, DateTimeMode::Full, false);
1399        height_col.key = Some(KeySegment::Object(Arc::from("measurements"), Arc::new(KeySegment::Simple(Arc::from("height")))));
1400
1401        opts.rows.columns = vec![weight_col, height_col];
1402
1403        let result = process_spreadsheet_direct(&opts).unwrap();
1404        let rows = result.to_vec();
1405        let first = rows.first().expect("at least one row");
1406
1407        // both original flat columns are gone -- their values only exist nested now
1408        assert!(first.get("weight").is_none());
1409        assert!(first.get("height").is_none());
1410
1411        let measurements = first.get("measurements").expect("measurements object present");
1412        assert_eq!(measurements.get("weight").unwrap(), 103);
1413        assert_eq!(measurements.get("height").unwrap(), 164);
1414    }
1415
1416    #[test]
1417    fn test_column_from_json_nested_key_segment_drives_real_output_end_to_end() {
1418        // Same nesting as the test above, but built entirely from JSON via
1419        // Column::from_json/OptionSet::override_columns -- the path a frontend UI or Web
1420        // API would actually use, with no Rust KeySegment construction at all.
1421        let sample_path = "data/sample-data-1.csv";
1422        let opts = OptionSet::new(sample_path).max_row_count(1).override_columns(&[
1423            serde_json::json!({
1424                "source_key": "weight",
1425                "format": "integer",
1426                "key": {"type": "object", "key": "measurements", "next": "weight"}
1427            }),
1428            serde_json::json!({
1429                "source_key": "height",
1430                "format": "integer",
1431                "key": {"type": "object", "key": "measurements", "next": "height"}
1432            }),
1433        ]);
1434
1435        let result = process_spreadsheet_direct(&opts).unwrap();
1436        let rows = result.to_vec();
1437        let first = rows.first().expect("at least one row");
1438
1439        assert!(first.get("weight").is_none());
1440        assert!(first.get("height").is_none());
1441        let measurements = first.get("measurements").expect("measurements object present");
1442        assert_eq!(measurements.get("weight").unwrap(), 103);
1443        assert_eq!(measurements.get("height").unwrap(), 164);
1444    }
1445
1446    #[test]
1447    fn test_omit_null_values_row_option_drops_null_keys_end_to_end() {
1448        // Format::Truthy on "maybe" (not a recognised yes/no token, no default) is a
1449        // clean, deliberate way to produce a genuine Value::Null for this test.
1450        let path = write_csv_fixture("omit_null_values.csv", "title,status\nTitle 1,maybe\n");
1451        let cols = vec![Column::from_source_key_with_format("status", None, Format::Truthy, None, DateTimeMode::Full, false)];
1452
1453        // default (off): the null key is still present
1454        let mut opts_default = OptionSet::new(&path);
1455        opts_default.rows.columns = cols.clone();
1456        let rows = process_spreadsheet_direct(&opts_default).unwrap().to_vec();
1457        assert_eq!(rows[0].get("status"), Some(&Value::Null));
1458
1459        // opted in: the null key is dropped entirely
1460        let mut opts_omit = OptionSet::new(&path);
1461        opts_omit.rows.columns = cols;
1462        opts_omit.rows.omit_null_values = true;
1463        let rows = process_spreadsheet_direct(&opts_omit).unwrap().to_vec();
1464        assert!(rows[0].get("status").is_none());
1465        assert_eq!(rows[0].get("title"), Some(&json!("Title 1")));
1466    }
1467
1468    #[test]
1469    fn test_resolve_datetime_mode_prefers_column_format_over_row_defaults() {
1470        // A column's own Format::Date/Format::Time/Format::DateTime overrides the
1471        // row-wide default; Format::Auto (and anything else) falls back to the column's
1472        // own datetime_mode next, then the row-wide default.
1473        assert_eq!(resolve_datetime_mode(&Format::Date, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::DateOnly);
1474        assert_eq!(resolve_datetime_mode(&Format::Time, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::TimeOnly);
1475        assert_eq!(resolve_datetime_mode(&Format::Hm, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::HmOnly);
1476        assert_eq!(resolve_datetime_mode(&Format::DateTimeSimple, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::Simple);
1477        assert_eq!(resolve_datetime_mode(&Format::DateTime, DateTimeMode::DateOnly, DateTimeMode::TimeOnly), DateTimeMode::Full);
1478        assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::Full);
1479        assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::DateOnly, DateTimeMode::Full), DateTimeMode::DateOnly);
1480        assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::Full, DateTimeMode::TimeOnly), DateTimeMode::TimeOnly);
1481        assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::HmOnly, DateTimeMode::DateOnly), DateTimeMode::HmOnly);
1482    }
1483
1484    #[test]
1485    fn test_source_key_override_casts_native_datetime_cell_to_date_only() {
1486        // Regression test: workbook_cell_to_value computed the column's Format override
1487        // but only ever consulted the row-wide --date-only flag for Data::DateTime /
1488        // Data::DateTimeIso cells, so a per-column `Format::Date` override on a real
1489        // (non-string) datetime cell had no effect at all.
1490        let sample_path = "data/sample-data-1.xlsx";
1491        let mut opts = OptionSet::new(sample_path).max_row_count(1);
1492        opts.rows.columns = vec![
1493            Column::from_source_key_with_format("start_time", None, Format::Date, None, DateTimeMode::Full, false),
1494        ];
1495
1496        let result = process_spreadsheet_direct(&opts).unwrap();
1497        let rows = result.to_vec();
1498        let first = rows.first().expect("at least one row");
1499        let start_time = first.get("start_time").expect("start_time column").as_str().unwrap();
1500        assert_eq!(start_time, "2023-06-15");
1501        assert!(!start_time.contains('T'), "should be date-only, got: {}", start_time);
1502    }
1503
1504    #[test]
1505    fn test_time_only_excel_cell_does_not_carry_the_epoch_placeholder_date() {
1506        // Regression: Excel has no true time-only type -- a cell formatted as plain
1507        // "hh:mm" (e.g. a recurring daily start time like "6:30") is really a full
1508        // datetime serial with zero elapsed days, which calamine converts by landing on
1509        // its epoch ("1899-12-31" in the 1900 date system). Formatting the whole thing
1510        // as a datetime carried that meaningless placeholder date through to the output
1511        // ("1899-12-31T06:30:00.000Z"); it should come back as a bare time instead.
1512        use rust_xlsxwriter::{ExcelDateTime, Format as XlsxFormat, Workbook};
1513
1514        let mut workbook = Workbook::new();
1515        let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1516        let time_fmt = XlsxFormat::new().set_num_format("hh:mm");
1517        sheet.write_string(0, 0, "meal").unwrap();
1518        sheet.write_string(0, 1, "start").unwrap();
1519        sheet.write_string(1, 0, "Breakfast").unwrap();
1520        let breakfast_time = ExcelDateTime::from_hms(6, 30, 0).unwrap();
1521        sheet.write_time_with_format(1, 1, breakfast_time, &time_fmt).unwrap();
1522        // a genuine full date+time, for contrast -- must still include the real date
1523        sheet.write_string(2, 0, "Meeting").unwrap();
1524        let meeting_dt = ExcelDateTime::from_ymd(2026, 3, 5).unwrap().and_hms(9, 0, 0).unwrap();
1525        let datetime_fmt = XlsxFormat::new().set_num_format("yyyy-mm-dd hh:mm");
1526        sheet.write_datetime_with_format(2, 1, meeting_dt, &datetime_fmt).unwrap();
1527        let path = std::env::temp_dir().join("time_only_cell.xlsx");
1528        workbook.save(&path).unwrap();
1529
1530        let opts = OptionSet::new(path.to_str().unwrap());
1531        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1532        assert_eq!(rows[0].get("start"), Some(&json!("06:30:00")));
1533        assert_eq!(rows[1].get("start"), Some(&json!("2026-03-05T09:00:00.000Z")));
1534    }
1535
1536    #[test]
1537    fn test_format_time_column_override_forces_time_only_even_for_a_full_datetime() {
1538        // Format::Time is an explicit override, distinct from the automatic
1539        // as_f64() < 1.0 detection above -- it must strip the date component even from
1540        // a cell that carries a genuine, non-epoch date, e.g. a per-column override on
1541        // a "logged_at" timestamp column where only the time-of-day is wanted.
1542        use rust_xlsxwriter::{ExcelDateTime, Format as XlsxFormat, Workbook};
1543
1544        let mut workbook = Workbook::new();
1545        let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1546        sheet.write_string(0, 0, "logged_at").unwrap();
1547        let dt = ExcelDateTime::from_ymd(2026, 3, 5).unwrap().and_hms(9, 15, 30).unwrap();
1548        let datetime_fmt = XlsxFormat::new().set_num_format("yyyy-mm-dd hh:mm:ss");
1549        sheet.write_datetime_with_format(1, 0, dt, &datetime_fmt).unwrap();
1550        let path = std::env::temp_dir().join("format_time_override_cell.xlsx");
1551        workbook.save(&path).unwrap();
1552
1553        let mut opts = OptionSet::new(path.to_str().unwrap());
1554        opts.rows.columns = vec![
1555            Column::from_source_key_with_format("logged_at", None, Format::Time, None, DateTimeMode::Full, false),
1556        ];
1557        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1558        assert_eq!(rows[0].get("logged_at"), Some(&json!("09:15:30")));
1559    }
1560
1561    #[test]
1562    fn test_row_wide_time_only_strips_the_date_from_a_native_iso_datetime_cell() {
1563        // opts.rows.datetime_mode mirrors --date-only but for the opposite end: with no
1564        // per-column override, it should reduce a full ISO datetime cell down to just
1565        // "HH:MM:SS.mmm" -- post-processing fuzzy-datetime's output, not a change to
1566        // the fuzzy-datetime crate itself (out of scope).
1567        let row_opts = RowOptionSet { datetime_mode: DateTimeMode::TimeOnly, ..Default::default() };
1568        let cell = Data::DateTimeIso("2023-06-15T10:17:00.000Z".to_string());
1569        assert_eq!(
1570            workbook_cell_to_value(&cell, &row_opts, 0),
1571            Value::String("10:17:00".to_string())
1572        );
1573    }
1574
1575    #[test]
1576    fn test_row_wide_hm_only_truncates_seconds_from_a_native_datetime_cell() {
1577        // --hm-only is coarser than --time-only: a start/end time or recurring daily
1578        // slot is usually better read as "09:15" than "09:15:30.000".
1579        let row_opts = RowOptionSet { datetime_mode: DateTimeMode::HmOnly, ..Default::default() };
1580        let cell = Data::DateTimeIso("2023-06-15T09:15:30.000Z".to_string());
1581        assert_eq!(
1582            workbook_cell_to_value(&cell, &row_opts, 0),
1583            Value::String("09:15".to_string())
1584        );
1585    }
1586
1587    #[test]
1588    fn test_csv_cell_format_time_extracts_time_of_day_from_a_plain_date_string() {
1589        // csv_cell_to_json_value previously had no Date/DateTime/Time handling at all --
1590        // a date-like string starting with a number (e.g. "2023-06-15T10:17") could be
1591        // misread by the numeric-extraction path before ever reaching fuzzy-datetime.
1592        let cols = vec![Column::new_format(Format::Time, None)];
1593        let row_opts = RowOptionSet::simple(&cols);
1594        assert_eq!(
1595            csv_cell_to_json_value("2023-06-15T10:17:00", &row_opts, 0),
1596            Value::String("10:17:00".to_string())
1597        );
1598    }
1599
1600    #[test]
1601    fn test_csv_cell_format_hm_drops_seconds_too() {
1602        // Format::Hm ("|hm" in --keys) is the CSV/string-cell equivalent of --hm-only,
1603        // e.g. spread-cli --keys "served_from|hm" for a restaurant menu's serving times.
1604        let cols = vec![Column::new_format(Format::Hm, None)];
1605        let row_opts = RowOptionSet::simple(&cols);
1606        assert_eq!(
1607            csv_cell_to_json_value("2023-06-15T09:15:30", &row_opts, 0),
1608            Value::String("09:15".to_string())
1609        );
1610    }
1611
1612    #[test]
1613    fn test_parse_bare_time_string_extracts_and_validates_via_to_numbers() {
1614        // to_numbers::<u8>() extracts the numeric components directly (no regex, no
1615        // manual splitting), so real minutes/seconds range validation -- and zero-padded
1616        // reformatting -- comes essentially for free; a superset check ("starts/ends
1617        // with a digit") would have let a clearly-implausible value like "11:60" through
1618        // as a bogus "valid" time.
1619        assert_eq!(parse_bare_time_string("11:39"), Some("11:39".to_string()));
1620        // reformatted with zero-padding, not passed through as typed
1621        assert_eq!(parse_bare_time_string("9:5:3"), Some("09:05:03".to_string()));
1622        assert_eq!(parse_bare_time_string(" 23:59:59 "), Some("23:59:59".to_string()));
1623        // '.' works exactly the same as ':' -- the dot-typed-time case
1624        assert_eq!(parse_bare_time_string("12.30"), Some("12:30".to_string()));
1625        // hours are deliberately NOT capped at < 24 -- a "time" column is just as often
1626        // a duration (elapsed hours: a race, a video, a timesheet total) as a
1627        // time-of-day, and durations routinely exceed 23 hours
1628        assert_eq!(parse_bare_time_string("27:45:00"), Some("27:45:00".to_string()));
1629        assert_eq!(parse_bare_time_string("100:00"), Some("100:00".to_string()));
1630        // minutes/seconds are always bounded to < 60 regardless -- out of range here is
1631        // correctly rejected, not passed through as a bogus result
1632        assert_eq!(parse_bare_time_string("11:60"), None);
1633        // clearly not a time at all -- no digits, or not exactly 2-3 numeric parts
1634        assert_eq!(parse_bare_time_string("not a time"), None);
1635        assert_eq!(parse_bare_time_string("12"), None);
1636        // a genuine decimal number that also happens to read as a plausible hh:mm is an
1637        // inherent, accepted ambiguity of the dot-as-colon convention (same one behind
1638        // "4.5" as 4h30m in decimal-hours notation vs. 4:50) -- Format::Time/Format::Hm
1639        // is an explicit "I know this is a time" assertion, so this isn't second-guessed
1640        assert_eq!(parse_bare_time_string("3.14"), Some("03:14".to_string()));
1641    }
1642
1643    #[test]
1644    fn test_parse_bare_time_string_converts_a_trailing_am_pm_marker_to_24_hour() {
1645        // A trailing am/pm marker is detected and converted to 24-hour notation, whether
1646        // or not it's separated from the digits by a space, and regardless of case.
1647        assert_eq!(parse_bare_time_string("2:30 PM"), Some("14:30".to_string()));
1648        assert_eq!(parse_bare_time_string("2:30PM"), Some("14:30".to_string()));
1649        assert_eq!(parse_bare_time_string("2:30pm"), Some("14:30".to_string()));
1650        // 12-hour-clock oddities: 12am is midnight, 12pm (noon) is unchanged
1651        assert_eq!(parse_bare_time_string("12:45am"), Some("00:45".to_string()));
1652        assert_eq!(parse_bare_time_string("12:00 PM"), Some("12:00".to_string()));
1653        assert_eq!(parse_bare_time_string("11:39 AM"), Some("11:39".to_string()));
1654        // also applies to the 3-part hh:mm:ss shape
1655        assert_eq!(parse_bare_time_string("11:39:05 PM"), Some("23:39:05".to_string()));
1656        // an hour outside 1-12 alongside an am/pm marker isn't genuine 12-hour notation
1657        // at all -- the 12-hour clock never produces those hours -- so it's trusted as
1658        // already-literal 24-hour and the redundant suffix is dropped, not rejected
1659        assert_eq!(parse_bare_time_string("14:00pm"), Some("14:00".to_string()));
1660        assert_eq!(parse_bare_time_string("14:30pm"), Some("14:30".to_string()));
1661        assert_eq!(parse_bare_time_string("0:30am"), Some("00:30".to_string()));
1662    }
1663
1664    #[test]
1665    fn test_parse_bare_time_string_rejects_anything_else_with_letters_rather_than_guess() {
1666        // Regression: to_numbers::<u8>() silently discards non-digit characters, so
1667        // without an explicit guard "2h45m" would extract as [2, 45] and format as
1668        // "02:45" -- not a rejection, a *wrong* answer indistinguishable from a correct
1669        // one. Duration-unit notation is a different, out-of-scope notation -- rejected,
1670        // not silently (and wrongly) reinterpreted as hh:mm.
1671        assert_eq!(parse_bare_time_string("2h45m"), None);
1672        assert_eq!(parse_bare_time_string("not a time"), None);
1673    }
1674
1675    #[test]
1676    fn test_format_time_falls_back_to_a_bare_time_string_with_no_date_component() {
1677        // Regression: a cell already containing just a time, e.g. "11:39" (no date at
1678        // all, common for a spreadsheet column pre-formatted as text), returned null
1679        // under Format::Time/Format::Hm -- iso_fuzzy_to_datetime_string requires a date
1680        // component to parse anything at all, so it always failed on a bare time, and
1681        // the whole thing fell through to the column's default (null, absent one).
1682        // Format::Time/Hm should treat an already-bare time string as already correct,
1683        // not demand a date it was never asked to interpret -- passed through as-is,
1684        // matching parse_bare_time_string's own loose, non-reformatting behavior.
1685        let time_cols = vec![Column::new_format(Format::Time, None)];
1686        let time_opts = RowOptionSet::simple(&time_cols);
1687        assert_eq!(csv_cell_to_json_value("11:39", &time_opts, 0), Value::String("11:39".to_string()));
1688
1689        let hm_cols = vec![Column::new_format(Format::Hm, None)];
1690        let hm_opts = RowOptionSet::simple(&hm_cols);
1691        assert_eq!(csv_cell_to_json_value("11:39", &hm_opts, 0), Value::String("11:39".to_string()));
1692
1693        // a genuine full datetime string still goes through the original extraction path,
1694        // not the bare-time fallback -- unaffected by this change
1695        assert_eq!(
1696            csv_cell_to_json_value("2023-06-15T09:15:30", &time_opts, 0),
1697            Value::String("09:15:30".to_string())
1698        );
1699
1700        // the xlsx/ods string-cell path (process_string_value) shares the same fix
1701        assert_eq!(process_string_value("11:39", Format::Time, None), Value::String("11:39".to_string()));
1702        assert_eq!(process_string_value("11:39", Format::Hm, None), Value::String("11:39".to_string()));
1703    }
1704
1705    #[test]
1706    fn test_decimal_to_hm_string_reads_the_fraction_as_literal_minutes_not_a_scaled_fraction() {
1707        // "12.30" typed by a user (meaning 12:30) commonly arrives here as the bare float
1708        // 12.3 -- Excel/Sheets have no time type for a dot-typed value, so it's silently
1709        // read as a decimal number, trailing zero dropped. The fractional digits must be
1710        // read literally as MM (0.3 -> "30", i.e. the trailing zero restored), never
1711        // scaled by 60 (0.3 of an hour would be 18 minutes, not 30).
1712        assert_eq!(decimal_to_hm_string(12.3), Some("12:30".to_string()));
1713        assert_eq!(decimal_to_hm_string(9.05), Some("09:05".to_string()));
1714        assert_eq!(decimal_to_hm_string(0.0), Some("00:00".to_string()));
1715        assert_eq!(decimal_to_hm_string(23.59), Some("23:59".to_string()));
1716        // hours are NOT capped at < 24 -- a "time" column is just as often a duration
1717        // (elapsed hours) as a time-of-day, and durations routinely exceed 23 hours;
1718        // there's no way to tell which from the value alone, so this doesn't guess
1719        assert_eq!(decimal_to_hm_string(24.0), Some("24:00".to_string()));
1720        assert_eq!(decimal_to_hm_string(100.3), Some("100:30".to_string()));
1721        // a genuine decimal quantity (price, percentage, ...) must still be left alone --
1722        // >= 60 "minutes" isn't a plausible time under any interpretation, and a negative
1723        // value is neither a time-of-day nor a duration
1724        assert_eq!(decimal_to_hm_string(12.75), None);
1725        assert_eq!(decimal_to_hm_string(-1.0), None);
1726    }
1727
1728    #[test]
1729    fn test_format_time_reconstructs_hm_from_a_decimal_disguised_time() {
1730        // The CSV/text-cell path: "12.30" survives as literal text only when a cell is
1731        // explicitly formatted/typed as text (bypassing Excel's auto-decimal-conversion);
1732        // otherwise it's already a float by the time it reaches spreadsheet_to_json at
1733        // all -- covered by the native Data::Float case below.
1734        //
1735        // Strings don't have the float case's trailing-zero-loss problem (a string
1736        // preserves exactly what was typed), so this just swaps '.' for ':' and reuses
1737        // parse_bare_time_string's own to_numbers-based extraction and validation --
1738        // not decimal_to_hm_string's reconstruction, which the native-float case still
1739        // needs. Single-digit components are zero-padded on the way out ("12.3" ->
1740        // "12:03"), same as parse_bare_time_string does for a literally-typed "12:3".
1741        let time_cols = vec![Column::new_format(Format::Time, None)];
1742        let time_opts = RowOptionSet::simple(&time_cols);
1743        assert_eq!(csv_cell_to_json_value("12.30", &time_opts, 0), Value::String("12:30".to_string()));
1744        assert_eq!(csv_cell_to_json_value("12.3", &time_opts, 0), Value::String("12:03".to_string()));
1745
1746        let hm_cols = vec![Column::new_format(Format::Hm, None)];
1747        let hm_opts = RowOptionSet::simple(&hm_cols);
1748        assert_eq!(csv_cell_to_json_value("12.30", &hm_opts, 0), Value::String("12:30".to_string()));
1749
1750        // the xlsx/ods text-cell path shares the same fallback
1751        assert_eq!(process_string_value("12.30", Format::Time, None), Value::String("12:30".to_string()));
1752
1753        // range validation now applies to the string path too (parse_bare_time_string's
1754        // own to_numbers-based check), so a genuinely implausible "time" is correctly
1755        // rejected rather than passed through as a bogus result
1756        assert_eq!(csv_cell_to_json_value("12.75", &time_opts, 0), Value::Null);
1757
1758        // the native Data::Float case -- the far more common real-world path, since
1759        // Excel/Sheets normally convert a dot-typed time entry to a float outright
1760        // rather than leaving it as text. Unlike the string case, the trailing zero is
1761        // already gone by the time this runs (12.30 and 12.3 are the same f64), so this
1762        // one *does* need decimal_to_hm_string's separate numeric reconstruction.
1763        assert_eq!(process_float_value(12.3, Format::Time, None), Value::String("12:30".to_string()));
1764        assert_eq!(process_float_value(12.3, Format::Hm, None), Value::String("12:30".to_string()));
1765
1766        // a genuine price/decimal column with Format::Time forced on it (user error, or
1767        // just not a time column) correctly yields null rather than a bogus time
1768        assert_eq!(process_float_value(12.75, Format::Time, None), Value::Null);
1769    }
1770
1771    #[test]
1772    fn test_format_date_recognizes_slash_separated_dates_of_any_order() {
1773        // Regression: Format::Date/DateTime/... on a text cell or CSV value used
1774        // iso_fuzzy_to_date_string/iso_fuzzy_to_datetime_string, which force
1775        // DateOrder::YMD *and* '-' as the only accepted separator (DateOptions::default())
1776        // -- passing an explicit DateOptions skips fuzzy-datetime's own order/separator
1777        // guessing entirely. Any '/'-separated date -- by far the most common separator
1778        // worldwide, in any of DMY/MDY/YMD order -- returned null outright, even the
1779        // unambiguous "2026/07/19" (right order, just the "wrong" separator). Now uses
1780        // fuzzy-datetime's guessing entry points instead, output shape unchanged.
1781        let cols = vec![Column::new_format(Format::Date, None)];
1782        let row_opts = RowOptionSet::simple(&cols);
1783        for (value, expected) in [
1784            ("19/07/2026", "2026-07-19"), // DMY (day 19 rules out MDY)
1785            ("07/19/2026", "2026-07-19"), // MDY (day 19 rules out DMY)
1786            ("2026/07/19", "2026-07-19"), // YMD, just not '-'
1787            ("2026-07-19", "2026-07-19"), // the already-working case, unaffected
1788        ] {
1789            assert_eq!(
1790                csv_cell_to_json_value(value, &row_opts, 0),
1791                Value::String(expected.to_string()),
1792                "{:?} should resolve to {:?}",
1793                value,
1794                expected
1795            );
1796        }
1797    }
1798
1799    #[test]
1800    fn test_format_datetime_recognizes_slash_separated_dates_too() {
1801        let cols = vec![Column::new_format(Format::DateTime, None)];
1802        let row_opts = RowOptionSet::simple(&cols);
1803        assert_eq!(
1804            csv_cell_to_json_value("19/07/2026", &row_opts, 0),
1805            Value::String("2026-07-19T00:00:00.000Z".to_string())
1806        );
1807        // the xlsx/ods string-cell path shares the same fix
1808        assert_eq!(
1809            process_string_value("19/07/2026", Format::Date, None),
1810            Value::String("2026-07-19".to_string())
1811        );
1812    }
1813
1814    #[test]
1815    fn test_format_date_recognizes_dot_separated_dates_too() {
1816        // Depends on the fuzzy-datetime 0.1.4 fix for segment_is_subseconds
1817        // misreading a bare 4-digit year as milliseconds-plus-timezone-suffix.
1818        let cols = vec![Column::new_format(Format::Date, None)];
1819        let row_opts = RowOptionSet::simple(&cols);
1820        assert_eq!(
1821            csv_cell_to_json_value("19.07.2026", &row_opts, 0),
1822            Value::String("2026-07-19".to_string())
1823        );
1824    }
1825
1826    #[test]
1827    fn test_simplify_datetime_string_drops_milliseconds_and_trailing_z() {
1828        assert_eq!(simplify_datetime_string("2026-07-18T18:07:34.000Z"), "2026-07-18T18:07:34");
1829        // also tolerates a string with no fractional seconds or Z at all
1830        assert_eq!(simplify_datetime_string("2026-07-18T18:07:34"), "2026-07-18T18:07:34");
1831    }
1832
1833    #[test]
1834    fn test_row_wide_simple_mode_strips_milliseconds_and_z_from_a_native_iso_datetime_cell() {
1835        let row_opts = RowOptionSet { datetime_mode: DateTimeMode::Simple, ..Default::default() };
1836        let cell = Data::DateTimeIso("2026-07-18T18:07:34.000Z".to_string());
1837        assert_eq!(
1838            workbook_cell_to_value(&cell, &row_opts, 0),
1839            Value::String("2026-07-18T18:07:34".to_string())
1840        );
1841    }
1842
1843    #[test]
1844    fn test_csv_cell_format_datetime_simple_drops_milliseconds_and_z() {
1845        // Format::DateTimeSimple ("|simple" or "|ds" in --keys) is the CSV/string-cell
1846        // equivalent of --simple: the full datetime, minus the JS-interop-oriented
1847        // milliseconds/trailing-Z formatting used by the default Full mode.
1848        let cols = vec![Column::new_format(Format::DateTimeSimple, None)];
1849        let row_opts = RowOptionSet::simple(&cols);
1850        assert_eq!(
1851            csv_cell_to_json_value("2026-07-18T18:07:34", &row_opts, 0),
1852            Value::String("2026-07-18T18:07:34".to_string())
1853        );
1854    }
1855
1856    #[test]
1857    fn test_simple_mode_still_avoids_the_epoch_placeholder_date_for_a_genuine_time_only_excel_cell() {
1858        // Simple is still "the whole datetime", just reformatted -- so it must keep the
1859        // same auto time-only detection as Full mode (see
1860        // test_time_only_excel_cell_does_not_carry_the_epoch_placeholder_date), just
1861        // without milliseconds this time.
1862        use rust_xlsxwriter::{ExcelDateTime, Format as XlsxFormat, Workbook};
1863
1864        let mut workbook = Workbook::new();
1865        let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1866        let time_fmt = XlsxFormat::new().set_num_format("hh:mm");
1867        sheet.write_string(0, 0, "start").unwrap();
1868        let breakfast_time = ExcelDateTime::from_hms(6, 30, 0).unwrap();
1869        sheet.write_time_with_format(1, 0, breakfast_time, &time_fmt).unwrap();
1870        let path = std::env::temp_dir().join("simple_mode_time_only_cell.xlsx");
1871        workbook.save(&path).unwrap();
1872
1873        let mut opts = OptionSet::new(path.to_str().unwrap());
1874        opts.rows.datetime_mode = DateTimeMode::Simple;
1875        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1876        assert_eq!(rows[0].get("start"), Some(&json!("06:30:00")));
1877    }
1878
1879    #[test]
1880    fn test_column_datetime_mode_applies_only_to_genuine_datetime_cells_under_format_auto() {
1881        // Column::datetime_mode (distinct from Format::Date/Time/Hm/DateTime) is scoped
1882        // to columns left at Format::Auto -- it only ever touches a cell that's already
1883        // a genuine datetime (Data::DateTime/Data::DateTimeIso), leaving strings and
1884        // numbers in the same column completely untouched, unlike an explicit Format
1885        // override which would force-interpret every cell type.
1886        let cols = vec![Column::from_key_ref_with_format(None, Format::Auto, None, DateTimeMode::HmOnly, false)];
1887        let row_opts = RowOptionSet::simple(&cols);
1888        let datetime_cell = Data::DateTimeIso("2023-06-15T09:15:30.000Z".to_string());
1889        assert_eq!(
1890            workbook_cell_to_value(&datetime_cell, &row_opts, 0),
1891            Value::String("09:15".to_string())
1892        );
1893        let string_cell = Data::String("not a date".to_string());
1894        assert_eq!(
1895            workbook_cell_to_value(&string_cell, &row_opts, 0),
1896            Value::String("not a date".to_string())
1897        );
1898    }
1899
1900    #[test]
1901    fn test_native_float_cell_decimal_format_rounds_to_the_given_precision() {
1902        // Regression: Format::Decimal(places) had no arm at all in process_float_value --
1903        // it fell through to the plain Value::Number catch-all, so e.g. --keys "price|d2"
1904        // on a genuine (non-string) xlsx/ods float cell had no effect whatsoever.
1905        let cols = vec![Column::new_format(Format::Decimal(2), None)];
1906        let row_opts = RowOptionSet::simple(&cols);
1907        let cell = Data::Float(19.98765);
1908        assert_eq!(workbook_cell_to_value(&cell, &row_opts, 0), json!(19.99));
1909    }
1910
1911    #[test]
1912    fn test_csv_cell_integer_format_truncates_decimal_values() {
1913        // Regression test: Number::as_i128() only succeeds for already-integer-valued
1914        // Numbers, so casting a decimal CSV cell like "58.2" to Format::Integer used to
1915        // silently produce 0 via unwrap_or(0) instead of the truncated value.
1916        let cols = vec![Column::new_format(Format::Integer, None)];
1917        let row_opts = RowOptionSet::simple(&cols);
1918        assert_eq!(csv_cell_to_json_value("58.2", &row_opts, 0), Value::Number(Number::from(58)));
1919        assert_eq!(csv_cell_to_json_value("82.5", &row_opts, 0), Value::Number(Number::from(82)));
1920        assert_eq!(csv_cell_to_json_value("100", &row_opts, 0), Value::Number(Number::from(100)));
1921    }
1922
1923    #[test]
1924    fn test_csv_cell_decimal_format_rounds_to_the_given_precision() {
1925        // Regression: Format::Decimal(places) had no arm at all in csv_cell_to_json_value's
1926        // numeric match -- it fell through to the plain Value::Number catch-all, so
1927        // e.g. --keys "price|d2" on a CSV cell had no effect whatsoever.
1928        let cols = vec![Column::new_format(Format::Decimal(2), None)];
1929        let row_opts = RowOptionSet::simple(&cols);
1930        assert_eq!(csv_cell_to_json_value("19.98765", &row_opts, 0), json!(19.99));
1931        assert_eq!(csv_cell_to_json_value("5.4", &row_opts, 0), json!(5.4));
1932        assert_eq!(csv_cell_to_json_value("100", &row_opts, 0), json!(100.0));
1933    }
1934
1935    #[test]
1936    fn test_csv_cell_float_format_strips_a_currency_symbol_but_keeps_the_decimal() {
1937        // Regression: to_first_number() already looks past a leading "£"/"$" to detect
1938        // that a cell has a number in it, but the actual value-cleaning step only ever
1939        // stripped commas -- the symbol itself made is_numeric() reject the cleaned
1940        // string outright, so a currency-formatted cell fell through to plain string
1941        // passthrough instead of being cast at all.
1942        let cols = vec![Column::new_format(Format::Float, None)];
1943        let row_opts = RowOptionSet::simple(&cols);
1944        assert_eq!(csv_cell_to_json_value("£1,999.99", &row_opts, 0), json!(1999.99));
1945        assert_eq!(csv_cell_to_json_value("$45.50", &row_opts, 0), json!(45.50));
1946        assert_eq!(csv_cell_to_json_value("1999.99", &row_opts, 0), json!(1999.99));
1947        assert_eq!(csv_cell_to_json_value("abc", &row_opts, 0), Value::String("abc".to_string()));
1948    }
1949
1950    #[test]
1951    fn test_format_array_splits_and_formats_each_element_via_to_segments() {
1952        let cols = vec![Column::new_format(Format::Array(Arc::new(Format::Float), Arc::from(",")), None)];
1953        let row_opts = RowOptionSet::simple(&cols);
1954        assert_eq!(csv_cell_to_json_value("34.8,78.3", &row_opts, 0), json!([34.8, 78.3]));
1955        // whitespace around a piece is trimmed before formatting
1956        assert_eq!(csv_cell_to_json_value(" 34.8 , 78.3 ", &row_opts, 0), json!([34.8, 78.3]));
1957    }
1958
1959    #[test]
1960    fn test_format_array_is_not_misread_as_a_bare_number_by_numeric_sniffing() {
1961        // Regression guard: to_first_number("34.8,78.3") matches "34.8" alone, so
1962        // Format::Array must be checked before csv_cell_to_json_value's generic
1963        // numeric-looks-like branch, or "78.3" would silently vanish.
1964        let cols = vec![Column::new_format(Format::Array(Arc::new(Format::Float), Arc::from(",")), None)];
1965        let row_opts = RowOptionSet::simple(&cols);
1966        let result = csv_cell_to_json_value("34.8,78.3", &row_opts, 0);
1967        assert_eq!(result.as_array().map(|a| a.len()), Some(2));
1968    }
1969
1970    #[test]
1971    fn test_format_array_with_text_elements_matches_the_original_tags_use_case() {
1972        let cols = vec![Column::new_format(Format::Array(Arc::new(Format::Text), Arc::from(",")), None)];
1973        let row_opts = RowOptionSet::simple(&cols);
1974        assert_eq!(csv_cell_to_json_value("lions,Kenya", &row_opts, 0), json!(["lions", "Kenya"]));
1975    }
1976
1977    #[test]
1978    fn test_format_from_str_array_syntax_round_trips_through_actual_cell_processing() {
1979        // End-to-end: the parsed Format::Array (from spread-cli's future --keys shorthand,
1980        // e.g. "int[](|)") actually drives real splitting into real integers, not just
1981        // the right enum shape -- this depends on the process_string_value Integer fix
1982        // above; without it this would produce ["34","78","9"] (strings) instead.
1983        let cols = vec![Column::new_format(Format::from_str("int[](|)").unwrap(), None)];
1984        let row_opts = RowOptionSet::simple(&cols);
1985        assert_eq!(csv_cell_to_json_value("34|78|9", &row_opts, 0), json!([34, 78, 9]));
1986
1987        let text_cols = vec![Column::new_format(Format::from_str("string[](|)").unwrap(), None)];
1988        let text_row_opts = RowOptionSet::simple(&text_cols);
1989        assert_eq!(
1990            csv_cell_to_json_value("lions|Kenya", &text_row_opts, 0),
1991            json!(["lions", "Kenya"])
1992        );
1993    }
1994
1995    #[test]
1996    fn test_format_array_element_that_fails_to_parse_becomes_null_in_place() {
1997        // A single bad element becomes null at its own position rather than discarding
1998        // the whole array or substituting some unrelated default into the middle of it.
1999        let cols = vec![Column::new_format(Format::Array(Arc::new(Format::Float), Arc::from(",")), None)];
2000        let row_opts = RowOptionSet::simple(&cols);
2001        assert_eq!(csv_cell_to_json_value("34.8,not-a-number", &row_opts, 0), json!([34.8, null]));
2002    }
2003
2004    #[test]
2005    fn test_format_array_on_a_native_float_cell_has_nothing_to_split_so_stays_a_plain_number() {
2006        // A genuine numeric xlsx/ods cell was never a delimited string to begin with --
2007        // Format::Array falls through to the ordinary Value::Number catch-all rather than
2008        // wrapping a single float in a one-element array.
2009        let cols = vec![Column::new_format(Format::Array(Arc::new(Format::Float), Arc::from(",")), None)];
2010        let row_opts = RowOptionSet::simple(&cols);
2011        let cell = Data::Float(34.8);
2012        assert_eq!(workbook_cell_to_value(&cell, &row_opts, 0), json!(34.8));
2013    }
2014
2015    #[test]
2016    fn test_csv_cell_does_not_coerce_ids_to_booleans() {
2017        // Regression test: these previously became `true`/`false` because their
2018        // embedded digit run (e.g. "SKU001" -> "001" -> 1) was fuzzily extracted
2019        // and matched against is_truthy_core's numeric range, even though the
2020        // column has no boolean intent (Format::Auto, the default).
2021        let row_opts = RowOptionSet::default();
2022        assert_eq!(csv_cell_to_json_value("SKU001", &row_opts, 0), Value::String("SKU001".to_string()));
2023        assert_eq!(csv_cell_to_json_value("A1", &row_opts, 0), Value::String("A1".to_string()));
2024        assert_eq!(csv_cell_to_json_value("01/06/2024", &row_opts, 0), Value::String("01/06/2024".to_string()));
2025        // literal boolean tokens should still be recognised
2026        assert_eq!(csv_cell_to_json_value("true", &row_opts, 0), Value::Bool(true));
2027        assert_eq!(csv_cell_to_json_value("false", &row_opts, 0), Value::Bool(false));
2028    }
2029
2030    #[test]
2031    fn test_direct_processing_csv() {
2032        let sample_path = "data/sample-data-1.csv";
2033
2034        // instantiate the OptionSet with a sample path and a maximum row count of 1000 rows as the source file has 401 rows
2035        // (although )the default max is 10,000)
2036        let opts = OptionSet::new(sample_path).max_row_count(1_000);
2037
2038        let result = process_spreadsheet_direct(&opts);
2039
2040        // The source file should have 1 header row and 400 data rows
2041        assert_eq!(result.unwrap().num_rows, 401);
2042    }
2043
2044    /// Writes raw CSV text to a temp file for testing header_row/data_row_index/
2045    /// omit_header against CSV specifically (calamine fixtures need a real xlsx writer,
2046    /// but CSV is plain text -- no generator needed).
2047    fn write_csv_fixture(filename: &str, content: &str) -> String {
2048        let path = std::env::temp_dir().join(filename);
2049        std::fs::write(&path, content).unwrap();
2050        path.to_string_lossy().to_string()
2051    }
2052
2053    #[test]
2054    fn test_csv_header_row_and_data_row_index_skip_a_gap() {
2055        // Row 0 title, row 1 notes, row 2 header, row 3 blank, rows 4-5 data --
2056        // the same shape as the xlsx gap fixture, but for CSV.
2057        let path = write_csv_fixture(
2058            "csv_header_gap.csv",
2059            "Report Title\nGenerated 2026-01-01\nsku,qty\n,\nSKU001,10\nSKU002,20\n",
2060        );
2061
2062        // baseline: header_row alone (no data_row_index) captures the blank gap row --
2063        // CSV has no native null, so an empty field comes through as "" rather than null
2064        let opts = OptionSet::new(&path).header_row(2);
2065        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
2066        assert_eq!(rows.len(), 3, "gap row, SKU001, SKU002");
2067        assert_eq!(rows[0].get("sku"), Some(&json!("")));
2068
2069        // data_row_index skips the gap row entirely
2070        let opts = OptionSet::new(&path).header_row(2).data_row_index(4);
2071        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
2072        assert_eq!(rows.len(), 2, "just SKU001 and SKU002");
2073        assert_eq!(rows[0].get("sku"), Some(&json!("SKU001")));
2074        assert_eq!(rows[1].get("sku"), Some(&json!("SKU002")));
2075    }
2076
2077    #[test]
2078    fn test_csv_header_row_span_combines_a_merged_year_row_with_a_region_row() {
2079        // Same shape as the user's original example, as plain CSV -- the merge-shadow
2080        // cells are just empty fields, no different from any other blank CSV field.
2081        let path = write_csv_fixture(
2082            "csv_multi_row_header.csv",
2083            "2015,,,2025,,\nNorth,Midlands,South,North,Midlands,South\n100,101,102,200,201,202\n",
2084        );
2085        let opts = OptionSet::new(&path).header_row(0).header_row_span(2);
2086        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
2087        assert_eq!(rows.len(), 1, "both header rows must be consumed, not leak through as data");
2088        let row = &rows[0];
2089        // plain integer-looking CSV text ("100", no decimal point) auto-types as an
2090        // integer, unlike the xlsx fixture's genuine Data::Float cells
2091        assert_eq!(row.get("2015_north"), Some(&json!(100)));
2092        assert_eq!(row.get("2015_midlands"), Some(&json!(101)));
2093        assert_eq!(row.get("2015_south"), Some(&json!(102)));
2094        assert_eq!(row.get("2025_north"), Some(&json!(200)));
2095        assert_eq!(row.get("2025_midlands"), Some(&json!(201)));
2096        assert_eq!(row.get("2025_south"), Some(&json!(202)));
2097    }
2098
2099    #[test]
2100    fn test_csv_omit_header_uses_fallback_keys_not_empty_rows() {
2101        // Regression: --omit-header on a CSV used to leave `headers` completely empty
2102        // (no A1/C01 fallback was ever built), so every row came out as `{}` -- and
2103        // separately, the `csv` crate's has_headers(true) default silently ate row 0
2104        // regardless of omit_header, discarding real data.
2105        let path = write_csv_fixture("csv_omit_header.csv", "SKU001,10\nSKU002,20\n");
2106        let opts = OptionSet::new(&path).omit_header();
2107        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
2108        assert_eq!(rows.len(), 2, "both rows present, including the former row 0");
2109        assert_eq!(rows[0].get("a"), Some(&json!("SKU001")));
2110        assert_eq!(rows[0].get("b"), Some(&json!(10)));
2111        assert_eq!(rows[1].get("a"), Some(&json!("SKU002")));
2112    }
2113
2114    #[test]
2115    fn test_csv_header_row_equals_data_row_index_for_predefined_headers() {
2116        // header_row == data_row_index: a CSV with predefined/external headers (here,
2117        // via --keys) where no line is actually consumed as a header -- e.g. after
2118        // skipping 2 notes rows, row 2 is immediately real data, not a header line.
2119        let path = write_csv_fixture(
2120            "csv_predefined_headers.csv",
2121            "Report Title\nGenerated 2026-01-01\nSKU001,10\nSKU002,20\n",
2122        );
2123        let mut opts = OptionSet::new(&path).header_row(2).data_row_index(2).omit_header();
2124        opts.rows.columns = vec![
2125            Column::new(Some("sku")),
2126            Column::new(Some("qty")),
2127        ];
2128        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
2129        assert_eq!(rows.len(), 2, "both data rows present, notes rows skipped");
2130        assert_eq!(rows[0].get("sku"), Some(&json!("SKU001")));
2131        assert_eq!(rows[0].get("qty"), Some(&json!(10)));
2132        assert_eq!(rows[1].get("sku"), Some(&json!("SKU002")));
2133    }
2134
2135    #[test]
2136    fn test_multisheet_preview_ods() {
2137        let sample_path = "data/sample-data-2.ods";
2138
2139        // instantiate the OptionSet with a sample path
2140        // a maximum row count returned of 10 rows
2141        // and read mode to *preview* to scan all sheets
2142        // It should correctly calculate
2143        let opts = OptionSet::new(sample_path)
2144            .max_row_count(10)
2145            .read_mode_preview();
2146
2147        let result = process_spreadsheet_direct(&opts);
2148
2149        // The source spreadsheet should have 2 sheets
2150        let dataset = result.unwrap();
2151        assert_eq!(dataset.sheets.len(), 2);
2152        // The source spreadsheet should have 101 + 17 (= 118) populated rows including headers
2153        assert_eq!(dataset.num_rows, 118);
2154
2155        // The first sheet's data should only output 10 rows (including the header)
2156        assert_eq!(dataset.data.first_sheet().len(), 10);
2157    }
2158
2159    #[test]
2160    fn test_column_override_1() {
2161        let sample_json = json!({
2162          "sku": "CHAIR16",
2163          "height": "112cm",
2164          "width": "69cm",
2165          "approved": "Y"
2166        });
2167
2168        let rows = json_object_to_calamine_data(sample_json);
2169
2170        let cols = vec![
2171            Column::new_format(Format::Text, Some(string_value(""))),
2172            Column::new_format(Format::Float, Some(float_value(95.0))),
2173            Column::new_format(Format::Float, Some(float_value(65.0))),
2174            Column::new_format(Format::Truthy, Some(bool_value(false))),
2175        ];
2176
2177        // The first sheet's data should only output 10 rows (including the header)
2178        let opts = &RowOptionSet::simple(&cols);
2179        let result = workbook_row_to_values(&rows, opts);
2180        // the second column be cast to 112.0
2181        assert_eq!(result.get(1).unwrap(), 112.0);
2182        // the third column be cast to 69.0
2183        assert_eq!(result.get(2).unwrap(), 69.0);
2184        // the fourth column be cast to boolean
2185        assert_eq!(result.get(3).unwrap(), true);
2186    }
2187
2188    #[test]
2189    fn test_column_override_2() {
2190        let sample_json = json!({
2191          "name": "Sophia",
2192          "dob": "2001-9-23",
2193          "weight": "62kg",
2194          "result": "GOOD"
2195        });
2196
2197        let rows = json_object_to_calamine_data(sample_json);
2198
2199        let cols = vec![
2200            Column::new_format(Format::Text, None),
2201            Column::new_format(Format::Date, None),
2202            Column::new_format(Format::Float, None),
2203            // the fourth column be cast to boolean
2204            Column::new_format(
2205                Format::truthy_custom("good", "bad"),
2206                Some(bool_value(false)),
2207            ),
2208        ];
2209
2210        // The first sheet's data should only output 10 rows (including the header)
2211        let opts = &RowOptionSet::simple(&cols);
2212        let result = workbook_row_to_values(&rows, opts);
2213        assert_eq!(result.get(1).unwrap(), "2001-09-23");
2214        assert_eq!(result.get(2).unwrap(), 62.0);
2215        assert_eq!(result.get(3).unwrap(), true);
2216    }
2217
2218    #[tokio::test]
2219    async fn test_read_workbook_info() {
2220        let sample_path = "data/sample-data-1.xlsx";
2221        let path_data = PathData::new(path::Path::new(sample_path));
2222        let info = read_workbook_sheet_info(&path_data).await;
2223        assert!(info.is_ok());
2224    }
2225
2226    #[tokio::test]
2227    async fn test_large_csv_file() {
2228        let sample_path = "data/large-datasheet.csv";
2229        let max_rows = 100_000;
2230        let opts = OptionSet::new(sample_path).max_row_count(max_rows);
2231        let result = process_spreadsheet_core(&opts, None, None).await;
2232        if let Ok(data) = result.clone() {
2233            assert_eq!(data.data.first_sheet().len(), max_rows as usize);
2234        } else {
2235            panic!("Failed to process large CSV file");
2236        }
2237        assert!(result.is_ok());
2238    }
2239
2240    #[tokio::test]
2241    async fn test_medium_excel_file() {
2242        let sample_path = "data/medium-spreadsheet-50_000.xlsx";
2243        let max_rows = 5_000;
2244        let opts = OptionSet::new(sample_path).max_row_count(max_rows);
2245        let result = process_spreadsheet_core(&opts, None, None).await;
2246        if let Ok(data) = result.clone() {
2247            assert_eq!(data.data.first_sheet().len(), max_rows as usize);
2248        } else {
2249            panic!("Failed to process large Excel file");
2250        }
2251        assert!(result.is_ok());
2252    }
2253}