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