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
28pub type SaveRowFn = Box<dyn Fn(IndexMap<String, Value>) -> Result<(), GenericError> + Send + Sync>;
30
31pub 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
38pub 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
53pub 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
62pub 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
102pub 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
132async 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
230pub 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 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 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 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 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
357pub 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 .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 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 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 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 += 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
484fn 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
493fn 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
501fn 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
522fn 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 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 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
605fn 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
619fn 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
690fn 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
706fn csv_cell_to_json_value(cell: &str, opts: &RowOptionSet, index: usize) -> Value {
708 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let opts = OptionSet::new(sample_path).max_row_count(1_000);
1049
1050 let result = process_spreadsheet_direct(&opts);
1051
1052 assert_eq!(result.unwrap().num_rows, 401);
1054 }
1055
1056 #[test]
1057 fn test_source_key_override_renames_and_reformats_without_position() {
1058 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let opts = OptionSet::new(sample_path).max_row_count(1_000);
1332
1333 let result = process_spreadsheet_direct(&opts);
1334
1335 assert_eq!(result.unwrap().num_rows, 401);
1337 }
1338
1339 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 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 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 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 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 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 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 let dataset = result.unwrap();
1424 assert_eq!(dataset.sheets.len(), 2);
1425 assert_eq!(dataset.num_rows, 118);
1427
1428 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 let opts = &RowOptionSet::simple(&cols);
1452 let result = workbook_row_to_values(&rows, opts);
1453 assert_eq!(result.get(1).unwrap(), 112.0);
1455 assert_eq!(result.get(2).unwrap(), 69.0);
1457 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 Column::new_format(
1478 Format::truthy_custom("good", "bad"),
1479 Some(bool_value(false)),
1480 ),
1481 ];
1482
1483 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}