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;
10use std::sync::Arc;
11
12use crate::data_set::*;
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::Extension;
21use crate::Format;
22use crate::OptionSet;
23use crate::PathData;
24use crate::RowOptionSet;
25use fuzzy_datetime::{iso_fuzzy_to_date_string, iso_fuzzy_to_datetime_string};
26
27/// Output the result set with captured rows (up to the maximum allowed) directly.
28/// This is now synchronous and calls the asynchronous function using a runtime.
29pub fn process_spreadsheet_direct(opts: &OptionSet) -> Result<ResultSet, GenericError> {
30    let rt = tokio::runtime::Runtime::new().unwrap();
31    rt.block_on(process_spreadsheet_core(opts, None, None))
32}
33
34/// Output the result set with captured rows (up to the maximum allowed) immediately.
35/// Use this in an async function using the tokio runtime if you direct results
36/// without a save callback
37pub async fn process_spreadsheet_immediate(opts: &OptionSet) -> Result<ResultSet, GenericError> {
38    process_spreadsheet_core(opts, None, None).await
39}
40
41#[deprecated(
42    since = "1.0.6",
43    note = "This function is a wrapper for the renamed function `process_spreadsheet_inline`"
44)]
45pub async fn render_spreadsheet_direct(opts: &OptionSet) -> Result<ResultSet, GenericError> {
46    process_spreadsheet_core(opts, None, None).await
47}
48
49/// Output the result set with deferred row saving and optional output reference
50pub async fn process_spreadsheet_async(
51    opts: &OptionSet,
52    save_func: Box<dyn Fn(IndexMap<String, Value>) -> Result<(), GenericError> + Send + Sync>,
53    out_ref: Option<&str>,
54) -> Result<ResultSet, GenericError> {
55    process_spreadsheet_core(opts, Some(save_func), out_ref).await
56}
57
58/// Output the result set with captured rows (up to the maximum allowed) directly.
59/// with optional asynchronous row save method and output reference
60pub async fn process_spreadsheet_core(
61    opts: &OptionSet,
62    save_opt: Option<
63        Box<dyn Fn(IndexMap<String, Value>) -> Result<(), GenericError> + Send + Sync>,
64    >,
65    out_ref: Option<&str>,
66) -> Result<ResultSet, GenericError> {
67    if let Some(filepath) = opts.path.clone() {
68        let path = Path::new(&filepath);
69        if !path.exists() {
70            #[allow(dead_code)]
71            return Err(GenericError("file_unavailable"));
72        }
73        let path_data = PathData::new(path);
74        if path_data.is_valid() {
75            if path_data.use_calamine() {
76                read_workbook_core(&path_data, opts, save_opt, out_ref).await
77            } else {
78                read_csv_core(&path_data, opts, save_opt, out_ref).await
79            }
80        } else {
81            Err(GenericError("unsupported_format"))
82        }
83    } else {
84        Err(GenericError("no_filepath_specified"))
85    }
86}
87
88#[deprecated(
89    since = "1.0.6",
90    note = "This function is a wrapper for the renamed function `process_spreadsheet_core`"
91)]
92pub async fn render_spreadsheet_core(
93    opts: &OptionSet,
94    save_opt: Option<
95        Box<dyn Fn(IndexMap<String, Value>) -> Result<(), GenericError> + Send + Sync>,
96    >,
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<
108        Box<dyn Fn(IndexMap<String, Value>) -> Result<(), GenericError> + Send + Sync>,
109    >,
110    out_ref: Option<&str>,
111) -> Result<ResultSet, GenericError> {
112    if let Ok(mut workbook) = open_workbook_auto(path_data.path()) {
113        let max_rows = opts.max_rows();
114        let (selected_names, sheet_names, _sheet_indices) =
115            match_sheet_name_and_index(&mut workbook, opts);
116
117        if selected_names.len() > 0 {
118            let info = WorkbookInfo::new(path_data, &selected_names, &sheet_names);
119
120            if opts.multimode() {
121                read_multiple_worksheets(&mut workbook, &sheet_names, opts, &info, max_rows).await
122            } else {
123                let sheet_ref = &selected_names[0];
124                read_single_worksheet(workbook, sheet_ref, opts, &info, save_opt, out_ref).await
125            }
126        } else {
127            Err(GenericError("workbook_with_no_sheets"))
128        }
129    } else {
130        Err(GenericError("cannot_open_workbook"))
131    }
132}
133
134/// Read multiple worksheets from a workbook in preview mode.
135async fn read_multiple_worksheets(
136    workbook: &mut Sheets<BufReader<File>>,
137    sheet_names: &[String],
138    opts: &OptionSet,
139    info: &WorkbookInfo,
140    max_rows: usize,
141) -> Result<ResultSet, GenericError> {
142    let mut sheets: Vec<SheetDataSet> = vec![];
143    let mut sheet_index: usize = 0;
144    let capture_rows = opts.capture_rows();
145    for sheet_ref in sheet_names {
146        let range = workbook.worksheet_range(&sheet_ref.clone())?;
147        let mut headers: Vec<String> = vec![];
148        let mut has_headers = false;
149        let capture_headers = !opts.omit_header;
150        let mut rows: Vec<IndexMap<String, Value>> =
151            Vec::with_capacity(if capture_rows { max_rows } else { 0 });
152        let mut row_index = 0;
153        let header_row_index = opts.header_row_index();
154        let mut col_keys: Vec<String> = vec![];
155        let columns = if sheet_index == 0 {
156            opts.rows.columns.clone()
157        } else {
158            vec![]
159        };
160        let mut resolved_row_opts = opts.rows.clone();
161        let match_header_row_below = capture_headers && header_row_index > 0;
162        if let Some(first_row) = range.headers() {
163            let natural_keys = natural_column_keys(&first_row, &opts.field_mode);
164            let resolved_columns = resolve_columns(&columns, &natural_keys);
165            headers = build_header_keys(&first_row, &resolved_columns, &opts.field_mode);
166            resolved_row_opts.columns = resolved_columns;
167            has_headers = !match_header_row_below;
168            col_keys = first_row;
169        }
170        let total = range.get_size().0;
171        if capture_rows || match_header_row_below {
172            let max_row_count = if capture_rows {
173                max_rows
174            } else {
175                header_row_index + 2
176            };
177            let max_take = if total < max_row_count {
178                total
179            } else {
180                max_row_count + 1
181            };
182            for row in range.rows().take(max_take) {
183                if row_index > max_row_count {
184                    break;
185                }
186                if match_header_row_below && (row_index + 1) == header_row_index {
187                    let h_row = row
188                        .into_iter()
189                        .map(|c| c.to_string().to_snake_case())
190                        .collect::<Vec<String>>();
191                    let natural_keys = natural_column_keys(&h_row, &opts.field_mode);
192                    let resolved_columns = resolve_columns(&columns, &natural_keys);
193                    headers = build_header_keys(&h_row, &resolved_columns, &opts.field_mode);
194                    resolved_row_opts.columns = resolved_columns;
195                    has_headers = true;
196                } else if (has_headers || !capture_headers) && capture_rows {
197                    let raw_values: Vec<String> = row.iter().map(|c| c.to_string()).collect();
198                    if is_not_header_row(&raw_values, row_index, &col_keys) {
199                        let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
200                        rows.push(row_map);
201                    }
202                }
203                row_index += 1;
204            }
205        }
206        sheets.push(SheetDataSet::new(&sheet_ref, &headers, &rows, total));
207        sheet_index += 1;
208    }
209    Ok(ResultSet::from_multiple(&sheets, &info, opts))
210}
211
212/// Read a single worksheet from a workbook in immediate (sync) or asycnhronous modes
213pub async fn read_single_worksheet(
214    mut workbook: Sheets<BufReader<File>>,
215    sheet_ref: &str,
216    opts: &OptionSet,
217    info: &WorkbookInfo,
218    save_opt: Option<
219        Box<dyn Fn(IndexMap<String, Value>) -> Result<(), GenericError> + Send + Sync>,
220    >,
221    out_ref: Option<&str>,
222) -> Result<ResultSet, GenericError> {
223    let range = workbook.worksheet_range(sheet_ref)?;
224    let capture_rows = opts.capture_rows();
225    let columns = opts.rows.columns.clone();
226    let max_rows = opts.max_rows();
227    let mut headers: Vec<String> = vec![];
228    let mut col_keys: Vec<String> = vec![];
229    let mut has_headers = false;
230    let capture_headers = !opts.omit_header;
231    let mut rows: Vec<IndexMap<String, Value>> =
232        Vec::with_capacity(if capture_rows { max_rows } else { 0 });
233    let mut row_index = 0;
234    let header_row_index = opts.header_row_index();
235    let match_header_row_below = capture_headers && header_row_index > 0;
236    let mut resolved_row_opts = opts.rows.clone();
237
238    if let Some(first_row) = range.headers() {
239        let natural_keys = natural_column_keys(&first_row, &opts.field_mode);
240        let resolved_columns = resolve_columns(&columns, &natural_keys);
241        headers = build_header_keys(&first_row, &resolved_columns, &opts.field_mode);
242        resolved_row_opts.columns = resolved_columns;
243        has_headers = !match_header_row_below;
244        col_keys = first_row;
245    }
246    let total = range.get_size().0;
247    if capture_rows || match_header_row_below {
248        let max_row_count = if capture_rows {
249            max_rows
250        } else {
251            header_row_index + 2
252        };
253        let max_take = if total < max_row_count {
254            total
255        } else {
256            max_row_count + 1
257        };
258        for row in range.rows().take(max_take) {
259            if row_index > max_row_count {
260                break;
261            }
262            if match_header_row_below && (row_index + 1) == header_row_index {
263                let h_row = row
264                    .into_iter()
265                    .map(|c| c.to_string().to_snake_case())
266                    .collect::<Vec<String>>();
267                let natural_keys = natural_column_keys(&h_row, &opts.field_mode);
268                let resolved_columns = resolve_columns(&columns, &natural_keys);
269                headers = build_header_keys(&h_row, &resolved_columns, &opts.field_mode);
270                resolved_row_opts.columns = resolved_columns;
271                has_headers = true;
272            } else if (has_headers || !capture_headers) && capture_rows {
273                // only capture rows if headers are either omitted or have already been captured
274                let raw_values: Vec<String> = row.iter().map(|c| c.to_string()).collect();
275                if is_not_header_row(&raw_values, row_index, &col_keys) {
276                    let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
277                    rows.push(row_map);
278                }
279            }
280            row_index += 1;
281        }
282    }
283    if let Some(save_method) = save_opt {
284        let mut row_iter = range.rows();
285        let mut save_count: usize = 0;
286        if let Some(first_row) = row_iter.next() {
287            let raw_values: Vec<String> = first_row.iter().map(|c| c.to_string()).collect();
288            if is_not_header_row(&raw_values, 0, &col_keys) {
289                let first_row_map = workbook_row_to_map(first_row, &resolved_row_opts, &headers);
290                save_method(first_row_map)?;
291                save_count += 1;
292            }
293        }
294        for row in row_iter {
295            if save_count >= max_rows {
296                break;
297            }
298            let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
299            save_method(row_map)?;
300            save_count += 1;
301        }
302    }
303
304    let ds = DataSet::from_count_and_rows(total, rows, opts);
305    Ok(ResultSet::new(info, &headers, ds, opts, out_ref))
306}
307
308/// Process a CSV/TSV file asynchronously with an optional row save method
309/// and output reference (file or database table reference)
310pub async fn read_csv_core<'a>(
311    path_data: &PathData<'a>,
312    opts: &OptionSet,
313    save_opt: Option<
314        Box<dyn Fn(IndexMap<String, Value>) -> Result<(), GenericError> + Send + Sync>,
315    >,
316    out_ref: Option<&str>,
317) -> Result<ResultSet, GenericError> {
318    let separator = match path_data.mode() {
319        Extension::Tsv => b't',
320        _ => b',',
321    };
322    if let Ok(mut rdr) = ReaderBuilder::new()
323        .delimiter(separator)
324        .from_path(path_data.path())
325    {
326        let capture_header = opts.omit_header == false;
327        let capture_rows = opts.capture_rows();
328        let max_line_usize = opts.max_rows();
329        let mut rows: Vec<IndexMap<String, Value>> =
330            Vec::with_capacity(if capture_rows { max_line_usize } else { 0 });
331        let mut line_count = 0;
332
333        let mut headers: Vec<String> = vec![];
334        let mut resolved_row_opts = opts.rows.clone();
335        if capture_header {
336            if let Ok(hdrs) = rdr.headers() {
337                headers = hdrs.into_iter().map(|s| s.to_owned()).collect();
338            }
339            let columns = opts.rows.columns.clone();
340            let natural_keys = natural_column_keys(&headers, &opts.field_mode);
341            let resolved_columns = resolve_columns(&columns, &natural_keys);
342            headers = build_header_keys(&headers, &resolved_columns, &opts.field_mode);
343            resolved_row_opts.columns = resolved_columns;
344        }
345
346        let mut total = 0;
347        let opts_rows_arc = Arc::new(&resolved_row_opts);
348        if capture_rows {
349            for result in rdr.records() {
350                if line_count >= max_line_usize {
351                    break;
352                }
353                if let Some(row) = csv_row_result_to_values(result, opts_rows_arc.clone()) {
354                    rows.push(to_index_map(&row, &headers));
355                    line_count += 1;
356                }
357            }
358            total = line_count + rdr.records().count() + 1;
359        } else if let Some(save_method) = save_opt {
360            for result in rdr.records() {
361                if let Some(row) = csv_row_result_to_values(result, opts_rows_arc.clone()) {
362                    let row_map = to_index_map(&row, &headers);
363                    save_method(row_map)?;
364                    total += 1;
365                }
366            }
367        } else {
368            if let Ok(mut count_rdr) = ReaderBuilder::new().from_path(&path_data.path()) {
369                total = count_rdr.records().count();
370            }
371        }
372        let info = WorkbookInfo::simple(path_data);
373        let ds = DataSet::from_count_and_rows(total, rows, opts);
374        Ok(ResultSet::new(&info, &headers, ds, opts, out_ref))
375    } else {
376        let error_msg = match path_data.ext() {
377            Extension::Tsv => "unreadable_tsv_file",
378            _ => "unreadable_csv_file",
379        };
380        Err(GenericError(error_msg))
381    }
382}
383
384// Convert an array of row data to an IndexMap of serde_json::Value objects
385fn workbook_row_to_map(
386    row: &[Data],
387    opts: &RowOptionSet,
388    headers: &[String],
389) -> IndexMap<String, Value> {
390    to_index_map(&workbook_row_to_values(row, opts), headers)
391}
392
393// Convert an array of row data to a vector of serde_json::Value objects
394fn workbook_row_to_values(row: &[Data], opts: &RowOptionSet) -> Vec<Value> {
395    let opts_arc = Arc::new(opts);
396    let mut c_index = 0;
397    let mut cells: Vec<Value> = vec![];
398    for cell in row {
399        let value = workbook_cell_to_value(cell, opts_arc.clone(), c_index);
400        cells.push(value);
401        c_index += 1;
402    }
403    cells
404}
405
406/// Convert a spreadsheet data cell to a polymorphic serde_json::Value object
407fn workbook_cell_to_value(cell: &Data, opts: Arc<&RowOptionSet>, c_index: usize) -> Value {
408    let col = opts.column(c_index);
409    let format = col.map_or(Format::Auto, |c| c.format.to_owned());
410    let def_val = col.and_then(|c| c.default.clone());
411
412    let date_only = resolve_date_only(&format, opts.date_only);
413
414    match cell {
415        Data::Int(i) => Value::Number(Number::from_i128(*i as i128).unwrap()),
416        Data::Float(f) => process_float_value(*f, format),
417        Data::DateTimeIso(d) => process_iso_datetime_value(d, def_val, date_only),
418        Data::DateTime(d) => process_excel_datetime_value(d, def_val, date_only),
419        Data::Bool(b) => Value::Bool(*b),
420        Data::String(s) => process_string_value(s, format, def_val),
421        Data::Empty => def_val.unwrap_or(Value::Null),
422        _ => Value::String(cell.to_string()),
423    }
424}
425
426/// A column's own Format::Date/Format::DateTime override takes precedence over the
427/// row-wide --date-only default; any other format (including Format::Auto) falls back
428/// to that row-wide default, unchanged from before this override existed.
429fn resolve_date_only(format: &Format, row_date_only: bool) -> bool {
430    match format {
431        Format::Date => true,
432        Format::DateTime => false,
433        _ => row_date_only,
434    }
435}
436
437fn process_float_value(value: f64, format: Format) -> Value {
438    match format {
439        Format::Integer => Value::Number(Number::from_i128(value as i128).unwrap()),
440        Format::Boolean => Value::Bool(value >= 1.0),
441        Format::Text => Value::String(value.to_string()),
442        _ => Value::Number(Number::from_f64(value).unwrap()),
443    }
444}
445
446fn process_excel_datetime_value(
447    datetime: &calamine::ExcelDateTime,
448    def_val: Option<Value>,
449    date_only: bool,
450) -> Value {
451    let dt_ref = datetime.as_datetime().map_or_else(
452        || def_val.unwrap_or(Value::Null),
453        |dt| {
454            let formatted_date = if date_only {
455                dt.format("%Y-%m-%d").to_string()
456            } else {
457                dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
458            };
459            Value::String(formatted_date)
460        },
461    );
462    dt_ref
463}
464
465fn process_iso_datetime_value(dt_str: &str, def_val: Option<Value>, date_only: bool) -> Value {
466    if date_only {
467        iso_fuzzy_to_date_string(dt_str)
468            .map_or_else(|| def_val.unwrap_or(Value::Null), |dt| Value::String(dt))
469    } else {
470        iso_fuzzy_to_datetime_string(dt_str)
471            .map_or_else(|| def_val.unwrap_or(Value::Null), |dt| Value::String(dt))
472    }
473}
474
475fn process_string_value(value: &str, format: Format, def_val: Option<Value>) -> Value {
476    match format {
477        Format::Boolean => process_truthy_value(value, def_val, |v, ef| v.is_truthy_core(ef)),
478        Format::Truthy => process_truthy_value(value, def_val, |v, ef| v.is_truthy_standard(ef)),
479        Format::TruthyCustom(opts) => process_truthy_value(value, def_val, |v, _| {
480            v.is_truthy_custom(&opts)
481        }),
482        Format::Decimal(places) => {
483            process_numeric_value(value, def_val, |n| float_value(n.round_decimal(places)))
484        }
485        Format::Float => process_numeric_value(value, def_val, float_value),
486        Format::Date => process_date_value(value, def_val, iso_fuzzy_to_date_string),
487        Format::DateTime => process_date_value(value, def_val, iso_fuzzy_to_datetime_string),
488        _ => Value::String(value.to_owned()),
489    }
490}
491
492fn process_truthy_value<F>(value: &str, def_val: Option<Value>, truthy_fn: F) -> Value
493where
494    F: Fn(&str, bool) -> Option<bool>,
495{
496    if let Some(is_true) = truthy_fn(value, false) {
497        Value::Bool(is_true)
498    } else {
499        def_val.unwrap_or(Value::Null)
500    }
501}
502
503fn process_numeric_value<F>(value: &str, def_val: Option<Value>, numeric_fn: F) -> Value
504where
505    F: Fn(f64) -> Value,
506{
507    if let Some(n) = value.to_first_number::<f64>() {
508        numeric_fn(n)
509    } else {
510        def_val.unwrap_or(Value::Null)
511    }
512}
513
514fn process_date_value<F>(value: &str, def_val: Option<Value>, date_fn: F) -> Value
515where
516    F: Fn(&str) -> Option<String>,
517{
518    if let Some(date_str) = date_fn(value) {
519        string_value(&date_str)
520    } else {
521        def_val.unwrap_or(Value::Null)
522    }
523}
524
525// Convert csv rows to value
526fn csv_row_result_to_values(
527    result: Result<StringRecord, csv::Error>,
528    opts: Arc<&RowOptionSet>,
529) -> Option<Vec<Value>> {
530    if let Ok(record) = result {
531        let mut row: Vec<Value> = vec![];
532        let mut ci: usize = 0;
533        for cell in record.into_iter() {
534            let new_cell = csv_cell_to_json_value(cell, opts.clone(), ci);
535            row.push(new_cell);
536            ci += 1;
537        }
538        return Some(row);
539    }
540    None
541}
542
543// convert CSV cell &str value to a polymorphic serde_json::VALUE
544fn csv_cell_to_json_value(cell: &str, opts: Arc<&RowOptionSet>, index: usize) -> Value {
545    let has_number = cell.to_first_number::<f64>().is_some();
546    // clean cell to check if it's numeric
547    let col = opts.column(index);
548    let (fmt, euro_num_mode) = if let Some(c) = col {
549        (c.format.clone(), c.decimal_comma)
550    } else {
551        (Format::Auto, opts.decimal_comma)
552    };
553    let num_cell = if has_number {
554        let euro_num_mode = uses_decimal_comma(cell, euro_num_mode);
555        if euro_num_mode {
556            cell.replace(",", ".").replace(",", ".")
557        } else {
558            cell.replace(",", "")
559        }
560    } else {
561        cell.to_owned()
562    };
563    let mut new_cell = Value::Null;
564    if num_cell.len() > 0 && num_cell.is_numeric() {
565        if let Ok(float_val) = serde_json::Number::from_str(&num_cell) {
566            match fmt {
567                Format::Integer => {
568                    // as_i128() only succeeds for Numbers that are already integer-valued
569                    // internally, so it silently yields 0 for any decimal value (e.g. "58.2")
570                    // via unwrap_or(0). Go through as_f64() and truncate instead, matching
571                    // the equivalent xlsx/ods cell conversion in process_float_value above.
572                    if let Some(f) = float_val.as_f64() {
573                        if let Some(int_val) = Number::from_i128(f as i128) {
574                            new_cell = Value::Number(int_val);
575                        }
576                    }
577                }
578                Format::Boolean => {
579                    // only 1.0 or more will evaluate as true
580                    new_cell = Value::Bool(float_val.as_f64().unwrap_or(0f64) >= 1.0);
581                }
582                _ => {
583                    new_cell = Value::Number(float_val);
584                }
585            }
586        }
587    } else if let Some(is_true) = cell.is_truthy_core(false) {
588        new_cell = Value::Bool(is_true);
589    } else {
590        new_cell = match fmt {
591            Format::Truthy => {
592                if let Some(is_true) = cell.is_truthy_standard(false) {
593                    Value::Bool(is_true)
594                } else {
595                    Value::Null
596                }
597            }
598            _ => Value::String(cell.to_string()),
599        };
600    }
601    new_cell
602}
603
604pub async fn read_workbook_sheet_info<'a>(
605    path_data: &PathData<'a>,
606) -> Result<IndexMap<String, usize>, GenericError> {
607    if let Ok(mut workbook) = open_workbook_auto(path_data.path()) {
608        let mut im: IndexMap<String, usize> = IndexMap::new();
609        for name in workbook.sheet_names() {
610            if let Ok(range) = workbook.worksheet_range(&name) {
611                im.insert(name, range.rows().count());
612            }
613        }
614        Ok(im)
615    } else {
616        Err(GenericError("cannot_open_workbook"))
617    }
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use crate::{helpers::*, Column};
624    use serde_json::json;
625    use std::path;
626
627    #[test]
628    fn test_direct_processing_xlsx() {
629        let sample_path = "data/sample-data-1.xlsx";
630
631        // instantiate the OptionSet with a sample path and a maximum row count of 1000 rows as the source file has 401 rows
632        // (although )the default max is 10,000)
633        let opts = OptionSet::new(sample_path).max_row_count(1_000);
634
635        let result = process_spreadsheet_direct(&opts);
636
637        // The source file should have 1 header row and 400 data rows
638        assert_eq!(result.unwrap().num_rows, 401);
639    }
640
641    #[test]
642    fn test_source_key_override_renames_and_reformats_without_position() {
643        // End-to-end: overriding just one field out of many by its natural key,
644        // without needing to enumerate/pad the columns ahead of it.
645        let sample_path = "data/sample-data-1.csv";
646        let mut opts = OptionSet::new(sample_path).max_row_count(2);
647        opts.rows.columns = vec![
648            Column::from_source_key_with_format("weight", Some("weight_lbs"), Format::Integer, None, false, false),
649        ];
650
651        let result = process_spreadsheet_direct(&opts).unwrap();
652        // every other column keeps its natural, auto-detected name
653        assert!(result.keys.contains(&"id".to_string()));
654        assert!(result.keys.contains(&"first_name".to_string()));
655        assert!(result.keys.contains(&"weight_lbs".to_string()));
656        assert!(!result.keys.contains(&"weight".to_string()));
657
658        let rows = result.to_vec();
659        let first = rows.first().expect("at least one row");
660        assert!(first.get("weight_lbs").is_some());
661        assert!(first.get("weight").is_none());
662        assert_eq!(first.get("id").unwrap(), 1);
663    }
664
665    #[test]
666    fn test_resolve_date_only_prefers_column_format_over_row_default() {
667        // A column's own Format::Date/Format::DateTime overrides the row-wide
668        // --date-only default; Format::Auto (and anything else) falls back to it.
669        assert_eq!(resolve_date_only(&Format::Date, false), true);
670        assert_eq!(resolve_date_only(&Format::DateTime, true), false);
671        assert_eq!(resolve_date_only(&Format::Auto, false), false);
672        assert_eq!(resolve_date_only(&Format::Auto, true), true);
673    }
674
675    #[test]
676    fn test_source_key_override_casts_native_datetime_cell_to_date_only() {
677        // Regression test: workbook_cell_to_value computed the column's Format override
678        // but only ever consulted the row-wide --date-only flag for Data::DateTime /
679        // Data::DateTimeIso cells, so a per-column `Format::Date` override on a real
680        // (non-string) datetime cell had no effect at all.
681        let sample_path = "data/sample-data-1.xlsx";
682        let mut opts = OptionSet::new(sample_path).max_row_count(1);
683        opts.rows.columns = vec![
684            Column::from_source_key_with_format("start_time", None, Format::Date, None, false, false),
685        ];
686
687        let result = process_spreadsheet_direct(&opts).unwrap();
688        let rows = result.to_vec();
689        let first = rows.first().expect("at least one row");
690        let start_time = first.get("start_time").expect("start_time column").as_str().unwrap();
691        assert_eq!(start_time, "2023-06-15");
692        assert!(!start_time.contains('T'), "should be date-only, got: {}", start_time);
693    }
694
695    #[test]
696    fn test_csv_cell_integer_format_truncates_decimal_values() {
697        // Regression test: Number::as_i128() only succeeds for already-integer-valued
698        // Numbers, so casting a decimal CSV cell like "58.2" to Format::Integer used to
699        // silently produce 0 via unwrap_or(0) instead of the truncated value.
700        let cols = vec![Column::new_format(Format::Integer, None)];
701        let row_opts = RowOptionSet::simple(&cols);
702        let opts_arc = Arc::new(&row_opts);
703        assert_eq!(csv_cell_to_json_value("58.2", opts_arc.clone(), 0), Value::Number(Number::from(58)));
704        assert_eq!(csv_cell_to_json_value("82.5", opts_arc.clone(), 0), Value::Number(Number::from(82)));
705        assert_eq!(csv_cell_to_json_value("100", opts_arc.clone(), 0), Value::Number(Number::from(100)));
706    }
707
708    #[test]
709    fn test_csv_cell_does_not_coerce_ids_to_booleans() {
710        // Regression test: these previously became `true`/`false` because their
711        // embedded digit run (e.g. "SKU001" -> "001" -> 1) was fuzzily extracted
712        // and matched against is_truthy_core's numeric range, even though the
713        // column has no boolean intent (Format::Auto, the default).
714        let row_opts = RowOptionSet::default();
715        let opts_arc = Arc::new(&row_opts);
716        assert_eq!(csv_cell_to_json_value("SKU001", opts_arc.clone(), 0), Value::String("SKU001".to_string()));
717        assert_eq!(csv_cell_to_json_value("A1", opts_arc.clone(), 0), Value::String("A1".to_string()));
718        assert_eq!(csv_cell_to_json_value("01/06/2024", opts_arc.clone(), 0), Value::String("01/06/2024".to_string()));
719        // literal boolean tokens should still be recognised
720        assert_eq!(csv_cell_to_json_value("true", opts_arc.clone(), 0), Value::Bool(true));
721        assert_eq!(csv_cell_to_json_value("false", opts_arc.clone(), 0), Value::Bool(false));
722    }
723
724    #[test]
725    fn test_direct_processing_csv() {
726        let sample_path = "data/sample-data-1.csv";
727
728        // instantiate the OptionSet with a sample path and a maximum row count of 1000 rows as the source file has 401 rows
729        // (although )the default max is 10,000)
730        let opts = OptionSet::new(sample_path).max_row_count(1_000);
731
732        let result = process_spreadsheet_direct(&opts);
733
734        // The source file should have 1 header row and 400 data rows
735        assert_eq!(result.unwrap().num_rows, 401);
736    }
737
738    #[test]
739    fn test_multisheet_preview_ods() {
740        let sample_path = "data/sample-data-2.ods";
741
742        // instantiate the OptionSet with a sample path
743        // a maximum row count returned of 10 rows
744        // and read mode to *preview* to scan all sheets
745        // It should correctly calculate
746        let opts = OptionSet::new(sample_path)
747            .max_row_count(10)
748            .read_mode_preview();
749
750        let result = process_spreadsheet_direct(&opts);
751
752        // The source spreadsheet should have 2 sheets
753        let dataset = result.unwrap();
754        assert_eq!(dataset.sheets.len(), 2);
755        // The source spreadsheet should have 101 + 17 (= 118) populated rows including headers
756        assert_eq!(dataset.num_rows, 118);
757
758        // The first sheet's data should only output 10 rows (including the header)
759        assert_eq!(dataset.data.first_sheet().len(), 10);
760    }
761
762    #[test]
763    fn test_column_override_1() {
764        let sample_json = json!({
765          "sku": "CHAIR16",
766          "height": "112cm",
767          "width": "69cm",
768          "approved": "Y"
769        });
770
771        let rows = json_object_to_calamine_data(sample_json);
772
773        let cols = vec![
774            Column::new_format(Format::Text, Some(string_value(""))),
775            Column::new_format(Format::Float, Some(float_value(95.0))),
776            Column::new_format(Format::Float, Some(float_value(65.0))),
777            Column::new_format(Format::Truthy, Some(bool_value(false))),
778        ];
779
780        // The first sheet's data should only output 10 rows (including the header)
781        let opts = &RowOptionSet::simple(&cols);
782        let result = workbook_row_to_values(&rows, opts);
783        // the second column be cast to 112.0
784        assert_eq!(result.get(1).unwrap(), 112.0);
785        // the third column be cast to 69.0
786        assert_eq!(result.get(2).unwrap(), 69.0);
787        // the fourth column be cast to boolean
788        assert_eq!(result.get(3).unwrap(), true);
789    }
790
791    #[test]
792    fn test_column_override_2() {
793        let sample_json = json!({
794          "name": "Sophia",
795          "dob": "2001-9-23",
796          "weight": "62kg",
797          "result": "GOOD"
798        });
799
800        let rows = json_object_to_calamine_data(sample_json);
801
802        let cols = vec![
803            Column::new_format(Format::Text, None),
804            Column::new_format(Format::Date, None),
805            Column::new_format(Format::Float, None),
806            // the fourth column be cast to boolean
807            Column::new_format(
808                Format::truthy_custom("good", "bad"),
809                Some(bool_value(false)),
810            ),
811        ];
812
813        // The first sheet's data should only output 10 rows (including the header)
814        let opts = &RowOptionSet::simple(&cols);
815        let result = workbook_row_to_values(&rows, opts);
816        assert_eq!(result.get(1).unwrap(), "2001-09-23");
817        assert_eq!(result.get(2).unwrap(), 62.0);
818        assert_eq!(result.get(3).unwrap(), true);
819    }
820
821    #[tokio::test]
822    async fn test_read_workbook_info() {
823        let sample_path = "data/sample-data-1.xlsx";
824        let path_data = PathData::new(path::Path::new(sample_path));
825        let info = read_workbook_sheet_info(&path_data).await;
826        assert!(info.is_ok());
827    }
828
829    #[tokio::test]
830    async fn test_large_csv_file() {
831        let sample_path = "data/large-datasheet.csv";
832        let max_rows = 100_000;
833        let opts = OptionSet::new(sample_path).max_row_count(max_rows);
834        let result = process_spreadsheet_core(&opts, None, None).await;
835        if let Ok(data) = result.clone() {
836            assert_eq!(data.data.first_sheet().len(), max_rows as usize);
837        } else {
838            panic!("Failed to process large CSV file");
839        }
840        assert!(result.is_ok());
841    }
842
843    #[tokio::test]
844    async fn test_medium_excel_file() {
845        let sample_path = "data/medium-spreadsheet-50_000.xlsx";
846        let max_rows = 5_000;
847        let opts = OptionSet::new(sample_path).max_row_count(max_rows);
848        let result = process_spreadsheet_core(&opts, None, None).await;
849        if let Ok(data) = result.clone() {
850            assert_eq!(data.data.first_sheet().len(), max_rows as usize);
851        } else {
852            panic!("Failed to process large Excel file");
853        }
854        assert!(result.is_ok());
855    }
856}