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 crate::headers::*;
16use crate::helpers::float_value;
17use crate::helpers::string_value;
18use is_truthy::*;
19use crate::round_decimal::RoundDecimal;
20use crate::DateTimeMode;
21use crate::Extension;
22use crate::Format;
23use crate::OptionSet;
24use crate::PathData;
25use crate::RowOptionSet;
26use fuzzy_datetime::{iso_fuzzy_to_date_string, iso_fuzzy_to_datetime_string};
27
28/// Callback invoked once per row when saving asynchronously (e.g. --deferred mode)
29pub type SaveRowFn = Box<dyn Fn(IndexMap<String, Value>) -> Result<(), GenericError> + Send + Sync>;
30
31/// Output the result set with captured rows (up to the maximum allowed) directly.
32/// This is now synchronous and calls the asynchronous function using a runtime.
33pub fn process_spreadsheet_direct(opts: &OptionSet) -> Result<ResultSet, GenericError> {
34    let rt = tokio::runtime::Runtime::new().unwrap();
35    rt.block_on(process_spreadsheet_core(opts, None, None))
36}
37
38/// Output the result set with captured rows (up to the maximum allowed) immediately.
39/// Use this in an async function using the tokio runtime if you direct results
40/// without a save callback
41pub async fn process_spreadsheet_immediate(opts: &OptionSet) -> Result<ResultSet, GenericError> {
42    process_spreadsheet_core(opts, None, None).await
43}
44
45#[deprecated(
46    since = "1.0.6",
47    note = "This function is a wrapper for the renamed function `process_spreadsheet_inline`"
48)]
49pub async fn render_spreadsheet_direct(opts: &OptionSet) -> Result<ResultSet, GenericError> {
50    process_spreadsheet_core(opts, None, None).await
51}
52
53/// Output the result set with deferred row saving and optional output reference
54pub async fn process_spreadsheet_async(
55    opts: &OptionSet,
56    save_func: SaveRowFn,
57    out_ref: Option<&str>,
58) -> Result<ResultSet, GenericError> {
59    process_spreadsheet_core(opts, Some(save_func), out_ref).await
60}
61
62/// Output the result set with captured rows (up to the maximum allowed) directly.
63/// with optional asynchronous row save method and output reference
64pub async fn process_spreadsheet_core(
65    opts: &OptionSet,
66    save_opt: Option<SaveRowFn>,
67    out_ref: Option<&str>,
68) -> Result<ResultSet, GenericError> {
69    if let Some(filepath) = opts.path.clone() {
70        let path = Path::new(&filepath);
71        if !path.exists() {
72            #[allow(dead_code)]
73            return Err(GenericError("file_unavailable"));
74        }
75        let path_data = PathData::new(path);
76        if path_data.is_valid() {
77            if path_data.use_calamine() {
78                read_workbook_core(&path_data, opts, save_opt, out_ref).await
79            } else {
80                read_csv_core(&path_data, opts, save_opt, out_ref).await
81            }
82        } else {
83            Err(GenericError("unsupported_format"))
84        }
85    } else {
86        Err(GenericError("no_filepath_specified"))
87    }
88}
89
90#[deprecated(
91    since = "1.0.6",
92    note = "This function is a wrapper for the renamed function `process_spreadsheet_core`"
93)]
94pub async fn render_spreadsheet_core(
95    opts: &OptionSet,
96    save_opt: Option<SaveRowFn>,
97    out_ref: Option<&str>,
98) -> Result<ResultSet, GenericError> {
99    process_spreadsheet_core(opts, save_opt, out_ref).await
100}
101
102/// Parse spreadsheets with an optional callback method to save rows asynchronously and an optional output reference
103/// that may be a file name or database identifier
104pub async fn read_workbook_core<'a>(
105    path_data: &PathData<'a>,
106    opts: &OptionSet,
107    save_opt: Option<SaveRowFn>,
108    out_ref: Option<&str>,
109) -> Result<ResultSet, GenericError> {
110    if let Ok(mut workbook) = open_workbook_auto(path_data.path()) {
111        let max_rows = opts.max_rows();
112        let (selected_names, sheet_names, _sheet_indices) =
113            match_sheet_name_and_index(&mut workbook, opts);
114
115        if !selected_names.is_empty() {
116            let info = WorkbookInfo::new(path_data, &selected_names, &sheet_names);
117
118            if opts.multimode() {
119                read_multiple_worksheets(&mut workbook, &sheet_names, opts, &info, max_rows).await
120            } else {
121                let sheet_ref = &selected_names[0];
122                read_single_worksheet(workbook, sheet_ref, opts, &info, save_opt, out_ref).await
123            }
124        } else {
125            Err(GenericError("workbook_with_no_sheets"))
126        }
127    } else {
128        Err(GenericError("cannot_open_workbook"))
129    }
130}
131
132/// Read multiple worksheets from a workbook in preview mode.
133async fn read_multiple_worksheets(
134    workbook: &mut Sheets<BufReader<File>>,
135    sheet_names: &[String],
136    opts: &OptionSet,
137    info: &WorkbookInfo,
138    max_rows: usize,
139) -> Result<ResultSet, GenericError> {
140    let mut sheets: Vec<SheetDataSet> = vec![];
141    let capture_rows = opts.capture_rows();
142    for (sheet_index, sheet_ref) in sheet_names.iter().enumerate() {
143        let range = workbook.worksheet_range(&sheet_ref.clone())?;
144        let mut headers: Vec<String> = vec![];
145        let mut has_headers = false;
146        let mut rows: Vec<IndexMap<String, Value>> =
147            Vec::with_capacity(if capture_rows { max_rows } else { 0 });
148        let mut row_index = 0;
149        let detected = resolve_header_and_data_rows(opts, || {
150            range.rows().take(DETECT_SAMPLE_SIZE)
151                .map(|row| row.iter().map(|c| c.to_string()).collect())
152                .collect()
153        });
154        let first_data_row_index = detected.data_index;
155        let capture_headers = detected.header_index.is_some();
156        let header_row_index = detected.header_index.unwrap_or(0);
157        let mut col_keys: Vec<String> = vec![];
158        let columns = if sheet_index == 0 {
159            opts.rows.columns.clone()
160        } else {
161            vec![]
162        };
163        let mut resolved_row_opts = opts.rows.clone();
164        let match_header_row_below = capture_headers && header_row_index > 0;
165        if capture_headers {
166            if let Some(first_row) = range.headers() {
167                let natural_keys = natural_column_keys(&first_row, &opts.field_mode);
168                let resolved_columns = resolve_columns(&columns, &natural_keys);
169                headers = build_header_keys(&first_row, &resolved_columns, &opts.field_mode);
170                resolved_row_opts.columns = resolved_columns;
171                has_headers = !match_header_row_below;
172                col_keys = first_row;
173            }
174        } else {
175            let num_cols = range.get_size().1;
176            let blank = vec![String::new(); num_cols];
177            let natural_keys = natural_column_keys(&blank, &opts.field_mode);
178            let resolved_columns = resolve_columns(&columns, &natural_keys);
179            headers = build_header_keys(&blank, &resolved_columns, &opts.field_mode.forced_fallback());
180            resolved_row_opts.columns = resolved_columns;
181            has_headers = true;
182        }
183        let total = range.get_size().0;
184        if capture_rows || match_header_row_below {
185            let max_row_count = if capture_rows {
186                max_rows
187            } else {
188                header_row_index + 2
189            };
190            let max_take = if total < max_row_count {
191                total
192            } else {
193                max_row_count + 1
194            };
195            for row in range.rows().take(max_take) {
196                if row_index > max_row_count {
197                    break;
198                }
199                if match_header_row_below && row_index == header_row_index {
200                    let h_row = row
201                        .iter()
202                        .map(|c| c.to_string().to_snake_case())
203                        .collect::<Vec<String>>();
204                    let natural_keys = natural_column_keys(&h_row, &opts.field_mode);
205                    let resolved_columns = resolve_columns(&columns, &natural_keys);
206                    headers = build_header_keys(&h_row, &resolved_columns, &opts.field_mode);
207                    resolved_row_opts.columns = resolved_columns;
208                    has_headers = true;
209                } else if (has_headers || !capture_headers) && capture_rows
210                    && row_index >= first_data_row_index {
211                    let is_real_data = if capture_headers {
212                        let raw_values: Vec<String> = row.iter().map(|c| c.to_string()).collect();
213                        is_not_header_row(&raw_values, row_index, &col_keys)
214                    } else {
215                        true
216                    };
217                    if is_real_data {
218                        let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
219                        rows.push(row_map);
220                    }
221                }
222                row_index += 1;
223            }
224        }
225        sheets.push(SheetDataSet::new(sheet_ref, &headers, &rows, total));
226    }
227    Ok(ResultSet::from_multiple(&sheets, info, opts))
228}
229
230/// Read a single worksheet from a workbook in immediate (sync) or asycnhronous modes
231pub async fn read_single_worksheet(
232    mut workbook: Sheets<BufReader<File>>,
233    sheet_ref: &str,
234    opts: &OptionSet,
235    info: &WorkbookInfo,
236    save_opt: Option<SaveRowFn>,
237    out_ref: Option<&str>,
238) -> Result<ResultSet, GenericError> {
239    let range = workbook.worksheet_range(sheet_ref)?;
240    let capture_rows = opts.capture_rows();
241    let columns = opts.rows.columns.clone();
242    let max_rows = opts.max_rows();
243    let mut headers: Vec<String> = vec![];
244    let mut col_keys: Vec<String> = vec![];
245    let mut has_headers = false;
246    let mut rows: Vec<IndexMap<String, Value>> =
247        Vec::with_capacity(if capture_rows { max_rows } else { 0 });
248    let mut row_index = 0;
249    let detected = resolve_header_and_data_rows(opts, || {
250        range.rows().take(DETECT_SAMPLE_SIZE)
251            .map(|row| row.iter().map(|c| c.to_string()).collect())
252            .collect()
253    });
254    let first_data_row_index = detected.data_index;
255    // No row is consumed as a header-text source for --omit-header, *or* when detection
256    // found no confident header row at all (see DetectedRows::header_index) -- both
257    // cases fall back to A1/C01-style names instead of deriving them from row text.
258    let capture_headers = detected.header_index.is_some();
259    let header_row_index = detected.header_index.unwrap_or(0);
260    let match_header_row_below = capture_headers && header_row_index > 0;
261    let mut resolved_row_opts = opts.rows.clone();
262
263    if capture_headers {
264        if let Some(first_row) = range.headers() {
265            let natural_keys = natural_column_keys(&first_row, &opts.field_mode);
266            let resolved_columns = resolve_columns(&columns, &natural_keys);
267            headers = build_header_keys(&first_row, &resolved_columns, &opts.field_mode);
268            resolved_row_opts.columns = resolved_columns;
269            has_headers = !match_header_row_below;
270            col_keys = first_row;
271        }
272    } else {
273        let num_cols = range.get_size().1;
274        let blank = vec![String::new(); num_cols];
275        let natural_keys = natural_column_keys(&blank, &opts.field_mode);
276        let resolved_columns = resolve_columns(&columns, &natural_keys);
277        headers = build_header_keys(&blank, &resolved_columns, &opts.field_mode.forced_fallback());
278        resolved_row_opts.columns = resolved_columns;
279        has_headers = true;
280    }
281    let total = range.get_size().0;
282    if capture_rows || match_header_row_below {
283        let max_row_count = if capture_rows {
284            max_rows
285        } else {
286            header_row_index + 2
287        };
288        let max_take = if total < max_row_count {
289            total
290        } else {
291            max_row_count + 1
292        };
293        for row in range.rows().take(max_take) {
294            if row_index > max_row_count {
295                break;
296            }
297            if match_header_row_below && row_index == header_row_index {
298                let h_row = row
299                    .iter()
300                    .map(|c| c.to_string().to_snake_case())
301                    .collect::<Vec<String>>();
302                let natural_keys = natural_column_keys(&h_row, &opts.field_mode);
303                let resolved_columns = resolve_columns(&columns, &natural_keys);
304                headers = build_header_keys(&h_row, &resolved_columns, &opts.field_mode);
305                resolved_row_opts.columns = resolved_columns;
306                has_headers = true;
307            } else if (has_headers || !capture_headers) && capture_rows
308                && row_index >= first_data_row_index {
309                // only capture rows if headers are either omitted or have already been captured
310                let is_real_data = if capture_headers {
311                    let raw_values: Vec<String> = row.iter().map(|c| c.to_string()).collect();
312                    is_not_header_row(&raw_values, row_index, &col_keys)
313                } else {
314                    // no header row was consumed, so there's no header text to
315                    // self-exclude a duplicate row against
316                    true
317                };
318                if is_real_data {
319                    let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
320                    rows.push(row_map);
321                }
322            }
323            row_index += 1;
324        }
325    }
326    if let Some(save_method) = save_opt {
327        // Skip everything before first_data_row_index (the header row itself, and any
328        // title/notes/gap rows above it) -- this used to just stream from the true start
329        // of the sheet regardless of header_row_index/data_row_index, silently exporting
330        // notes rows as bogus data records.
331        let mut save_count: usize = 0;
332        for (idx, row) in range.rows().enumerate() {
333            if save_count >= max_rows {
334                break;
335            }
336            if idx < first_data_row_index {
337                continue;
338            }
339            let is_real_data = if capture_headers {
340                let raw_values: Vec<String> = row.iter().map(|c| c.to_string()).collect();
341                is_not_header_row(&raw_values, idx, &col_keys)
342            } else {
343                true
344            };
345            if is_real_data {
346                let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
347                save_method(row_map)?;
348                save_count += 1;
349            }
350        }
351    }
352
353    let ds = DataSet::from_count_and_rows(total, rows, opts);
354    Ok(ResultSet::new(info, &headers, ds, opts, out_ref, detected.header_index, first_data_row_index))
355}
356
357/// Process a CSV/TSV file asynchronously with an optional row save method
358/// and output reference (file or database table reference)
359///
360/// Reads with `has_headers(false)` and drives header/gap/data classification manually by
361/// 0-based line index (mirroring the calamine path in `read_single_worksheet`) rather than
362/// relying on the `csv` crate's own implicit "always skip the first line" behavior --
363/// needed to honor `header_row`/`data_row_index` (including the case where they're equal:
364/// a CSV with predefined/external headers where no line is actually consumed as a header,
365/// e.g. `omit_header` with a fixed schema via `--keys`).
366pub async fn read_csv_core<'a>(
367    path_data: &PathData<'a>,
368    opts: &OptionSet,
369    save_opt: Option<SaveRowFn>,
370    out_ref: Option<&str>,
371) -> Result<ResultSet, GenericError> {
372    let separator = match path_data.mode() {
373        Extension::Tsv => b't',
374        _ => b',',
375    };
376    if let Ok(mut rdr) = ReaderBuilder::new()
377        .delimiter(separator)
378        .has_headers(false)
379        // Notes/title rows before the real header (header_row > 0) commonly have a
380        // different field count than the data rows below them -- without this, the
381        // csv crate rejects every record as malformed once row 0's width doesn't match
382        // the rest of the file.
383        .flexible(true)
384        .from_path(path_data.path())
385    {
386        let capture_rows = opts.capture_rows();
387        let max_line_usize = opts.max_rows();
388        // Sampling (when actually needed for detection) opens a fresh, short-lived reader
389        // rather than reusing `rdr` -- csv::Reader is a moving cursor, so peeking ahead on
390        // the same reader would consume records the main pass below still needs.
391        let detected = resolve_header_and_data_rows(opts, || {
392            let mut sample_rows = Vec::new();
393            if let Ok(mut sample_rdr) = ReaderBuilder::new()
394                .delimiter(separator)
395                .has_headers(false)
396                .flexible(true)
397                .from_path(path_data.path())
398            {
399                for record in sample_rdr.records().take(DETECT_SAMPLE_SIZE).flatten() {
400                    sample_rows.push(record.iter().map(|s| s.to_string()).collect());
401                }
402            }
403            sample_rows
404        });
405        let first_data_row_index = detected.data_index;
406        // No line is a header source for --omit-header, *or* when detection found no
407        // confident header row at all (see DetectedRows::header_index) -- both fall
408        // back to lazily-built A1/C01-style names below.
409        let capture_header = detected.header_index.is_some();
410        let header_row_index = detected.header_index.unwrap_or(0);
411
412        let mut rows: Vec<IndexMap<String, Value>> =
413            Vec::with_capacity(if capture_rows { max_line_usize } else { 0 });
414        let mut headers: Vec<String> = vec![];
415        let mut resolved_row_opts = opts.rows.clone();
416        // With omit_header, no line is ever a header source -- fallback (A1/C01) keys are
417        // derived once, lazily, from the first eligible data row's column count.
418        let mut fallback_keys_built = false;
419
420        let mut total: usize = 0;
421        let mut line_count: usize = 0;
422        let mut row_index: usize = 0;
423
424        for result in rdr.records() {
425            let Ok(record) = result else {
426                row_index += 1;
427                continue;
428            };
429            // "total"/num_rows is a structural line count for the whole file, matching
430            // the calamine path's range.get_size().0 -- it includes the header row (and
431            // any skipped gap rows), not just rows that end up classified as data.
432            total += 1;
433
434            if capture_header && row_index == header_row_index {
435                let raw: Vec<String> = record.iter().map(|s| s.to_string()).collect();
436                let natural_keys = natural_column_keys(&raw, &opts.field_mode);
437                let resolved_columns = resolve_columns(&opts.rows.columns, &natural_keys);
438                headers = build_header_keys(&raw, &resolved_columns, &opts.field_mode);
439                resolved_row_opts.columns = resolved_columns;
440                row_index += 1;
441                continue;
442            }
443
444            if row_index < first_data_row_index {
445                row_index += 1;
446                continue;
447            }
448
449            if !capture_header && !fallback_keys_built {
450                let blank: Vec<String> = record.iter().map(|_| String::new()).collect();
451                let resolved_columns = resolve_columns(&opts.rows.columns, &natural_column_keys(&blank, &opts.field_mode));
452                headers = build_header_keys(&blank, &resolved_columns, &opts.field_mode.forced_fallback());
453                resolved_row_opts.columns = resolved_columns;
454                fallback_keys_built = true;
455            }
456
457            if capture_rows {
458                if line_count < max_line_usize {
459                    if let Some(row) = csv_row_result_to_values(Ok(record), &resolved_row_opts) {
460                        rows.push(to_index_map(&row, &headers));
461                        line_count += 1;
462                    }
463                }
464            } else if let Some(save_method) = save_opt.as_ref() {
465                if let Some(row) = csv_row_result_to_values(Ok(record), &resolved_row_opts) {
466                    let row_map = to_index_map(&row, &headers);
467                    save_method(row_map)?;
468                }
469            }
470            row_index += 1;
471        }
472        let info = WorkbookInfo::simple(path_data);
473        let ds = DataSet::from_count_and_rows(total, rows, opts);
474        Ok(ResultSet::new(&info, &headers, ds, opts, out_ref, detected.header_index, first_data_row_index))
475    } else {
476        let error_msg = match path_data.ext() {
477            Extension::Tsv => "unreadable_tsv_file",
478            _ => "unreadable_csv_file",
479        };
480        Err(GenericError(error_msg))
481    }
482}
483
484// Convert an array of row data to an IndexMap of serde_json::Value objects
485fn workbook_row_to_map(
486    row: &[Data],
487    opts: &RowOptionSet,
488    headers: &[String],
489) -> IndexMap<String, Value> {
490    to_index_map(&workbook_row_to_values(row, opts), headers)
491}
492
493// Convert an array of row data to a vector of serde_json::Value objects
494fn workbook_row_to_values(row: &[Data], opts: &RowOptionSet) -> Vec<Value> {
495    row.iter()
496        .enumerate()
497        .map(|(c_index, cell)| workbook_cell_to_value(cell, opts, c_index))
498        .collect()
499}
500
501/// Convert a spreadsheet data cell to a polymorphic serde_json::Value object
502fn workbook_cell_to_value(cell: &Data, opts: &RowOptionSet, c_index: usize) -> Value {
503    let col = opts.column(c_index);
504    let format = col.map_or(Format::Auto, |c| c.format.to_owned());
505    let def_val = col.and_then(|c| c.default.clone());
506    let col_mode = col.map_or(DateTimeMode::Full, |c| c.datetime_mode);
507
508    let mode = resolve_datetime_mode(&format, col_mode, opts.datetime_mode);
509
510    match cell {
511        Data::Int(i) => Value::Number(Number::from_i128(*i as i128).unwrap()),
512        Data::Float(f) => process_float_value(*f, format),
513        Data::DateTimeIso(d) => process_iso_datetime_value(d, def_val, mode),
514        Data::DateTime(d) => process_excel_datetime_value(d, def_val, mode),
515        Data::Bool(b) => Value::Bool(*b),
516        Data::String(s) => process_string_value(s, format, def_val),
517        Data::Empty => def_val.unwrap_or(Value::Null),
518        _ => Value::String(cell.to_string()),
519    }
520}
521
522/// A column's own Format::Date/Format::Time/Format::Hm/Format::DateTime/
523/// Format::DateTimeSimple override takes precedence over everything else, since it
524/// forces date/time interpretation regardless of the cell's native type. Next is the
525/// column's own `datetime_mode` (only meaningful on a Format::Auto column, restricted to
526/// cells that are already genuine datetimes -- see `Column::datetime_mode`'s doc
527/// comment). Anything else falls back to the row-wide default. No override anywhere
528/// means the full datetime.
529fn resolve_datetime_mode(format: &Format, col_mode: DateTimeMode, row_mode: DateTimeMode) -> DateTimeMode {
530    match format {
531        Format::Date => DateTimeMode::DateOnly,
532        Format::Time => DateTimeMode::TimeOnly,
533        Format::Hm => DateTimeMode::HmOnly,
534        Format::DateTime => DateTimeMode::Full,
535        Format::DateTimeSimple => DateTimeMode::Simple,
536        _ if col_mode != DateTimeMode::Full => col_mode,
537        _ => row_mode,
538    }
539}
540
541fn process_float_value(value: f64, format: Format) -> Value {
542    match format {
543        Format::Integer => Value::Number(Number::from_i128(value as i128).unwrap()),
544        Format::Boolean => Value::Bool(value >= 1.0),
545        Format::Text => Value::String(value.to_string()),
546        _ => Value::Number(Number::from_f64(value).unwrap()),
547    }
548}
549
550fn process_excel_datetime_value(
551    datetime: &calamine::ExcelDateTime,
552    def_val: Option<Value>,
553    mode: DateTimeMode,
554) -> Value {
555    // Excel has no true time-only type -- a cell formatted as plain "hh:mm" (not the
556    // bracketed "[h]:mm:ss" duration format) is really a full datetime serial with zero
557    // elapsed days, which calamine converts by landing on its epoch ("1899-12-31" in the
558    // 1900 date system). Carrying that placeholder date through to a full ISO datetime
559    // string would misrepresent a genuine time-of-day value as if it were a real date,
560    // so a cell with no real date component (serial < 1.0) is auto-rendered as a bare
561    // time even without an explicit Format::Time/--time-only request -- for both Full
562    // and Simple modes, since Simple is still "the whole datetime", just reformatted.
563    let auto_time_only = matches!(mode, DateTimeMode::Full | DateTimeMode::Simple) && datetime.as_f64() < 1.0;
564    datetime.as_datetime().map_or_else(
565        || def_val.unwrap_or(Value::Null),
566        |dt| {
567            // Milliseconds are only meaningful in the default Full mode's genuine
568            // full-datetime output, kept for JS-interop compatibility; every other mode
569            // -- including Full/Simple's own bare-time fallback above -- renders plain
570            // "HH:MM:SS", since a value that's already been reduced to seconds-only
571            // precision (or came from an Excel "hh:mm"-formatted cell with no seconds
572            // at all) gains nothing from a trailing ".000".
573            let formatted_date = match mode {
574                DateTimeMode::DateOnly => dt.format("%Y-%m-%d").to_string(),
575                DateTimeMode::TimeOnly => dt.format("%H:%M:%S").to_string(),
576                DateTimeMode::HmOnly => dt.format("%H:%M").to_string(),
577                DateTimeMode::Simple if auto_time_only => dt.format("%H:%M:%S").to_string(),
578                DateTimeMode::Simple => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
579                DateTimeMode::Full if auto_time_only => dt.format("%H:%M:%S").to_string(),
580                DateTimeMode::Full => dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
581            };
582            Value::String(formatted_date)
583        },
584    )
585}
586
587fn process_iso_datetime_value(dt_str: &str, def_val: Option<Value>, mode: DateTimeMode) -> Value {
588    match mode {
589        DateTimeMode::DateOnly => iso_fuzzy_to_date_string(dt_str)
590            .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
591        DateTimeMode::TimeOnly => iso_fuzzy_to_datetime_string(dt_str)
592            .and_then(|full| extract_time_portion(&full, false))
593            .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
594        DateTimeMode::HmOnly => iso_fuzzy_to_datetime_string(dt_str)
595            .and_then(|full| extract_time_portion(&full, true))
596            .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
597        DateTimeMode::Simple => iso_fuzzy_to_datetime_string(dt_str)
598            .map(|full| simplify_datetime_string(&full))
599            .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
600        DateTimeMode::Full => iso_fuzzy_to_datetime_string(dt_str)
601            .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
602    }
603}
604
605/// Extracts just the time-of-day portion from a full ISO-8601 datetime string like
606/// "2023-06-15T10:17:00.000Z" -- the shape iso_fuzzy_to_datetime_string always produces
607/// -- as "HH:MM:SS" or, when `hm_only`, just "HH:MM"; milliseconds are always dropped,
608/// matching process_excel_datetime_value's native-cell equivalent. fuzzy-datetime itself
609/// has no time-only concept by design (it stays scoped to date/datetime completion and
610/// order-guessing); this is purely string surgery on its output for
611/// Format::Time/Format::Hm/--time-only/--hm-only, not a new parsing capability.
612fn extract_time_portion(full_datetime_str: &str, hm_only: bool) -> Option<String> {
613    let time_part = full_datetime_str.split('T').nth(1)?;
614    let time_part = time_part.trim_end_matches('Z');
615    let len = if hm_only { 5 } else { 8 };
616    time_part.get(0..len).map(str::to_string)
617}
618
619/// Strips the trailing ".mmmZ" milliseconds-and-Z suffix from a full ISO-8601 datetime
620/// string like "2023-06-15T10:17:00.000Z", leaving "2023-06-15T10:17:00" -- for
621/// Format::DateTimeSimple/--simple, where the millisecond precision and JS-interop-style
622/// trailing Z (both there for the default Full mode) are unwanted noise.
623fn simplify_datetime_string(full_datetime_str: &str) -> String {
624    let trimmed = full_datetime_str.trim_end_matches('Z');
625    match trimmed.split_once('.') {
626        Some((base, _)) => base.to_string(),
627        None => trimmed.to_string(),
628    }
629}
630
631fn process_string_value(value: &str, format: Format, def_val: Option<Value>) -> Value {
632    match format {
633        Format::Boolean => process_truthy_value(value, def_val, |v, ef| v.is_truthy_core(ef)),
634        Format::Truthy => process_truthy_value(value, def_val, |v, ef| v.is_truthy_standard(ef)),
635        Format::TruthyCustom(opts) => process_truthy_value(value, def_val, |v, _| {
636            v.is_truthy_custom(&opts)
637        }),
638        Format::Decimal(places) => {
639            process_numeric_value(value, def_val, |n| float_value(n.round_decimal(places)))
640        }
641        Format::Float => process_numeric_value(value, def_val, float_value),
642        Format::Date => process_date_value(value, def_val, iso_fuzzy_to_date_string),
643        Format::DateTime => process_date_value(value, def_val, iso_fuzzy_to_datetime_string),
644        Format::DateTimeSimple => process_date_value(value, def_val, |s| {
645            iso_fuzzy_to_datetime_string(s).map(|full| simplify_datetime_string(&full))
646        }),
647        Format::Time => process_date_value(value, def_val, |s| {
648            iso_fuzzy_to_datetime_string(s).and_then(|full| extract_time_portion(&full, false))
649        }),
650        Format::Hm => process_date_value(value, def_val, |s| {
651            iso_fuzzy_to_datetime_string(s).and_then(|full| extract_time_portion(&full, true))
652        }),
653        _ => Value::String(value.to_owned()),
654    }
655}
656
657fn process_truthy_value<F>(value: &str, def_val: Option<Value>, truthy_fn: F) -> Value
658where
659    F: Fn(&str, bool) -> Option<bool>,
660{
661    if let Some(is_true) = truthy_fn(value, false) {
662        Value::Bool(is_true)
663    } else {
664        def_val.unwrap_or(Value::Null)
665    }
666}
667
668fn process_numeric_value<F>(value: &str, def_val: Option<Value>, numeric_fn: F) -> Value
669where
670    F: Fn(f64) -> Value,
671{
672    if let Some(n) = value.to_first_number::<f64>() {
673        numeric_fn(n)
674    } else {
675        def_val.unwrap_or(Value::Null)
676    }
677}
678
679fn process_date_value<F>(value: &str, def_val: Option<Value>, date_fn: F) -> Value
680where
681    F: Fn(&str) -> Option<String>,
682{
683    if let Some(date_str) = date_fn(value) {
684        string_value(&date_str)
685    } else {
686        def_val.unwrap_or(Value::Null)
687    }
688}
689
690// Convert csv rows to value
691fn csv_row_result_to_values(
692    result: Result<StringRecord, csv::Error>,
693    opts: &RowOptionSet,
694) -> Option<Vec<Value>> {
695    if let Ok(record) = result {
696        let row = record
697            .into_iter()
698            .enumerate()
699            .map(|(ci, cell)| csv_cell_to_json_value(cell, opts, ci))
700            .collect();
701        return Some(row);
702    }
703    None
704}
705
706// convert CSV cell &str value to a polymorphic serde_json::VALUE
707fn csv_cell_to_json_value(cell: &str, opts: &RowOptionSet, index: usize) -> Value {
708    // clean cell to check if it's numeric
709    let col = opts.column(index);
710    let (fmt, euro_num_mode) = if let Some(c) = col {
711        (c.format.clone(), c.decimal_comma)
712    } else {
713        (Format::Auto, opts.decimal_comma)
714    };
715    // A column's own Format::Date/Format::Time/Format::Hm/Format::DateTime/
716    // Format::DateTimeSimple override is checked before any numeric parsing, since a
717    // date-like string ("2023-06-15") can
718    // otherwise be misread as starting with a plain number ("2023") and fall through to
719    // Value::Number instead of going through fuzzy-datetime at all.
720    let def_val = col.and_then(|c| c.default.clone());
721    match fmt {
722        Format::Date => return process_date_value(cell, def_val, iso_fuzzy_to_date_string),
723        Format::DateTime => return process_date_value(cell, def_val, iso_fuzzy_to_datetime_string),
724        Format::DateTimeSimple => {
725            return process_date_value(cell, def_val, |s| {
726                iso_fuzzy_to_datetime_string(s).map(|full| simplify_datetime_string(&full))
727            })
728        }
729        Format::Time => {
730            return process_date_value(cell, def_val, |s| {
731                iso_fuzzy_to_datetime_string(s).and_then(|full| extract_time_portion(&full, false))
732            })
733        }
734        Format::Hm => {
735            return process_date_value(cell, def_val, |s| {
736                iso_fuzzy_to_datetime_string(s).and_then(|full| extract_time_portion(&full, true))
737            })
738        }
739        _ => {}
740    }
741    let has_number = cell.to_first_number::<f64>().is_some();
742    let num_cell = if has_number {
743        let euro_num_mode = uses_decimal_comma(cell, euro_num_mode);
744        if euro_num_mode {
745            cell.replace(",", ".").replace(",", ".")
746        } else {
747            cell.replace(",", "")
748        }
749    } else {
750        cell.to_owned()
751    };
752    let mut new_cell = Value::Null;
753    if !num_cell.is_empty() && num_cell.is_numeric() {
754        if let Ok(float_val) = serde_json::Number::from_str(&num_cell) {
755            match fmt {
756                Format::Integer => {
757                    // as_i128() only succeeds for Numbers that are already integer-valued
758                    // internally, so it silently yields 0 for any decimal value (e.g. "58.2")
759                    // via unwrap_or(0). Go through as_f64() and truncate instead, matching
760                    // the equivalent xlsx/ods cell conversion in process_float_value above.
761                    if let Some(f) = float_val.as_f64() {
762                        if let Some(int_val) = Number::from_i128(f as i128) {
763                            new_cell = Value::Number(int_val);
764                        }
765                    }
766                }
767                Format::Boolean => {
768                    // only 1.0 or more will evaluate as true
769                    new_cell = Value::Bool(float_val.as_f64().unwrap_or(0f64) >= 1.0);
770                }
771                _ => {
772                    new_cell = Value::Number(float_val);
773                }
774            }
775        }
776    } else if let Some(is_true) = cell.is_truthy_core(false) {
777        new_cell = Value::Bool(is_true);
778    } else {
779        new_cell = match fmt {
780            Format::Truthy => {
781                if let Some(is_true) = cell.is_truthy_standard(false) {
782                    Value::Bool(is_true)
783                } else {
784                    Value::Null
785                }
786            }
787            _ => Value::String(cell.to_string()),
788        };
789    }
790    new_cell
791}
792
793pub async fn read_workbook_sheet_info<'a>(
794    path_data: &PathData<'a>,
795) -> Result<IndexMap<String, usize>, GenericError> {
796    if let Ok(mut workbook) = open_workbook_auto(path_data.path()) {
797        let mut im: IndexMap<String, usize> = IndexMap::new();
798        for name in workbook.sheet_names() {
799            if let Ok(range) = workbook.worksheet_range(&name) {
800                im.insert(name, range.rows().count());
801            }
802        }
803        Ok(im)
804    } else {
805        Err(GenericError("cannot_open_workbook"))
806    }
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812    use crate::{helpers::*, Column};
813    use serde_json::json;
814    use std::path;
815
816    /// Generates a workbook laid out the way many real-world spreadsheets are: a title
817    /// row, a notes row, the real header row, a blank gap row, then the actual data --
818    /// for testing header_row/data_row_index against a realistic gap scenario. Row 0
819    /// (0-based) "Report Title", row 1 "Generated 2026-01-01", row 2 header ("sku",
820    /// "qty"), row 3 blank, rows 4-5 data (SKU001/10, SKU002/20).
821    fn gen_header_gap_fixture(filename: &str) -> String {
822        use rust_xlsxwriter::Workbook;
823        let mut workbook = Workbook::new();
824        let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
825        sheet.write_string(0, 0, "Report Title").unwrap();
826        sheet.write_string(1, 0, "Generated 2026-01-01").unwrap();
827        sheet.write_string(2, 0, "sku").unwrap();
828        sheet.write_string(2, 1, "qty").unwrap();
829        // row 3 intentionally left blank
830        sheet.write_string(4, 0, "SKU001").unwrap();
831        sheet.write_number(4, 1, 10.0).unwrap();
832        sheet.write_string(5, 0, "SKU002").unwrap();
833        sheet.write_number(5, 1, 20.0).unwrap();
834        let path = std::env::temp_dir().join(filename);
835        workbook.save(&path).unwrap();
836        path.to_string_lossy().to_string()
837    }
838
839    /// The user-supplied example that motivated auto-detection: a title row, a proper
840    /// 3-column header, an explanatory-text row that only fills one cell, then real
841    /// data. header_row=1, data_row_index=3 (both 0-based) is the expected guess.
842    fn gen_auto_detect_fixture(filename: &str) -> String {
843        use rust_xlsxwriter::Workbook;
844        let mut workbook = Workbook::new();
845        let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
846        sheet.write_string(0, 0, "Sales 2025").unwrap();
847        sheet.write_string(1, 0, "region").unwrap();
848        sheet.write_string(1, 1, "team size").unwrap();
849        sheet.write_string(1, 2, "revenue").unwrap();
850        sheet.write_string(2, 0, "long explanation about the data").unwrap();
851        sheet.write_string(3, 0, "west").unwrap();
852        sheet.write_number(3, 1, 12.0).unwrap();
853        sheet.write_number(3, 2, 923456.0).unwrap();
854        sheet.write_string(4, 0, "east").unwrap();
855        sheet.write_number(4, 1, 7.0).unwrap();
856        sheet.write_number(4, 2, 817285.0).unwrap();
857        let path = std::env::temp_dir().join(filename);
858        workbook.save(&path).unwrap();
859        path.to_string_lossy().to_string()
860    }
861
862    #[test]
863    fn test_detect_header_opt_in_finds_header_and_data_row_xlsx() {
864        // detect_header is off by default for direct library use (see the field's own
865        // doc) -- callers that want auto-detection opt in explicitly via .detect_header().
866        let path = gen_auto_detect_fixture("auto_detect.xlsx");
867        let opts = OptionSet::new(&path).detect_header();
868        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
869        assert_eq!(rows.len(), 2, "the explanation row must not leak through as data");
870        assert_eq!(rows[0].get("region"), Some(&json!("west")));
871        assert_eq!(rows[0].get("team_size"), Some(&json!(12.0)));
872        assert_eq!(rows[0].get("revenue"), Some(&json!(923456.0)));
873        assert_eq!(rows[1].get("region"), Some(&json!("east")));
874    }
875
876    #[test]
877    fn test_detect_header_opt_in_finds_header_and_data_row_csv() {
878        let path = write_csv_fixture(
879            "auto_detect.csv",
880            "Sales 2025\nregion,team size,revenue\nlong explanation about the data\nwest,12,923456\neast,7,817285\n",
881        );
882        let opts = OptionSet::new(&path).detect_header();
883        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
884        assert_eq!(rows.len(), 2, "the explanation row must not leak through as data");
885        assert_eq!(rows[0].get("region"), Some(&json!("west")));
886        assert_eq!(rows[0].get("team_size"), Some(&json!(12)));
887        assert_eq!(rows[1].get("region"), Some(&json!("east")));
888    }
889
890    #[test]
891    fn test_detect_header_falls_back_to_fallback_naming_for_headerless_text_csv() {
892        // No header row exists at all, and there's no numeric/boolean/date signal
893        // anywhere (a content-migration file) -- detection must not consume the first
894        // row as a bogus header, losing it as data. Field names fall back to A1-style
895        // letters, same as --omit-header, since there's no header text to derive from.
896        let path = write_csv_fixture(
897            "headerless_migration.csv",
898            "welcome_msg,Welcome to our store,Bienvenue dans notre magasin\ngoodbye_msg,Thank you for visiting,Merci de votre visite\n",
899        );
900        let opts = OptionSet::new(&path).detect_header();
901        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
902        assert_eq!(rows.len(), 2, "both rows must be present, none consumed as a header");
903        assert_eq!(rows[0].get("a"), Some(&json!("welcome_msg")));
904        assert_eq!(rows[0].get("b"), Some(&json!("Welcome to our store")));
905        assert_eq!(rows[1].get("a"), Some(&json!("goodbye_msg")));
906    }
907
908    #[test]
909    fn test_detect_header_falls_back_to_fallback_naming_for_headerless_text_xlsx() {
910        use rust_xlsxwriter::Workbook;
911        let mut workbook = Workbook::new();
912        let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
913        sheet.write_string(0, 0, "welcome_msg").unwrap();
914        sheet.write_string(0, 1, "Welcome to our store").unwrap();
915        sheet.write_string(0, 2, "Bienvenue dans notre magasin").unwrap();
916        sheet.write_string(1, 0, "goodbye_msg").unwrap();
917        sheet.write_string(1, 1, "Thank you for visiting").unwrap();
918        sheet.write_string(1, 2, "Merci de votre visite").unwrap();
919        let path = std::env::temp_dir().join("headerless_migration.xlsx");
920        workbook.save(&path).unwrap();
921
922        let opts = OptionSet::new(path.to_str().unwrap()).detect_header();
923        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
924        assert_eq!(rows.len(), 2, "both rows must be present, none consumed as a header");
925        assert_eq!(rows[0].get("a"), Some(&json!("welcome_msg")));
926        assert_eq!(rows[1].get("a"), Some(&json!("goodbye_msg")));
927    }
928
929    #[test]
930    fn test_omit_header_uses_fallback_names_for_xlsx_not_real_header_text() {
931        // Regression: --omit-header used to be a no-op for xlsx/ods -- real header text
932        // was still used for field names regardless of the flag, because the row-0
933        // shortcut (range.headers()) ran unconditionally. Now gated on capture_headers,
934        // shared with the detect_header fallback-naming path above.
935        let sample_path = "data/sample-data-1.xlsx";
936        let opts = OptionSet::new(sample_path).omit_header();
937        let result = process_spreadsheet_direct(&opts).unwrap();
938        assert!(result.keys.contains(&"a".to_string()), "got: {:?}", result.keys);
939        assert!(!result.keys.contains(&"id".to_string()), "got: {:?}", result.keys);
940        let rows = result.to_vec();
941        // the literal header row's own text is now the first "data" row, since no row
942        // is consumed for headers at all
943        assert_eq!(rows[0].get("a"), Some(&json!("id")));
944    }
945
946    #[test]
947    fn test_detect_header_off_by_default_leaves_notes_rows_uncleaned() {
948        // Without .detect_header(), the library falls back to its old, simple default
949        // (row 0 is the header) even on a file that has title/notes rows -- this is the
950        // behavior direct library consumers should see unless they opt in.
951        let path = gen_auto_detect_fixture("auto_detect_no_opt_in.xlsx");
952        let opts = OptionSet::new(&path);
953        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
954        // row 0 ("Sales 2025") is treated as the header; every subsequent row (including
955        // the real header and the notes row) is captured as data instead of being cleaned up
956        assert_eq!(rows.len(), 4);
957    }
958
959    #[test]
960    fn test_detect_header_does_not_change_output_for_a_normal_well_formed_file() {
961        // Regression guard: with .detect_header() opted in, a file with no title/notes
962        // rows at all (row 0 is already a perfectly good header) must produce identical
963        // output to the plain default -- detection should just confirm row 0, not guess
964        // something else.
965        let sample_path = "data/sample-data-1.xlsx";
966        let opts = OptionSet::new(sample_path).max_row_count(1_000).detect_header();
967        let result = process_spreadsheet_direct(&opts).unwrap();
968        assert_eq!(result.num_rows, 401);
969        assert_eq!(result.to_vec()[0].get("first_name"), Some(&json!("Dulce")));
970    }
971
972    #[test]
973    fn test_header_row_without_data_row_index_captures_the_gap_row_as_data() {
974        // Baseline: with only header_row set (no data_row_index), the row directly
975        // below the header is treated as data, even though it's actually a blank gap
976        // row here -- this is the pre-existing behavior data_row_index exists to fix.
977        let path = gen_header_gap_fixture("header_gap_baseline.xlsx");
978        let opts = OptionSet::new(&path).header_row(2);
979        let result = process_spreadsheet_direct(&opts).unwrap();
980        let rows = result.to_vec();
981        assert_eq!(rows.len(), 3, "gap row, SKU001, SKU002");
982        assert_eq!(rows[0].get("sku"), Some(&Value::Null));
983        assert_eq!(rows[1].get("sku"), Some(&json!("SKU001")));
984        assert_eq!(rows[2].get("sku"), Some(&json!("SKU002")));
985    }
986
987    #[test]
988    fn test_data_row_index_skips_the_gap_between_header_and_real_data() {
989        // header_row=2 (the "sku"/"qty" row), data_row_index=4 (the SKU001 row), both
990        // 0-based -- row 3, the blank gap row, is skipped entirely rather than captured
991        // as a null row.
992        let path = gen_header_gap_fixture("header_gap_with_data_row.xlsx");
993        let opts = OptionSet::new(&path).header_row(2).data_row_index(4);
994        let result = process_spreadsheet_direct(&opts).unwrap();
995        let rows = result.to_vec();
996        assert_eq!(rows.len(), 2, "just SKU001 and SKU002, no gap row");
997        assert_eq!(rows[0].get("sku"), Some(&json!("SKU001")));
998        assert_eq!(rows[0].get("qty"), Some(&json!(10.0)));
999        assert_eq!(rows[1].get("sku"), Some(&json!("SKU002")));
1000        assert_eq!(rows[1].get("qty"), Some(&json!(20.0)));
1001    }
1002
1003    #[test]
1004    fn test_result_set_reports_the_resolved_header_and_body_start_indices() {
1005        // Regression: ResultSet.header_row_index/body_start_index used to not exist at
1006        // all -- callers had no way to learn what row indices a read actually used,
1007        // whether from an explicit override (this test) or auto-detection (the next
1008        // one), since OptionSet.header_row/.data_row_index only ever reflect an
1009        // explicit override and stay None otherwise.
1010        let path = gen_header_gap_fixture("header_gap_reports_resolved_indices.xlsx");
1011        let opts = OptionSet::new(&path).header_row(2).data_row_index(4);
1012        let result = process_spreadsheet_direct(&opts).unwrap();
1013        assert_eq!(result.header_row_index, Some(2));
1014        assert_eq!(result.body_start_index, 4);
1015    }
1016
1017    #[test]
1018    fn test_result_set_reports_resolved_indices_from_auto_detection_too() {
1019        // Same as above, but with no explicit header_row/data_row_index at all -- the
1020        // resolved indices must reflect what auto-detection actually found, not just
1021        // echo back the (unset) explicit-override fields.
1022        let path = gen_header_gap_fixture("header_gap_reports_detected_indices.xlsx");
1023        let opts = OptionSet::new(&path).detect_header();
1024        let result = process_spreadsheet_direct(&opts).unwrap();
1025        assert_eq!(result.header_row_index, Some(2));
1026        assert_eq!(result.body_start_index, 4);
1027    }
1028
1029    #[test]
1030    fn test_data_row_index_in_preview_multimode_skips_the_gap_too() {
1031        // Same fixture, read via --preview (multimode) -- the gap-skipping logic is
1032        // duplicated in read_multiple_worksheets, so it needs its own regression test.
1033        let path = gen_header_gap_fixture("header_gap_preview.xlsx");
1034        let opts = OptionSet::new(&path).header_row(2).data_row_index(4).read_mode_preview();
1035        let result = process_spreadsheet_direct(&opts).unwrap();
1036        let sheet_rows = result.data.first_sheet();
1037        assert_eq!(sheet_rows.len(), 2, "just SKU001 and SKU002, no gap row");
1038        assert_eq!(sheet_rows[0].get("sku"), Some(&json!("SKU001")));
1039        assert_eq!(sheet_rows[1].get("sku"), Some(&json!("SKU002")));
1040    }
1041
1042    #[test]
1043    fn test_direct_processing_xlsx() {
1044        let sample_path = "data/sample-data-1.xlsx";
1045
1046        // instantiate the OptionSet with a sample path and a maximum row count of 1000 rows as the source file has 401 rows
1047        // (although )the default max is 10,000)
1048        let opts = OptionSet::new(sample_path).max_row_count(1_000);
1049
1050        let result = process_spreadsheet_direct(&opts);
1051
1052        // The source file should have 1 header row and 400 data rows
1053        assert_eq!(result.unwrap().num_rows, 401);
1054    }
1055
1056    #[test]
1057    fn test_source_key_override_renames_and_reformats_without_position() {
1058        // End-to-end: overriding just one field out of many by its natural key,
1059        // without needing to enumerate/pad the columns ahead of it.
1060        let sample_path = "data/sample-data-1.csv";
1061        let mut opts = OptionSet::new(sample_path).max_row_count(2);
1062        opts.rows.columns = vec![
1063            Column::from_source_key_with_format("weight", Some("weight_lbs"), Format::Integer, None, DateTimeMode::Full, false),
1064        ];
1065
1066        let result = process_spreadsheet_direct(&opts).unwrap();
1067        // every other column keeps its natural, auto-detected name
1068        assert!(result.keys.contains(&"id".to_string()));
1069        assert!(result.keys.contains(&"first_name".to_string()));
1070        assert!(result.keys.contains(&"weight_lbs".to_string()));
1071        assert!(!result.keys.contains(&"weight".to_string()));
1072
1073        let rows = result.to_vec();
1074        let first = rows.first().expect("at least one row");
1075        assert!(first.get("weight_lbs").is_some());
1076        assert!(first.get("weight").is_none());
1077        assert_eq!(first.get("id").unwrap(), 1);
1078    }
1079
1080    #[test]
1081    fn test_resolve_datetime_mode_prefers_column_format_over_row_defaults() {
1082        // A column's own Format::Date/Format::Time/Format::DateTime overrides the
1083        // row-wide default; Format::Auto (and anything else) falls back to the column's
1084        // own datetime_mode next, then the row-wide default.
1085        assert_eq!(resolve_datetime_mode(&Format::Date, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::DateOnly);
1086        assert_eq!(resolve_datetime_mode(&Format::Time, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::TimeOnly);
1087        assert_eq!(resolve_datetime_mode(&Format::Hm, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::HmOnly);
1088        assert_eq!(resolve_datetime_mode(&Format::DateTimeSimple, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::Simple);
1089        assert_eq!(resolve_datetime_mode(&Format::DateTime, DateTimeMode::DateOnly, DateTimeMode::TimeOnly), DateTimeMode::Full);
1090        assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::Full);
1091        assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::DateOnly, DateTimeMode::Full), DateTimeMode::DateOnly);
1092        assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::Full, DateTimeMode::TimeOnly), DateTimeMode::TimeOnly);
1093        assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::HmOnly, DateTimeMode::DateOnly), DateTimeMode::HmOnly);
1094    }
1095
1096    #[test]
1097    fn test_source_key_override_casts_native_datetime_cell_to_date_only() {
1098        // Regression test: workbook_cell_to_value computed the column's Format override
1099        // but only ever consulted the row-wide --date-only flag for Data::DateTime /
1100        // Data::DateTimeIso cells, so a per-column `Format::Date` override on a real
1101        // (non-string) datetime cell had no effect at all.
1102        let sample_path = "data/sample-data-1.xlsx";
1103        let mut opts = OptionSet::new(sample_path).max_row_count(1);
1104        opts.rows.columns = vec![
1105            Column::from_source_key_with_format("start_time", None, Format::Date, None, DateTimeMode::Full, false),
1106        ];
1107
1108        let result = process_spreadsheet_direct(&opts).unwrap();
1109        let rows = result.to_vec();
1110        let first = rows.first().expect("at least one row");
1111        let start_time = first.get("start_time").expect("start_time column").as_str().unwrap();
1112        assert_eq!(start_time, "2023-06-15");
1113        assert!(!start_time.contains('T'), "should be date-only, got: {}", start_time);
1114    }
1115
1116    #[test]
1117    fn test_time_only_excel_cell_does_not_carry_the_epoch_placeholder_date() {
1118        // Regression: Excel has no true time-only type -- a cell formatted as plain
1119        // "hh:mm" (e.g. a recurring daily start time like "6:30") is really a full
1120        // datetime serial with zero elapsed days, which calamine converts by landing on
1121        // its epoch ("1899-12-31" in the 1900 date system). Formatting the whole thing
1122        // as a datetime carried that meaningless placeholder date through to the output
1123        // ("1899-12-31T06:30:00.000Z"); it should come back as a bare time instead.
1124        use rust_xlsxwriter::{ExcelDateTime, Format as XlsxFormat, Workbook};
1125
1126        let mut workbook = Workbook::new();
1127        let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1128        let time_fmt = XlsxFormat::new().set_num_format("hh:mm");
1129        sheet.write_string(0, 0, "meal").unwrap();
1130        sheet.write_string(0, 1, "start").unwrap();
1131        sheet.write_string(1, 0, "Breakfast").unwrap();
1132        let breakfast_time = ExcelDateTime::from_hms(6, 30, 0).unwrap();
1133        sheet.write_time_with_format(1, 1, breakfast_time, &time_fmt).unwrap();
1134        // a genuine full date+time, for contrast -- must still include the real date
1135        sheet.write_string(2, 0, "Meeting").unwrap();
1136        let meeting_dt = ExcelDateTime::from_ymd(2026, 3, 5).unwrap().and_hms(9, 0, 0).unwrap();
1137        let datetime_fmt = XlsxFormat::new().set_num_format("yyyy-mm-dd hh:mm");
1138        sheet.write_datetime_with_format(2, 1, meeting_dt, &datetime_fmt).unwrap();
1139        let path = std::env::temp_dir().join("time_only_cell.xlsx");
1140        workbook.save(&path).unwrap();
1141
1142        let opts = OptionSet::new(path.to_str().unwrap());
1143        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1144        assert_eq!(rows[0].get("start"), Some(&json!("06:30:00")));
1145        assert_eq!(rows[1].get("start"), Some(&json!("2026-03-05T09:00:00.000Z")));
1146    }
1147
1148    #[test]
1149    fn test_format_time_column_override_forces_time_only_even_for_a_full_datetime() {
1150        // Format::Time is an explicit override, distinct from the automatic
1151        // as_f64() < 1.0 detection above -- it must strip the date component even from
1152        // a cell that carries a genuine, non-epoch date, e.g. a per-column override on
1153        // a "logged_at" timestamp column where only the time-of-day is wanted.
1154        use rust_xlsxwriter::{ExcelDateTime, Format as XlsxFormat, Workbook};
1155
1156        let mut workbook = Workbook::new();
1157        let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1158        sheet.write_string(0, 0, "logged_at").unwrap();
1159        let dt = ExcelDateTime::from_ymd(2026, 3, 5).unwrap().and_hms(9, 15, 30).unwrap();
1160        let datetime_fmt = XlsxFormat::new().set_num_format("yyyy-mm-dd hh:mm:ss");
1161        sheet.write_datetime_with_format(1, 0, dt, &datetime_fmt).unwrap();
1162        let path = std::env::temp_dir().join("format_time_override_cell.xlsx");
1163        workbook.save(&path).unwrap();
1164
1165        let mut opts = OptionSet::new(path.to_str().unwrap());
1166        opts.rows.columns = vec![
1167            Column::from_source_key_with_format("logged_at", None, Format::Time, None, DateTimeMode::Full, false),
1168        ];
1169        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1170        assert_eq!(rows[0].get("logged_at"), Some(&json!("09:15:30")));
1171    }
1172
1173    #[test]
1174    fn test_row_wide_time_only_strips_the_date_from_a_native_iso_datetime_cell() {
1175        // opts.rows.datetime_mode mirrors --date-only but for the opposite end: with no
1176        // per-column override, it should reduce a full ISO datetime cell down to just
1177        // "HH:MM:SS.mmm" -- post-processing fuzzy-datetime's output, not a change to
1178        // the fuzzy-datetime crate itself (out of scope).
1179        let row_opts = RowOptionSet { datetime_mode: DateTimeMode::TimeOnly, ..Default::default() };
1180        let cell = Data::DateTimeIso("2023-06-15T10:17:00.000Z".to_string());
1181        assert_eq!(
1182            workbook_cell_to_value(&cell, &row_opts, 0),
1183            Value::String("10:17:00".to_string())
1184        );
1185    }
1186
1187    #[test]
1188    fn test_row_wide_hm_only_truncates_seconds_from_a_native_datetime_cell() {
1189        // --hm-only is coarser than --time-only: a start/end time or recurring daily
1190        // slot is usually better read as "09:15" than "09:15:30.000".
1191        let row_opts = RowOptionSet { datetime_mode: DateTimeMode::HmOnly, ..Default::default() };
1192        let cell = Data::DateTimeIso("2023-06-15T09:15:30.000Z".to_string());
1193        assert_eq!(
1194            workbook_cell_to_value(&cell, &row_opts, 0),
1195            Value::String("09:15".to_string())
1196        );
1197    }
1198
1199    #[test]
1200    fn test_csv_cell_format_time_extracts_time_of_day_from_a_plain_date_string() {
1201        // csv_cell_to_json_value previously had no Date/DateTime/Time handling at all --
1202        // a date-like string starting with a number (e.g. "2023-06-15T10:17") could be
1203        // misread by the numeric-extraction path before ever reaching fuzzy-datetime.
1204        let cols = vec![Column::new_format(Format::Time, None)];
1205        let row_opts = RowOptionSet::simple(&cols);
1206        assert_eq!(
1207            csv_cell_to_json_value("2023-06-15T10:17:00", &row_opts, 0),
1208            Value::String("10:17:00".to_string())
1209        );
1210    }
1211
1212    #[test]
1213    fn test_csv_cell_format_hm_drops_seconds_too() {
1214        // Format::Hm ("|hm" in --keys) is the CSV/string-cell equivalent of --hm-only,
1215        // e.g. spread-cli --keys "served_from|hm" for a restaurant menu's serving times.
1216        let cols = vec![Column::new_format(Format::Hm, None)];
1217        let row_opts = RowOptionSet::simple(&cols);
1218        assert_eq!(
1219            csv_cell_to_json_value("2023-06-15T09:15:30", &row_opts, 0),
1220            Value::String("09:15".to_string())
1221        );
1222    }
1223
1224    #[test]
1225    fn test_simplify_datetime_string_drops_milliseconds_and_trailing_z() {
1226        assert_eq!(simplify_datetime_string("2026-07-18T18:07:34.000Z"), "2026-07-18T18:07:34");
1227        // also tolerates a string with no fractional seconds or Z at all
1228        assert_eq!(simplify_datetime_string("2026-07-18T18:07:34"), "2026-07-18T18:07:34");
1229    }
1230
1231    #[test]
1232    fn test_row_wide_simple_mode_strips_milliseconds_and_z_from_a_native_iso_datetime_cell() {
1233        let row_opts = RowOptionSet { datetime_mode: DateTimeMode::Simple, ..Default::default() };
1234        let cell = Data::DateTimeIso("2026-07-18T18:07:34.000Z".to_string());
1235        assert_eq!(
1236            workbook_cell_to_value(&cell, &row_opts, 0),
1237            Value::String("2026-07-18T18:07:34".to_string())
1238        );
1239    }
1240
1241    #[test]
1242    fn test_csv_cell_format_datetime_simple_drops_milliseconds_and_z() {
1243        // Format::DateTimeSimple ("|simple" or "|ds" in --keys) is the CSV/string-cell
1244        // equivalent of --simple: the full datetime, minus the JS-interop-oriented
1245        // milliseconds/trailing-Z formatting used by the default Full mode.
1246        let cols = vec![Column::new_format(Format::DateTimeSimple, None)];
1247        let row_opts = RowOptionSet::simple(&cols);
1248        assert_eq!(
1249            csv_cell_to_json_value("2026-07-18T18:07:34", &row_opts, 0),
1250            Value::String("2026-07-18T18:07:34".to_string())
1251        );
1252    }
1253
1254    #[test]
1255    fn test_simple_mode_still_avoids_the_epoch_placeholder_date_for_a_genuine_time_only_excel_cell() {
1256        // Simple is still "the whole datetime", just reformatted -- so it must keep the
1257        // same auto time-only detection as Full mode (see
1258        // test_time_only_excel_cell_does_not_carry_the_epoch_placeholder_date), just
1259        // without milliseconds this time.
1260        use rust_xlsxwriter::{ExcelDateTime, Format as XlsxFormat, Workbook};
1261
1262        let mut workbook = Workbook::new();
1263        let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1264        let time_fmt = XlsxFormat::new().set_num_format("hh:mm");
1265        sheet.write_string(0, 0, "start").unwrap();
1266        let breakfast_time = ExcelDateTime::from_hms(6, 30, 0).unwrap();
1267        sheet.write_time_with_format(1, 0, breakfast_time, &time_fmt).unwrap();
1268        let path = std::env::temp_dir().join("simple_mode_time_only_cell.xlsx");
1269        workbook.save(&path).unwrap();
1270
1271        let mut opts = OptionSet::new(path.to_str().unwrap());
1272        opts.rows.datetime_mode = DateTimeMode::Simple;
1273        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1274        assert_eq!(rows[0].get("start"), Some(&json!("06:30:00")));
1275    }
1276
1277    #[test]
1278    fn test_column_datetime_mode_applies_only_to_genuine_datetime_cells_under_format_auto() {
1279        // Column::datetime_mode (distinct from Format::Date/Time/Hm/DateTime) is scoped
1280        // to columns left at Format::Auto -- it only ever touches a cell that's already
1281        // a genuine datetime (Data::DateTime/Data::DateTimeIso), leaving strings and
1282        // numbers in the same column completely untouched, unlike an explicit Format
1283        // override which would force-interpret every cell type.
1284        let cols = vec![Column::from_key_ref_with_format(None, Format::Auto, None, DateTimeMode::HmOnly, false)];
1285        let row_opts = RowOptionSet::simple(&cols);
1286        let datetime_cell = Data::DateTimeIso("2023-06-15T09:15:30.000Z".to_string());
1287        assert_eq!(
1288            workbook_cell_to_value(&datetime_cell, &row_opts, 0),
1289            Value::String("09:15".to_string())
1290        );
1291        let string_cell = Data::String("not a date".to_string());
1292        assert_eq!(
1293            workbook_cell_to_value(&string_cell, &row_opts, 0),
1294            Value::String("not a date".to_string())
1295        );
1296    }
1297
1298    #[test]
1299    fn test_csv_cell_integer_format_truncates_decimal_values() {
1300        // Regression test: Number::as_i128() only succeeds for already-integer-valued
1301        // Numbers, so casting a decimal CSV cell like "58.2" to Format::Integer used to
1302        // silently produce 0 via unwrap_or(0) instead of the truncated value.
1303        let cols = vec![Column::new_format(Format::Integer, None)];
1304        let row_opts = RowOptionSet::simple(&cols);
1305        assert_eq!(csv_cell_to_json_value("58.2", &row_opts, 0), Value::Number(Number::from(58)));
1306        assert_eq!(csv_cell_to_json_value("82.5", &row_opts, 0), Value::Number(Number::from(82)));
1307        assert_eq!(csv_cell_to_json_value("100", &row_opts, 0), Value::Number(Number::from(100)));
1308    }
1309
1310    #[test]
1311    fn test_csv_cell_does_not_coerce_ids_to_booleans() {
1312        // Regression test: these previously became `true`/`false` because their
1313        // embedded digit run (e.g. "SKU001" -> "001" -> 1) was fuzzily extracted
1314        // and matched against is_truthy_core's numeric range, even though the
1315        // column has no boolean intent (Format::Auto, the default).
1316        let row_opts = RowOptionSet::default();
1317        assert_eq!(csv_cell_to_json_value("SKU001", &row_opts, 0), Value::String("SKU001".to_string()));
1318        assert_eq!(csv_cell_to_json_value("A1", &row_opts, 0), Value::String("A1".to_string()));
1319        assert_eq!(csv_cell_to_json_value("01/06/2024", &row_opts, 0), Value::String("01/06/2024".to_string()));
1320        // literal boolean tokens should still be recognised
1321        assert_eq!(csv_cell_to_json_value("true", &row_opts, 0), Value::Bool(true));
1322        assert_eq!(csv_cell_to_json_value("false", &row_opts, 0), Value::Bool(false));
1323    }
1324
1325    #[test]
1326    fn test_direct_processing_csv() {
1327        let sample_path = "data/sample-data-1.csv";
1328
1329        // instantiate the OptionSet with a sample path and a maximum row count of 1000 rows as the source file has 401 rows
1330        // (although )the default max is 10,000)
1331        let opts = OptionSet::new(sample_path).max_row_count(1_000);
1332
1333        let result = process_spreadsheet_direct(&opts);
1334
1335        // The source file should have 1 header row and 400 data rows
1336        assert_eq!(result.unwrap().num_rows, 401);
1337    }
1338
1339    /// Writes raw CSV text to a temp file for testing header_row/data_row_index/
1340    /// omit_header against CSV specifically (calamine fixtures need a real xlsx writer,
1341    /// but CSV is plain text -- no generator needed).
1342    fn write_csv_fixture(filename: &str, content: &str) -> String {
1343        let path = std::env::temp_dir().join(filename);
1344        std::fs::write(&path, content).unwrap();
1345        path.to_string_lossy().to_string()
1346    }
1347
1348    #[test]
1349    fn test_csv_header_row_and_data_row_index_skip_a_gap() {
1350        // Row 0 title, row 1 notes, row 2 header, row 3 blank, rows 4-5 data --
1351        // the same shape as the xlsx gap fixture, but for CSV.
1352        let path = write_csv_fixture(
1353            "csv_header_gap.csv",
1354            "Report Title\nGenerated 2026-01-01\nsku,qty\n,\nSKU001,10\nSKU002,20\n",
1355        );
1356
1357        // baseline: header_row alone (no data_row_index) captures the blank gap row --
1358        // CSV has no native null, so an empty field comes through as "" rather than null
1359        let opts = OptionSet::new(&path).header_row(2);
1360        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1361        assert_eq!(rows.len(), 3, "gap row, SKU001, SKU002");
1362        assert_eq!(rows[0].get("sku"), Some(&json!("")));
1363
1364        // data_row_index skips the gap row entirely
1365        let opts = OptionSet::new(&path).header_row(2).data_row_index(4);
1366        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1367        assert_eq!(rows.len(), 2, "just SKU001 and SKU002");
1368        assert_eq!(rows[0].get("sku"), Some(&json!("SKU001")));
1369        assert_eq!(rows[1].get("sku"), Some(&json!("SKU002")));
1370    }
1371
1372    #[test]
1373    fn test_csv_omit_header_uses_fallback_keys_not_empty_rows() {
1374        // Regression: --omit-header on a CSV used to leave `headers` completely empty
1375        // (no A1/C01 fallback was ever built), so every row came out as `{}` -- and
1376        // separately, the `csv` crate's has_headers(true) default silently ate row 0
1377        // regardless of omit_header, discarding real data.
1378        let path = write_csv_fixture("csv_omit_header.csv", "SKU001,10\nSKU002,20\n");
1379        let opts = OptionSet::new(&path).omit_header();
1380        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1381        assert_eq!(rows.len(), 2, "both rows present, including the former row 0");
1382        assert_eq!(rows[0].get("a"), Some(&json!("SKU001")));
1383        assert_eq!(rows[0].get("b"), Some(&json!(10)));
1384        assert_eq!(rows[1].get("a"), Some(&json!("SKU002")));
1385    }
1386
1387    #[test]
1388    fn test_csv_header_row_equals_data_row_index_for_predefined_headers() {
1389        // header_row == data_row_index: a CSV with predefined/external headers (here,
1390        // via --keys) where no line is actually consumed as a header -- e.g. after
1391        // skipping 2 notes rows, row 2 is immediately real data, not a header line.
1392        let path = write_csv_fixture(
1393            "csv_predefined_headers.csv",
1394            "Report Title\nGenerated 2026-01-01\nSKU001,10\nSKU002,20\n",
1395        );
1396        let mut opts = OptionSet::new(&path).header_row(2).data_row_index(2).omit_header();
1397        opts.rows.columns = vec![
1398            Column::new(Some("sku")),
1399            Column::new(Some("qty")),
1400        ];
1401        let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1402        assert_eq!(rows.len(), 2, "both data rows present, notes rows skipped");
1403        assert_eq!(rows[0].get("sku"), Some(&json!("SKU001")));
1404        assert_eq!(rows[0].get("qty"), Some(&json!(10)));
1405        assert_eq!(rows[1].get("sku"), Some(&json!("SKU002")));
1406    }
1407
1408    #[test]
1409    fn test_multisheet_preview_ods() {
1410        let sample_path = "data/sample-data-2.ods";
1411
1412        // instantiate the OptionSet with a sample path
1413        // a maximum row count returned of 10 rows
1414        // and read mode to *preview* to scan all sheets
1415        // It should correctly calculate
1416        let opts = OptionSet::new(sample_path)
1417            .max_row_count(10)
1418            .read_mode_preview();
1419
1420        let result = process_spreadsheet_direct(&opts);
1421
1422        // The source spreadsheet should have 2 sheets
1423        let dataset = result.unwrap();
1424        assert_eq!(dataset.sheets.len(), 2);
1425        // The source spreadsheet should have 101 + 17 (= 118) populated rows including headers
1426        assert_eq!(dataset.num_rows, 118);
1427
1428        // The first sheet's data should only output 10 rows (including the header)
1429        assert_eq!(dataset.data.first_sheet().len(), 10);
1430    }
1431
1432    #[test]
1433    fn test_column_override_1() {
1434        let sample_json = json!({
1435          "sku": "CHAIR16",
1436          "height": "112cm",
1437          "width": "69cm",
1438          "approved": "Y"
1439        });
1440
1441        let rows = json_object_to_calamine_data(sample_json);
1442
1443        let cols = vec![
1444            Column::new_format(Format::Text, Some(string_value(""))),
1445            Column::new_format(Format::Float, Some(float_value(95.0))),
1446            Column::new_format(Format::Float, Some(float_value(65.0))),
1447            Column::new_format(Format::Truthy, Some(bool_value(false))),
1448        ];
1449
1450        // The first sheet's data should only output 10 rows (including the header)
1451        let opts = &RowOptionSet::simple(&cols);
1452        let result = workbook_row_to_values(&rows, opts);
1453        // the second column be cast to 112.0
1454        assert_eq!(result.get(1).unwrap(), 112.0);
1455        // the third column be cast to 69.0
1456        assert_eq!(result.get(2).unwrap(), 69.0);
1457        // the fourth column be cast to boolean
1458        assert_eq!(result.get(3).unwrap(), true);
1459    }
1460
1461    #[test]
1462    fn test_column_override_2() {
1463        let sample_json = json!({
1464          "name": "Sophia",
1465          "dob": "2001-9-23",
1466          "weight": "62kg",
1467          "result": "GOOD"
1468        });
1469
1470        let rows = json_object_to_calamine_data(sample_json);
1471
1472        let cols = vec![
1473            Column::new_format(Format::Text, None),
1474            Column::new_format(Format::Date, None),
1475            Column::new_format(Format::Float, None),
1476            // the fourth column be cast to boolean
1477            Column::new_format(
1478                Format::truthy_custom("good", "bad"),
1479                Some(bool_value(false)),
1480            ),
1481        ];
1482
1483        // The first sheet's data should only output 10 rows (including the header)
1484        let opts = &RowOptionSet::simple(&cols);
1485        let result = workbook_row_to_values(&rows, opts);
1486        assert_eq!(result.get(1).unwrap(), "2001-09-23");
1487        assert_eq!(result.get(2).unwrap(), 62.0);
1488        assert_eq!(result.get(3).unwrap(), true);
1489    }
1490
1491    #[tokio::test]
1492    async fn test_read_workbook_info() {
1493        let sample_path = "data/sample-data-1.xlsx";
1494        let path_data = PathData::new(path::Path::new(sample_path));
1495        let info = read_workbook_sheet_info(&path_data).await;
1496        assert!(info.is_ok());
1497    }
1498
1499    #[tokio::test]
1500    async fn test_large_csv_file() {
1501        let sample_path = "data/large-datasheet.csv";
1502        let max_rows = 100_000;
1503        let opts = OptionSet::new(sample_path).max_row_count(max_rows);
1504        let result = process_spreadsheet_core(&opts, None, None).await;
1505        if let Ok(data) = result.clone() {
1506            assert_eq!(data.data.first_sheet().len(), max_rows as usize);
1507        } else {
1508            panic!("Failed to process large CSV file");
1509        }
1510        assert!(result.is_ok());
1511    }
1512
1513    #[tokio::test]
1514    async fn test_medium_excel_file() {
1515        let sample_path = "data/medium-spreadsheet-50_000.xlsx";
1516        let max_rows = 5_000;
1517        let opts = OptionSet::new(sample_path).max_row_count(max_rows);
1518        let result = process_spreadsheet_core(&opts, None, None).await;
1519        if let Ok(data) = result.clone() {
1520            assert_eq!(data.data.first_sheet().len(), max_rows as usize);
1521        } else {
1522            panic!("Failed to process large Excel file");
1523        }
1524        assert!(result.is_ok());
1525    }
1526}