1use calamine::{open_workbook_auto, Data, Reader, Sheets};
2use csv::{ReaderBuilder, StringRecord};
3use heck::ToSnakeCase;
4use indexmap::IndexMap;
5use serde_json::{Number, Value};
6use std::fs::File;
7use std::io::BufReader;
8use std::path::Path;
9use std::str::FromStr;
10
11use crate::data_set::*;
12use crate::detect::{resolve_header_and_data_rows, DETECT_SAMPLE_SIZE};
13use crate::error::GenericError;
14use alphanumeric::*;
15use simple_string_patterns::SimpleMatch;
16use crate::headers::*;
17use crate::helpers::float_value;
18use crate::helpers::string_value;
19use is_truthy::*;
20use crate::round_decimal::RoundDecimal;
21use crate::DateTimeMode;
22use crate::Extension;
23use to_segments::ToSegments;
24use crate::Format;
25use crate::OptionSet;
26use crate::PathData;
27use crate::RowOptionSet;
28use fuzzy_datetime::{fuzzy_to_date_string, fuzzy_to_datetime_string_opts, iso_fuzzy_to_date_string, iso_fuzzy_to_datetime_string};
29
30pub type SaveRowFn = Box<dyn Fn(IndexMap<String, Value>) -> Result<(), GenericError> + Send + Sync>;
32
33pub fn process_spreadsheet_direct(opts: &OptionSet) -> Result<ResultSet, GenericError> {
36 let rt = tokio::runtime::Runtime::new().unwrap();
37 rt.block_on(process_spreadsheet_core(opts, None, None))
38}
39
40pub async fn process_spreadsheet_immediate(opts: &OptionSet) -> Result<ResultSet, GenericError> {
44 process_spreadsheet_core(opts, None, None).await
45}
46
47#[deprecated(
48 since = "1.0.6",
49 note = "This function is a wrapper for the renamed function `process_spreadsheet_inline`"
50)]
51pub async fn render_spreadsheet_direct(opts: &OptionSet) -> Result<ResultSet, GenericError> {
52 process_spreadsheet_core(opts, None, None).await
53}
54
55pub async fn process_spreadsheet_async(
57 opts: &OptionSet,
58 save_func: SaveRowFn,
59 out_ref: Option<&str>,
60) -> Result<ResultSet, GenericError> {
61 process_spreadsheet_core(opts, Some(save_func), out_ref).await
62}
63
64pub async fn process_spreadsheet_core(
67 opts: &OptionSet,
68 save_opt: Option<SaveRowFn>,
69 out_ref: Option<&str>,
70) -> Result<ResultSet, GenericError> {
71 if let Some(filepath) = opts.path.clone() {
72 let path = Path::new(&filepath);
73 if !path.exists() {
74 #[allow(dead_code)]
75 return Err(GenericError("file_unavailable"));
76 }
77 let path_data = PathData::new(path);
78 if path_data.is_valid() {
79 if path_data.use_calamine() {
80 read_workbook_core(&path_data, opts, save_opt, out_ref).await
81 } else {
82 read_csv_core(&path_data, opts, save_opt, out_ref).await
83 }
84 } else {
85 Err(GenericError("unsupported_format"))
86 }
87 } else {
88 Err(GenericError("no_filepath_specified"))
89 }
90}
91
92#[deprecated(
93 since = "1.0.6",
94 note = "This function is a wrapper for the renamed function `process_spreadsheet_core`"
95)]
96pub async fn render_spreadsheet_core(
97 opts: &OptionSet,
98 save_opt: Option<SaveRowFn>,
99 out_ref: Option<&str>,
100) -> Result<ResultSet, GenericError> {
101 process_spreadsheet_core(opts, save_opt, out_ref).await
102}
103
104pub async fn read_workbook_core<'a>(
107 path_data: &PathData<'a>,
108 opts: &OptionSet,
109 save_opt: Option<SaveRowFn>,
110 out_ref: Option<&str>,
111) -> Result<ResultSet, GenericError> {
112 if let Ok(mut workbook) = open_workbook_auto(path_data.path()) {
113 let max_rows = opts.max_rows();
114 let (selected_names, sheet_names, _sheet_indices) =
115 match_sheet_name_and_index(&mut workbook, opts);
116
117 if !selected_names.is_empty() {
118 let info = WorkbookInfo::new(path_data, &selected_names, &sheet_names);
119
120 if opts.multimode() {
121 read_multiple_worksheets(&mut workbook, &sheet_names, opts, &info, max_rows).await
122 } else {
123 let sheet_ref = &selected_names[0];
124 read_single_worksheet(workbook, sheet_ref, opts, &info, save_opt, out_ref).await
125 }
126 } else {
127 Err(GenericError("workbook_with_no_sheets"))
128 }
129 } else {
130 Err(GenericError("cannot_open_workbook"))
131 }
132}
133
134async fn read_multiple_worksheets(
136 workbook: &mut Sheets<BufReader<File>>,
137 sheet_names: &[String],
138 opts: &OptionSet,
139 info: &WorkbookInfo,
140 max_rows: usize,
141) -> Result<ResultSet, GenericError> {
142 let mut sheets: Vec<SheetDataSet> = vec![];
143 let capture_rows = opts.capture_rows();
144 for (sheet_index, sheet_ref) in sheet_names.iter().enumerate() {
145 let range = workbook.worksheet_range(&sheet_ref.clone())?;
146 let mut headers: Vec<String> = vec![];
147 let mut has_headers = false;
148 let mut rows: Vec<IndexMap<String, Value>> =
149 Vec::with_capacity(if capture_rows { max_rows } else { 0 });
150 let mut row_index = 0;
151 let detected = resolve_header_and_data_rows(opts, || {
152 range.rows().take(DETECT_SAMPLE_SIZE)
153 .map(|row| row.iter().map(|c| c.to_string()).collect())
154 .collect()
155 });
156 let first_data_row_index = detected.data_index;
157 let capture_headers = detected.header_index.is_some();
158 let header_row_index = detected.header_index.unwrap_or(0);
159 let mut col_keys: Vec<String> = vec![];
160 let columns = if sheet_index == 0 {
161 opts.rows.columns.clone()
162 } else {
163 vec![]
164 };
165 let mut resolved_row_opts = opts.rows.clone();
166 let match_header_row_below = capture_headers && header_row_index > 0;
167 if capture_headers {
168 if let Some(first_row) = range.headers() {
169 let natural_keys = natural_column_keys(&first_row, &opts.field_mode);
170 let resolved_columns = resolve_columns(&columns, &natural_keys);
171 headers = build_header_keys(&first_row, &resolved_columns, &opts.field_mode);
172 resolved_row_opts.columns = resolved_columns;
173 has_headers = !match_header_row_below;
174 col_keys = first_row.to_vec();
175 }
176 } else {
177 let num_cols = range.get_size().1;
178 let blank = vec![String::new(); num_cols];
179 let natural_keys = natural_column_keys(&blank, &opts.field_mode);
180 let resolved_columns = resolve_columns(&columns, &natural_keys);
181 headers = build_header_keys(&blank, &resolved_columns, &opts.field_mode.forced_fallback());
182 resolved_row_opts.columns = resolved_columns;
183 has_headers = true;
184 }
185 let total = range.get_size().0;
186 if capture_rows || match_header_row_below {
187 let max_row_count = if capture_rows {
188 max_rows
189 } else {
190 header_row_index + 2
191 };
192 let max_take = if total < max_row_count {
193 total
194 } else {
195 max_row_count + 1
196 };
197 for row in range.rows().take(max_take) {
198 if row_index > max_row_count {
199 break;
200 }
201 if match_header_row_below && row_index == header_row_index {
202 let h_row = row
203 .iter()
204 .map(|c| c.to_string().to_snake_case())
205 .collect::<Vec<String>>();
206 let natural_keys = natural_column_keys(&h_row, &opts.field_mode);
207 let resolved_columns = resolve_columns(&columns, &natural_keys);
208 headers = build_header_keys(&h_row, &resolved_columns, &opts.field_mode);
209 resolved_row_opts.columns = resolved_columns;
210 has_headers = true;
211 } else if (has_headers || !capture_headers) && capture_rows
212 && row_index >= first_data_row_index {
213 let is_real_data = if capture_headers {
214 let raw_values: Vec<String> = row.iter().map(|c| c.to_string()).collect();
215 is_not_header_row(&raw_values, row_index, &col_keys)
216 } else {
217 true
218 };
219 if is_real_data {
220 let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
221 rows.push(row_map);
222 }
223 }
224 row_index += 1;
225 }
226 }
227 sheets.push(SheetDataSet::new(sheet_ref, &headers, &rows, total));
228 }
229 Ok(ResultSet::from_multiple(&sheets, info, opts))
230}
231
232pub async fn read_single_worksheet(
234 mut workbook: Sheets<BufReader<File>>,
235 sheet_ref: &str,
236 opts: &OptionSet,
237 info: &WorkbookInfo,
238 save_opt: Option<SaveRowFn>,
239 out_ref: Option<&str>,
240) -> Result<ResultSet, GenericError> {
241 let range = workbook.worksheet_range(sheet_ref)?;
242 let capture_rows = opts.capture_rows();
243 let columns = opts.rows.columns.clone();
244 let max_rows = opts.max_rows();
245 let mut headers: Vec<String> = vec![];
246 let mut col_keys: Vec<String> = vec![];
247 let mut has_headers = false;
248 let mut rows: Vec<IndexMap<String, Value>> =
249 Vec::with_capacity(if capture_rows { max_rows } else { 0 });
250 let mut row_index = 0;
251 let detected = resolve_header_and_data_rows(opts, || {
252 range.rows().take(DETECT_SAMPLE_SIZE)
253 .map(|row| row.iter().map(|c| c.to_string()).collect())
254 .collect()
255 });
256 let first_data_row_index = detected.data_index;
257 let capture_headers = detected.header_index.is_some();
261 let header_row_index = detected.header_index.unwrap_or(0);
262 let header_row_span = opts.effective_header_row_span();
263 let match_header_row_below = capture_headers && header_row_index > 0 && header_row_span <= 1;
267 let mut resolved_row_opts = opts.rows.clone();
268
269 if capture_headers && header_row_span > 1 {
270 let raw_header_rows: Vec<Vec<String>> = range
275 .rows()
276 .skip(header_row_index)
277 .take(header_row_span)
278 .map(|row| row.iter().map(|c| c.to_string()).collect())
279 .collect();
280 let combined = combine_header_rows(&raw_header_rows);
281 let natural_keys = natural_column_keys(&combined, &opts.field_mode);
282 let resolved_columns = resolve_columns(&columns, &natural_keys);
283 headers = build_header_keys(&combined, &resolved_columns, &opts.field_mode);
284 resolved_row_opts.columns = resolved_columns;
285 has_headers = true;
286 col_keys = combined;
287 } else if capture_headers {
288 if let Some(first_row) = range.headers() {
289 let natural_keys = natural_column_keys(&first_row, &opts.field_mode);
290 let resolved_columns = resolve_columns(&columns, &natural_keys);
291 headers = build_header_keys(&first_row, &resolved_columns, &opts.field_mode);
292 resolved_row_opts.columns = resolved_columns;
293 has_headers = !match_header_row_below;
294 col_keys = first_row.to_vec();
295 }
296 } else {
297 let num_cols = range.get_size().1;
298 let blank = vec![String::new(); num_cols];
299 let natural_keys = natural_column_keys(&blank, &opts.field_mode);
300 let resolved_columns = resolve_columns(&columns, &natural_keys);
301 headers = build_header_keys(&blank, &resolved_columns, &opts.field_mode.forced_fallback());
302 resolved_row_opts.columns = resolved_columns;
303 has_headers = true;
304 }
305 let total = range.get_size().0;
306 if capture_rows || match_header_row_below {
307 let max_row_count = if capture_rows {
308 max_rows
309 } else {
310 header_row_index + 2
311 };
312 let max_take = if total < max_row_count {
313 total
314 } else {
315 max_row_count + 1
316 };
317 for row in range.rows().take(max_take) {
318 if row_index > max_row_count {
319 break;
320 }
321 if match_header_row_below && row_index == header_row_index {
322 let h_row = row
323 .iter()
324 .map(|c| c.to_string().to_snake_case())
325 .collect::<Vec<String>>();
326 let natural_keys = natural_column_keys(&h_row, &opts.field_mode);
327 let resolved_columns = resolve_columns(&columns, &natural_keys);
328 headers = build_header_keys(&h_row, &resolved_columns, &opts.field_mode);
329 resolved_row_opts.columns = resolved_columns;
330 has_headers = true;
331 } else if (has_headers || !capture_headers) && capture_rows
332 && row_index >= first_data_row_index {
333 let is_real_data = if capture_headers {
335 let raw_values: Vec<String> = row.iter().map(|c| c.to_string()).collect();
336 is_not_header_row(&raw_values, row_index, &col_keys)
337 } else {
338 true
341 };
342 if is_real_data {
343 let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
344 rows.push(row_map);
345 }
346 }
347 row_index += 1;
348 }
349 }
350 if let Some(save_method) = save_opt {
351 let mut save_count: usize = 0;
356 for (idx, row) in range.rows().enumerate() {
357 if save_count >= max_rows {
358 break;
359 }
360 if idx < first_data_row_index {
361 continue;
362 }
363 let is_real_data = if capture_headers {
364 let raw_values: Vec<String> = row.iter().map(|c| c.to_string()).collect();
365 is_not_header_row(&raw_values, idx, &col_keys)
366 } else {
367 true
368 };
369 if is_real_data {
370 let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
371 save_method(row_map)?;
372 save_count += 1;
373 }
374 }
375 }
376
377 let ds = DataSet::from_count_and_rows(total, rows, opts);
378 Ok(ResultSet::new(info, &headers, ds, opts, out_ref, detected.header_index, first_data_row_index))
379}
380
381pub async fn read_csv_core<'a>(
391 path_data: &PathData<'a>,
392 opts: &OptionSet,
393 save_opt: Option<SaveRowFn>,
394 out_ref: Option<&str>,
395) -> Result<ResultSet, GenericError> {
396 let separator = match path_data.mode() {
397 Extension::Tsv => b't',
398 _ => b',',
399 };
400 if let Ok(mut rdr) = ReaderBuilder::new()
401 .delimiter(separator)
402 .has_headers(false)
403 .flexible(true)
408 .from_path(path_data.path())
409 {
410 let capture_rows = opts.capture_rows();
411 let max_line_usize = opts.max_rows();
412 let detected = resolve_header_and_data_rows(opts, || {
416 let mut sample_rows = Vec::new();
417 if let Ok(mut sample_rdr) = ReaderBuilder::new()
418 .delimiter(separator)
419 .has_headers(false)
420 .flexible(true)
421 .from_path(path_data.path())
422 {
423 for record in sample_rdr.records().take(DETECT_SAMPLE_SIZE).flatten() {
424 sample_rows.push(record.iter().map(|s| s.to_string()).collect());
425 }
426 }
427 sample_rows
428 });
429 let first_data_row_index = detected.data_index;
430 let capture_header = detected.header_index.is_some();
434 let header_row_index = detected.header_index.unwrap_or(0);
435 let header_row_span = opts.effective_header_row_span();
436
437 let mut rows: Vec<IndexMap<String, Value>> =
438 Vec::with_capacity(if capture_rows { max_line_usize } else { 0 });
439 let mut headers: Vec<String> = vec![];
440 let mut resolved_row_opts = opts.rows.clone();
441 let mut header_row_buffer: Vec<Vec<String>> = Vec::new();
442 let mut fallback_keys_built = false;
445
446 let mut total: usize = 0;
447 let mut line_count: usize = 0;
448 let mut row_index: usize = 0;
449
450 for result in rdr.records() {
451 let Ok(record) = result else {
452 row_index += 1;
453 continue;
454 };
455 total += 1;
459
460 if capture_header && header_row_span > 1 && row_index >= header_row_index && row_index < header_row_index + header_row_span {
461 let raw: Vec<String> = record.iter().map(|s| s.to_string()).collect();
462 header_row_buffer.push(raw);
463 if row_index == header_row_index + header_row_span - 1 {
464 let combined = combine_header_rows(&header_row_buffer);
465 let natural_keys = natural_column_keys(&combined, &opts.field_mode);
466 let resolved_columns = resolve_columns(&opts.rows.columns, &natural_keys);
467 headers = build_header_keys(&combined, &resolved_columns, &opts.field_mode);
468 resolved_row_opts.columns = resolved_columns;
469 }
470 row_index += 1;
471 continue;
472 }
473
474 if capture_header && header_row_span <= 1 && row_index == header_row_index {
475 let raw: Vec<String> = record.iter().map(|s| s.to_string()).collect();
476 let natural_keys = natural_column_keys(&raw, &opts.field_mode);
477 let resolved_columns = resolve_columns(&opts.rows.columns, &natural_keys);
478 headers = build_header_keys(&raw, &resolved_columns, &opts.field_mode);
479 resolved_row_opts.columns = resolved_columns;
480 row_index += 1;
481 continue;
482 }
483
484 if row_index < first_data_row_index {
485 row_index += 1;
486 continue;
487 }
488
489 if !capture_header && !fallback_keys_built {
490 let blank: Vec<String> = record.iter().map(|_| String::new()).collect();
491 let resolved_columns = resolve_columns(&opts.rows.columns, &natural_column_keys(&blank, &opts.field_mode));
492 headers = build_header_keys(&blank, &resolved_columns, &opts.field_mode.forced_fallback());
493 resolved_row_opts.columns = resolved_columns;
494 fallback_keys_built = true;
495 }
496
497 if capture_rows {
498 if line_count < max_line_usize {
499 if let Some(row) = csv_row_result_to_values(Ok(record), &resolved_row_opts) {
500 let mut row_map = to_index_map(&row, &headers, Some(&resolved_row_opts.columns));
501 if resolved_row_opts.omit_null_values {
502 omit_null_values(&mut row_map);
503 }
504 rows.push(row_map);
505 line_count += 1;
506 }
507 }
508 } else if let Some(save_method) = save_opt.as_ref() {
509 if let Some(row) = csv_row_result_to_values(Ok(record), &resolved_row_opts) {
510 let mut row_map = to_index_map(&row, &headers, Some(&resolved_row_opts.columns));
511 if resolved_row_opts.omit_null_values {
512 omit_null_values(&mut row_map);
513 }
514 save_method(row_map)?;
515 }
516 }
517 row_index += 1;
518 }
519 let info = WorkbookInfo::simple(path_data);
520 let ds = DataSet::from_count_and_rows(total, rows, opts);
521 Ok(ResultSet::new(&info, &headers, ds, opts, out_ref, detected.header_index, first_data_row_index))
522 } else {
523 let error_msg = match path_data.ext() {
524 Extension::Tsv => "unreadable_tsv_file",
525 _ => "unreadable_csv_file",
526 };
527 Err(GenericError(error_msg))
528 }
529}
530
531fn workbook_row_to_map(
533 row: &[Data],
534 opts: &RowOptionSet,
535 headers: &[String],
536) -> IndexMap<String, Value> {
537 let mut row_map = to_index_map(&workbook_row_to_values(row, opts), headers, Some(&opts.columns));
538 if opts.omit_null_values {
539 omit_null_values(&mut row_map);
540 }
541 row_map
542}
543
544fn workbook_row_to_values(row: &[Data], opts: &RowOptionSet) -> Vec<Value> {
546 row.iter()
547 .enumerate()
548 .map(|(c_index, cell)| workbook_cell_to_value(cell, opts, c_index))
549 .collect()
550}
551
552fn workbook_cell_to_value(cell: &Data, opts: &RowOptionSet, c_index: usize) -> Value {
554 let col = opts.column(c_index);
555 let format = col.map_or(Format::Auto, |c| c.format.to_owned());
556 let def_val = col.and_then(|c| c.default.clone());
557 let col_mode = col.map_or(DateTimeMode::Full, |c| c.datetime_mode);
558
559 let mode = resolve_datetime_mode(&format, col_mode, opts.datetime_mode);
560
561 match cell {
562 Data::Int(i) => Value::Number(Number::from_i128(*i as i128).unwrap()),
563 Data::Float(f) => process_float_value(*f, format, def_val),
564 Data::DateTimeIso(d) => process_iso_datetime_value(d, def_val, mode),
565 Data::DateTime(d) => process_excel_datetime_value(d, def_val, mode),
566 Data::Bool(b) => Value::Bool(*b),
567 Data::String(s) => process_string_value(s, format, def_val),
568 Data::Empty => def_val.unwrap_or(Value::Null),
569 _ => Value::String(cell.to_string()),
570 }
571}
572
573fn resolve_datetime_mode(format: &Format, col_mode: DateTimeMode, row_mode: DateTimeMode) -> DateTimeMode {
581 match format {
582 Format::Date => DateTimeMode::DateOnly,
583 Format::Time => DateTimeMode::TimeOnly,
584 Format::Hm => DateTimeMode::HmOnly,
585 Format::DateTime => DateTimeMode::Full,
586 Format::DateTimeSimple => DateTimeMode::Simple,
587 _ if col_mode != DateTimeMode::Full => col_mode,
588 _ => row_mode,
589 }
590}
591
592fn process_float_value(value: f64, format: Format, def_val: Option<Value>) -> Value {
593 match format {
594 Format::Integer => Value::Number(Number::from_i128(value as i128).unwrap()),
595 Format::Boolean => Value::Bool(value >= 1.0),
596 Format::Text => Value::String(value.to_string()),
597 Format::Decimal(places) => float_value(value.round_decimal(places)),
598 Format::Time | Format::Hm => decimal_to_hm_string(value)
599 .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
600 _ => Value::Number(Number::from_f64(value).unwrap()),
601 }
602}
603
604fn process_excel_datetime_value(
605 datetime: &calamine::ExcelDateTime,
606 def_val: Option<Value>,
607 mode: DateTimeMode,
608) -> Value {
609 let auto_time_only = matches!(mode, DateTimeMode::Full | DateTimeMode::Simple) && datetime.as_f64() < 1.0;
618 datetime.as_datetime().map_or_else(
619 || def_val.unwrap_or(Value::Null),
620 |dt| {
621 let formatted_date = match mode {
628 DateTimeMode::DateOnly => dt.format("%Y-%m-%d").to_string(),
629 DateTimeMode::TimeOnly => dt.format("%H:%M:%S").to_string(),
630 DateTimeMode::HmOnly => dt.format("%H:%M").to_string(),
631 DateTimeMode::Simple if auto_time_only => dt.format("%H:%M:%S").to_string(),
632 DateTimeMode::Simple => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
633 DateTimeMode::Full if auto_time_only => dt.format("%H:%M:%S").to_string(),
634 DateTimeMode::Full => dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
635 };
636 Value::String(formatted_date)
637 },
638 )
639}
640
641fn process_iso_datetime_value(dt_str: &str, def_val: Option<Value>, mode: DateTimeMode) -> Value {
642 match mode {
643 DateTimeMode::DateOnly => iso_fuzzy_to_date_string(dt_str)
644 .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
645 DateTimeMode::TimeOnly => iso_fuzzy_to_datetime_string(dt_str)
646 .and_then(|full| extract_time_portion(&full, false))
647 .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
648 DateTimeMode::HmOnly => iso_fuzzy_to_datetime_string(dt_str)
649 .and_then(|full| extract_time_portion(&full, true))
650 .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
651 DateTimeMode::Simple => iso_fuzzy_to_datetime_string(dt_str)
652 .map(|full| simplify_datetime_string(&full))
653 .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
654 DateTimeMode::Full => iso_fuzzy_to_datetime_string(dt_str)
655 .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
656 }
657}
658
659fn extract_time_portion(full_datetime_str: &str, hm_only: bool) -> Option<String> {
667 let time_part = full_datetime_str.split('T').nth(1)?;
668 let time_part = time_part.trim_end_matches('Z');
669 let len = if hm_only { 5 } else { 8 };
670 time_part.get(0..len).map(str::to_string)
671}
672
673fn simplify_datetime_string(full_datetime_str: &str) -> String {
678 let trimmed = full_datetime_str.trim_end_matches('Z');
679 match trimmed.split_once('.') {
680 Some((base, _)) => base.to_string(),
681 None => trimmed.to_string(),
682 }
683}
684
685fn guess_date_string(value: &str) -> Option<String> {
693 fuzzy_to_date_string(value, None)
694}
695
696fn guess_datetime_string(value: &str) -> Option<String> {
701 fuzzy_to_datetime_string_opts(value, 'T', None, Some(':'), true)
702}
703
704fn parse_bare_time_string(value: &str) -> Option<String> {
744 let has_am_suffix = value.ends_with_ci("am");
745 let has_pm_suffix = !has_am_suffix && value.ends_with_ci("pm");
746 let is_12_hour = has_am_suffix || has_pm_suffix;
747
748 if !is_12_hour && value.has_alphabetic() {
749 return None;
750 }
751
752 let numeric_part = if is_12_hour {
756 value[..value.len() - 2].trim_end()
757 } else {
758 value
759 };
760
761 let numbers: Vec<u8> = numeric_part.replace('.', ":").to_numbers();
762
763 let resolve_hour = |hrs: u8| -> u8 {
764 if !is_12_hour || !(1..=12).contains(&hrs) {
765 return hrs;
769 }
770 match (has_am_suffix, hrs) {
771 (true, 12) => 0, (true, _) => hrs, (false, 12) => 12, (false, _) => hrs + 12, }
776 };
777
778 match numbers.as_slice() {
779 [hrs, mins] if *mins < 60 => Some(format!("{:02}:{:02}", resolve_hour(*hrs), mins)),
780 [hrs, mins, secs] if *mins < 60 && *secs < 60 => {
781 Some(format!("{:02}:{:02}:{:02}", resolve_hour(*hrs), mins, secs))
782 }
783 _ => None,
784 }
785}
786
787fn decimal_to_hm_string(value: f64) -> Option<String> {
810 if value < 0.0 {
811 return None;
812 }
813 let hours = value.trunc() as u8;
814 let frac_digits = format!("{:.2}", value.fract().abs());
815 let minutes: u32 = frac_digits.split('.').nth(1)?.parse().ok()?;
816 if minutes >= 60 {
817 return None;
818 }
819 Some(format!("{:02}:{:02}", hours, minutes))
820}
821
822fn process_string_value(value: &str, format: Format, def_val: Option<Value>) -> Value {
823 match format {
824 Format::Boolean => process_truthy_value(value, def_val, |v, ef| v.is_truthy_core(ef)),
825 Format::Truthy => process_truthy_value(value, def_val, |v, ef| v.is_truthy_standard(ef)),
826 Format::TruthyCustom(opts) => process_truthy_value(value, def_val, |v, _| {
827 v.is_truthy_custom(&opts)
828 }),
829 Format::Decimal(places) => {
830 process_numeric_value(value, def_val, |n| float_value(n.round_decimal(places)))
831 }
832 Format::Float => process_numeric_value(value, def_val, float_value),
833 Format::Integer => process_numeric_value(value, def_val, |n| {
840 Number::from_i128(n as i128).map(Value::Number).unwrap_or(Value::Null)
841 }),
842 Format::Date => process_date_value(value, def_val, guess_date_string),
843 Format::DateTime => process_date_value(value, def_val, guess_datetime_string),
844 Format::DateTimeSimple => process_date_value(value, def_val, |s| {
845 guess_datetime_string(s).map(|full| simplify_datetime_string(&full))
846 }),
847 Format::Time => process_date_value(value, def_val, |s| {
848 guess_datetime_string(s)
849 .and_then(|full| extract_time_portion(&full, false))
850 .or_else(|| parse_bare_time_string(s))
851 }),
852 Format::Hm => process_date_value(value, def_val, |s| {
853 guess_datetime_string(s)
854 .and_then(|full| extract_time_portion(&full, true))
855 .or_else(|| parse_bare_time_string(s))
856 }),
857 Format::Array(element_format, separator) => process_array_value(value, &element_format, &separator),
858 _ => Value::String(value.to_owned()),
859 }
860}
861
862fn process_truthy_value<F>(value: &str, def_val: Option<Value>, truthy_fn: F) -> Value
863where
864 F: Fn(&str, bool) -> Option<bool>,
865{
866 if let Some(is_true) = truthy_fn(value, false) {
867 Value::Bool(is_true)
868 } else {
869 def_val.unwrap_or(Value::Null)
870 }
871}
872
873fn process_numeric_value<F>(value: &str, def_val: Option<Value>, numeric_fn: F) -> Value
874where
875 F: Fn(f64) -> Value,
876{
877 if let Some(n) = value.to_first_number::<f64>() {
878 numeric_fn(n)
879 } else {
880 def_val.unwrap_or(Value::Null)
881 }
882}
883
884fn process_date_value<F>(value: &str, def_val: Option<Value>, date_fn: F) -> Value
885where
886 F: Fn(&str) -> Option<String>,
887{
888 if let Some(date_str) = date_fn(value) {
889 string_value(&date_str)
890 } else {
891 def_val.unwrap_or(Value::Null)
892 }
893}
894
895fn process_array_value(value: &str, element_format: &Format, separator: &str) -> Value {
902 Value::Array(
903 value
904 .to_segments(separator)
905 .iter()
906 .map(|segment| process_string_value(segment.trim(), element_format.clone(), None))
907 .collect(),
908 )
909}
910
911fn csv_row_result_to_values(
913 result: Result<StringRecord, csv::Error>,
914 opts: &RowOptionSet,
915) -> Option<Vec<Value>> {
916 if let Ok(record) = result {
917 let row = record
918 .into_iter()
919 .enumerate()
920 .map(|(ci, cell)| csv_cell_to_json_value(cell, opts, ci))
921 .collect();
922 return Some(row);
923 }
924 None
925}
926
927fn csv_cell_to_json_value(cell: &str, opts: &RowOptionSet, index: usize) -> Value {
929 let col = opts.column(index);
931 let (fmt, euro_num_mode) = if let Some(c) = col {
932 (c.format.clone(), c.decimal_comma)
933 } else {
934 (Format::Auto, opts.decimal_comma)
935 };
936 let def_val = col.and_then(|c| c.default.clone());
942 match fmt {
943 Format::Date => return process_date_value(cell, def_val, guess_date_string),
944 Format::DateTime => return process_date_value(cell, def_val, guess_datetime_string),
945 Format::DateTimeSimple => {
946 return process_date_value(cell, def_val, |s| {
947 guess_datetime_string(s).map(|full| simplify_datetime_string(&full))
948 })
949 }
950 Format::Time => {
951 return process_date_value(cell, def_val, |s| {
952 guess_datetime_string(s)
953 .and_then(|full| extract_time_portion(&full, false))
954 .or_else(|| parse_bare_time_string(s))
955 })
956 }
957 Format::Hm => {
958 return process_date_value(cell, def_val, |s| {
959 guess_datetime_string(s)
960 .and_then(|full| extract_time_portion(&full, true))
961 .or_else(|| parse_bare_time_string(s))
962 })
963 }
964 Format::Array(ref element_format, ref separator) => {
969 return process_array_value(cell, element_format, separator)
970 }
971 _ => {}
972 }
973 let has_number = cell.to_first_number::<f64>().is_some();
974 let trimmed_cell = cell.trim_matches(|c: char| !c.is_alphanumeric() && c != ',' && c != '.' && c != '-');
982 let num_cell = if has_number {
983 let euro_num_mode = uses_decimal_comma(trimmed_cell, euro_num_mode);
984 if euro_num_mode {
985 trimmed_cell.replace(",", ".").replace(",", ".")
986 } else {
987 trimmed_cell.replace(",", "")
988 }
989 } else {
990 cell.to_owned()
991 };
992 let mut new_cell = Value::Null;
993 if !num_cell.is_empty() && num_cell.is_numeric() {
994 if let Ok(float_val) = serde_json::Number::from_str(&num_cell) {
995 match fmt {
996 Format::Integer => {
997 if let Some(f) = float_val.as_f64() {
1002 if let Some(int_val) = Number::from_i128(f as i128) {
1003 new_cell = Value::Number(int_val);
1004 }
1005 }
1006 }
1007 Format::Boolean => {
1008 new_cell = Value::Bool(float_val.as_f64().unwrap_or(0f64) >= 1.0);
1010 }
1011 Format::Decimal(places) => {
1012 if let Some(f) = float_val.as_f64() {
1013 new_cell = float_value(f.round_decimal(places));
1014 }
1015 }
1016 _ => {
1017 new_cell = Value::Number(float_val);
1018 }
1019 }
1020 }
1021 } else if let Some(is_true) = cell.is_truthy_core(false) {
1022 new_cell = Value::Bool(is_true);
1023 } else {
1024 new_cell = match fmt {
1025 Format::Truthy => {
1026 if let Some(is_true) = cell.is_truthy_standard(false) {
1027 Value::Bool(is_true)
1028 } else {
1029 Value::Null
1030 }
1031 }
1032 _ => Value::String(cell.to_string()),
1033 };
1034 }
1035 new_cell
1036}
1037
1038pub async fn read_workbook_sheet_info<'a>(
1039 path_data: &PathData<'a>,
1040) -> Result<IndexMap<String, usize>, GenericError> {
1041 if let Ok(mut workbook) = open_workbook_auto(path_data.path()) {
1042 let mut im: IndexMap<String, usize> = IndexMap::new();
1043 for name in workbook.sheet_names() {
1044 if let Ok(range) = workbook.worksheet_range(&name) {
1045 im.insert(name, range.rows().count());
1046 }
1047 }
1048 Ok(im)
1049 } else {
1050 Err(GenericError("cannot_open_workbook"))
1051 }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::*;
1057 use crate::{helpers::*, Column, KeySegment};
1058 use serde_json::json;
1059 use std::path;
1060 use std::sync::Arc;
1061
1062 fn gen_header_gap_fixture(filename: &str) -> String {
1068 use rust_xlsxwriter::Workbook;
1069 let mut workbook = Workbook::new();
1070 let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1071 sheet.write_string(0, 0, "Report Title").unwrap();
1072 sheet.write_string(1, 0, "Generated 2026-01-01").unwrap();
1073 sheet.write_string(2, 0, "sku").unwrap();
1074 sheet.write_string(2, 1, "qty").unwrap();
1075 sheet.write_string(4, 0, "SKU001").unwrap();
1077 sheet.write_number(4, 1, 10.0).unwrap();
1078 sheet.write_string(5, 0, "SKU002").unwrap();
1079 sheet.write_number(5, 1, 20.0).unwrap();
1080 let path = std::env::temp_dir().join(filename);
1081 workbook.save(&path).unwrap();
1082 path.to_string_lossy().to_string()
1083 }
1084
1085 fn gen_auto_detect_fixture(filename: &str) -> String {
1089 use rust_xlsxwriter::Workbook;
1090 let mut workbook = Workbook::new();
1091 let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1092 sheet.write_string(0, 0, "Sales 2025").unwrap();
1093 sheet.write_string(1, 0, "region").unwrap();
1094 sheet.write_string(1, 1, "team size").unwrap();
1095 sheet.write_string(1, 2, "revenue").unwrap();
1096 sheet.write_string(2, 0, "long explanation about the data").unwrap();
1097 sheet.write_string(3, 0, "west").unwrap();
1098 sheet.write_number(3, 1, 12.0).unwrap();
1099 sheet.write_number(3, 2, 923456.0).unwrap();
1100 sheet.write_string(4, 0, "east").unwrap();
1101 sheet.write_number(4, 1, 7.0).unwrap();
1102 sheet.write_number(4, 2, 817285.0).unwrap();
1103 let path = std::env::temp_dir().join(filename);
1104 workbook.save(&path).unwrap();
1105 path.to_string_lossy().to_string()
1106 }
1107
1108 fn gen_multi_row_header_fixture(filename: &str) -> String {
1115 use rust_xlsxwriter::Workbook;
1116 let mut workbook = Workbook::new();
1117 let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1118 sheet.write_string(0, 0, "2015").unwrap();
1119 sheet.write_string(0, 3, "2025").unwrap();
1121 sheet.write_string(1, 0, "North").unwrap();
1123 sheet.write_string(1, 1, "Midlands").unwrap();
1124 sheet.write_string(1, 2, "South").unwrap();
1125 sheet.write_string(1, 3, "North").unwrap();
1126 sheet.write_string(1, 4, "Midlands").unwrap();
1127 sheet.write_string(1, 5, "South").unwrap();
1128 sheet.write_number(2, 0, 100.0).unwrap();
1129 sheet.write_number(2, 1, 101.0).unwrap();
1130 sheet.write_number(2, 2, 102.0).unwrap();
1131 sheet.write_number(2, 3, 200.0).unwrap();
1132 sheet.write_number(2, 4, 201.0).unwrap();
1133 sheet.write_number(2, 5, 202.0).unwrap();
1134 let path = std::env::temp_dir().join(filename);
1135 workbook.save(&path).unwrap();
1136 path.to_string_lossy().to_string()
1137 }
1138
1139 #[test]
1140 fn test_header_row_span_combines_a_merged_year_row_with_a_region_row_xlsx() {
1141 let path = gen_multi_row_header_fixture("multi_row_header.xlsx");
1142 let opts = OptionSet::new(&path).header_row(0).header_row_span(2);
1143 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1144 assert_eq!(rows.len(), 1, "both header rows must be consumed, not leak through as data");
1145 let row = &rows[0];
1146 assert_eq!(row.get("2015_north"), Some(&json!(100.0)));
1148 assert_eq!(row.get("2015_midlands"), Some(&json!(101.0)));
1149 assert_eq!(row.get("2015_south"), Some(&json!(102.0)));
1150 assert_eq!(row.get("2025_north"), Some(&json!(200.0)));
1151 assert_eq!(row.get("2025_midlands"), Some(&json!(201.0)));
1152 assert_eq!(row.get("2025_south"), Some(&json!(202.0)));
1153 }
1154
1155 #[test]
1156 fn test_header_row_span_default_of_one_is_unchanged_from_today_xlsx() {
1157 let path = gen_header_gap_fixture("single_row_header_unchanged.xlsx");
1160 let opts = OptionSet::new(&path).header_row(2).data_row_index(4);
1161 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1162 assert_eq!(rows.len(), 2);
1163 assert_eq!(rows[0].get("sku"), Some(&json!("SKU001")));
1164 assert_eq!(rows[0].get("qty"), Some(&json!(10.0)));
1165 }
1166
1167 #[test]
1168 fn test_detect_header_opt_in_finds_header_and_data_row_xlsx() {
1169 let path = gen_auto_detect_fixture("auto_detect.xlsx");
1172 let opts = OptionSet::new(&path).detect_header();
1173 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1174 assert_eq!(rows.len(), 2, "the explanation row must not leak through as data");
1175 assert_eq!(rows[0].get("region"), Some(&json!("west")));
1176 assert_eq!(rows[0].get("team_size"), Some(&json!(12.0)));
1177 assert_eq!(rows[0].get("revenue"), Some(&json!(923456.0)));
1178 assert_eq!(rows[1].get("region"), Some(&json!("east")));
1179 }
1180
1181 #[test]
1182 fn test_detect_header_opt_in_finds_header_and_data_row_csv() {
1183 let path = write_csv_fixture(
1184 "auto_detect.csv",
1185 "Sales 2025\nregion,team size,revenue\nlong explanation about the data\nwest,12,923456\neast,7,817285\n",
1186 );
1187 let opts = OptionSet::new(&path).detect_header();
1188 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1189 assert_eq!(rows.len(), 2, "the explanation row must not leak through as data");
1190 assert_eq!(rows[0].get("region"), Some(&json!("west")));
1191 assert_eq!(rows[0].get("team_size"), Some(&json!(12)));
1192 assert_eq!(rows[1].get("region"), Some(&json!("east")));
1193 }
1194
1195 #[test]
1196 fn test_detect_header_falls_back_to_fallback_naming_for_headerless_text_csv() {
1197 let path = write_csv_fixture(
1202 "headerless_migration.csv",
1203 "welcome_msg,Welcome to our store,Bienvenue dans notre magasin\ngoodbye_msg,Thank you for visiting,Merci de votre visite\n",
1204 );
1205 let opts = OptionSet::new(&path).detect_header();
1206 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1207 assert_eq!(rows.len(), 2, "both rows must be present, none consumed as a header");
1208 assert_eq!(rows[0].get("a"), Some(&json!("welcome_msg")));
1209 assert_eq!(rows[0].get("b"), Some(&json!("Welcome to our store")));
1210 assert_eq!(rows[1].get("a"), Some(&json!("goodbye_msg")));
1211 }
1212
1213 #[test]
1214 fn test_detect_header_falls_back_to_fallback_naming_for_headerless_text_xlsx() {
1215 use rust_xlsxwriter::Workbook;
1216 let mut workbook = Workbook::new();
1217 let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1218 sheet.write_string(0, 0, "welcome_msg").unwrap();
1219 sheet.write_string(0, 1, "Welcome to our store").unwrap();
1220 sheet.write_string(0, 2, "Bienvenue dans notre magasin").unwrap();
1221 sheet.write_string(1, 0, "goodbye_msg").unwrap();
1222 sheet.write_string(1, 1, "Thank you for visiting").unwrap();
1223 sheet.write_string(1, 2, "Merci de votre visite").unwrap();
1224 let path = std::env::temp_dir().join("headerless_migration.xlsx");
1225 workbook.save(&path).unwrap();
1226
1227 let opts = OptionSet::new(path.to_str().unwrap()).detect_header();
1228 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1229 assert_eq!(rows.len(), 2, "both rows must be present, none consumed as a header");
1230 assert_eq!(rows[0].get("a"), Some(&json!("welcome_msg")));
1231 assert_eq!(rows[1].get("a"), Some(&json!("goodbye_msg")));
1232 }
1233
1234 #[test]
1235 fn test_omit_header_uses_fallback_names_for_xlsx_not_real_header_text() {
1236 let sample_path = "data/sample-data-1.xlsx";
1241 let opts = OptionSet::new(sample_path).omit_header();
1242 let result = process_spreadsheet_direct(&opts).unwrap();
1243 assert!(result.keys.contains(&"a".to_string()), "got: {:?}", result.keys);
1244 assert!(!result.keys.contains(&"id".to_string()), "got: {:?}", result.keys);
1245 let rows = result.to_vec();
1246 assert_eq!(rows[0].get("a"), Some(&json!("id")));
1249 }
1250
1251 #[test]
1252 fn test_detect_header_off_by_default_leaves_notes_rows_uncleaned() {
1253 let path = gen_auto_detect_fixture("auto_detect_no_opt_in.xlsx");
1257 let opts = OptionSet::new(&path);
1258 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1259 assert_eq!(rows.len(), 4);
1262 }
1263
1264 #[test]
1265 fn test_detect_header_does_not_change_output_for_a_normal_well_formed_file() {
1266 let sample_path = "data/sample-data-1.xlsx";
1271 let opts = OptionSet::new(sample_path).max_row_count(1_000).detect_header();
1272 let result = process_spreadsheet_direct(&opts).unwrap();
1273 assert_eq!(result.num_rows, 401);
1274 assert_eq!(result.to_vec()[0].get("first_name"), Some(&json!("Dulce")));
1275 }
1276
1277 #[test]
1278 fn test_header_row_without_data_row_index_captures_the_gap_row_as_data() {
1279 let path = gen_header_gap_fixture("header_gap_baseline.xlsx");
1283 let opts = OptionSet::new(&path).header_row(2);
1284 let result = process_spreadsheet_direct(&opts).unwrap();
1285 let rows = result.to_vec();
1286 assert_eq!(rows.len(), 3, "gap row, SKU001, SKU002");
1287 assert_eq!(rows[0].get("sku"), Some(&Value::Null));
1288 assert_eq!(rows[1].get("sku"), Some(&json!("SKU001")));
1289 assert_eq!(rows[2].get("sku"), Some(&json!("SKU002")));
1290 }
1291
1292 #[test]
1293 fn test_data_row_index_skips_the_gap_between_header_and_real_data() {
1294 let path = gen_header_gap_fixture("header_gap_with_data_row.xlsx");
1298 let opts = OptionSet::new(&path).header_row(2).data_row_index(4);
1299 let result = process_spreadsheet_direct(&opts).unwrap();
1300 let rows = result.to_vec();
1301 assert_eq!(rows.len(), 2, "just SKU001 and SKU002, no gap row");
1302 assert_eq!(rows[0].get("sku"), Some(&json!("SKU001")));
1303 assert_eq!(rows[0].get("qty"), Some(&json!(10.0)));
1304 assert_eq!(rows[1].get("sku"), Some(&json!("SKU002")));
1305 assert_eq!(rows[1].get("qty"), Some(&json!(20.0)));
1306 }
1307
1308 #[test]
1309 fn test_result_set_reports_the_resolved_header_and_body_start_indices() {
1310 let path = gen_header_gap_fixture("header_gap_reports_resolved_indices.xlsx");
1316 let opts = OptionSet::new(&path).header_row(2).data_row_index(4);
1317 let result = process_spreadsheet_direct(&opts).unwrap();
1318 assert_eq!(result.header_row_index, Some(2));
1319 assert_eq!(result.body_start_index, 4);
1320 }
1321
1322 #[test]
1323 fn test_result_set_reports_resolved_indices_from_auto_detection_too() {
1324 let path = gen_header_gap_fixture("header_gap_reports_detected_indices.xlsx");
1328 let opts = OptionSet::new(&path).detect_header();
1329 let result = process_spreadsheet_direct(&opts).unwrap();
1330 assert_eq!(result.header_row_index, Some(2));
1331 assert_eq!(result.body_start_index, 4);
1332 }
1333
1334 #[test]
1335 fn test_data_row_index_in_preview_multimode_skips_the_gap_too() {
1336 let path = gen_header_gap_fixture("header_gap_preview.xlsx");
1339 let opts = OptionSet::new(&path).header_row(2).data_row_index(4).read_mode_preview();
1340 let result = process_spreadsheet_direct(&opts).unwrap();
1341 let sheet_rows = result.data.first_sheet();
1342 assert_eq!(sheet_rows.len(), 2, "just SKU001 and SKU002, no gap row");
1343 assert_eq!(sheet_rows[0].get("sku"), Some(&json!("SKU001")));
1344 assert_eq!(sheet_rows[1].get("sku"), Some(&json!("SKU002")));
1345 }
1346
1347 #[test]
1348 fn test_direct_processing_xlsx() {
1349 let sample_path = "data/sample-data-1.xlsx";
1350
1351 let opts = OptionSet::new(sample_path).max_row_count(1_000);
1354
1355 let result = process_spreadsheet_direct(&opts);
1356
1357 assert_eq!(result.unwrap().num_rows, 401);
1359 }
1360
1361 #[test]
1362 fn test_source_key_override_renames_and_reformats_without_position() {
1363 let sample_path = "data/sample-data-1.csv";
1366 let mut opts = OptionSet::new(sample_path).max_row_count(2);
1367 opts.rows.columns = vec![
1368 Column::from_source_key_with_format("weight", Some("weight_lbs"), Format::Integer, None, DateTimeMode::Full, false),
1369 ];
1370
1371 let result = process_spreadsheet_direct(&opts).unwrap();
1372 assert!(result.keys.contains(&"id".to_string()));
1374 assert!(result.keys.contains(&"first_name".to_string()));
1375 assert!(result.keys.contains(&"weight_lbs".to_string()));
1376 assert!(!result.keys.contains(&"weight".to_string()));
1377
1378 let rows = result.to_vec();
1379 let first = rows.first().expect("at least one row");
1380 assert!(first.get("weight_lbs").is_some());
1381 assert!(first.get("weight").is_none());
1382 assert_eq!(first.get("id").unwrap(), 1);
1383 }
1384
1385 #[test]
1386 fn test_column_key_as_key_segment_nests_two_columns_into_one_object_end_to_end() {
1387 let sample_path = "data/sample-data-1.csv";
1393 let mut opts = OptionSet::new(sample_path).max_row_count(1);
1394
1395 let mut weight_col = Column::from_source_key_with_format("weight", None, Format::Integer, None, DateTimeMode::Full, false);
1396 weight_col.key = Some(KeySegment::Object(Arc::from("measurements"), Arc::new(KeySegment::Simple(Arc::from("weight")))));
1397
1398 let mut height_col = Column::from_source_key_with_format("height", None, Format::Integer, None, DateTimeMode::Full, false);
1399 height_col.key = Some(KeySegment::Object(Arc::from("measurements"), Arc::new(KeySegment::Simple(Arc::from("height")))));
1400
1401 opts.rows.columns = vec![weight_col, height_col];
1402
1403 let result = process_spreadsheet_direct(&opts).unwrap();
1404 let rows = result.to_vec();
1405 let first = rows.first().expect("at least one row");
1406
1407 assert!(first.get("weight").is_none());
1409 assert!(first.get("height").is_none());
1410
1411 let measurements = first.get("measurements").expect("measurements object present");
1412 assert_eq!(measurements.get("weight").unwrap(), 103);
1413 assert_eq!(measurements.get("height").unwrap(), 164);
1414 }
1415
1416 #[test]
1417 fn test_column_from_json_nested_key_segment_drives_real_output_end_to_end() {
1418 let sample_path = "data/sample-data-1.csv";
1422 let opts = OptionSet::new(sample_path).max_row_count(1).override_columns(&[
1423 serde_json::json!({
1424 "source_key": "weight",
1425 "format": "integer",
1426 "key": {"type": "object", "key": "measurements", "next": "weight"}
1427 }),
1428 serde_json::json!({
1429 "source_key": "height",
1430 "format": "integer",
1431 "key": {"type": "object", "key": "measurements", "next": "height"}
1432 }),
1433 ]);
1434
1435 let result = process_spreadsheet_direct(&opts).unwrap();
1436 let rows = result.to_vec();
1437 let first = rows.first().expect("at least one row");
1438
1439 assert!(first.get("weight").is_none());
1440 assert!(first.get("height").is_none());
1441 let measurements = first.get("measurements").expect("measurements object present");
1442 assert_eq!(measurements.get("weight").unwrap(), 103);
1443 assert_eq!(measurements.get("height").unwrap(), 164);
1444 }
1445
1446 #[test]
1447 fn test_omit_null_values_row_option_drops_null_keys_end_to_end() {
1448 let path = write_csv_fixture("omit_null_values.csv", "title,status\nTitle 1,maybe\n");
1451 let cols = vec![Column::from_source_key_with_format("status", None, Format::Truthy, None, DateTimeMode::Full, false)];
1452
1453 let mut opts_default = OptionSet::new(&path);
1455 opts_default.rows.columns = cols.clone();
1456 let rows = process_spreadsheet_direct(&opts_default).unwrap().to_vec();
1457 assert_eq!(rows[0].get("status"), Some(&Value::Null));
1458
1459 let mut opts_omit = OptionSet::new(&path);
1461 opts_omit.rows.columns = cols;
1462 opts_omit.rows.omit_null_values = true;
1463 let rows = process_spreadsheet_direct(&opts_omit).unwrap().to_vec();
1464 assert!(rows[0].get("status").is_none());
1465 assert_eq!(rows[0].get("title"), Some(&json!("Title 1")));
1466 }
1467
1468 #[test]
1469 fn test_resolve_datetime_mode_prefers_column_format_over_row_defaults() {
1470 assert_eq!(resolve_datetime_mode(&Format::Date, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::DateOnly);
1474 assert_eq!(resolve_datetime_mode(&Format::Time, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::TimeOnly);
1475 assert_eq!(resolve_datetime_mode(&Format::Hm, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::HmOnly);
1476 assert_eq!(resolve_datetime_mode(&Format::DateTimeSimple, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::Simple);
1477 assert_eq!(resolve_datetime_mode(&Format::DateTime, DateTimeMode::DateOnly, DateTimeMode::TimeOnly), DateTimeMode::Full);
1478 assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::Full);
1479 assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::DateOnly, DateTimeMode::Full), DateTimeMode::DateOnly);
1480 assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::Full, DateTimeMode::TimeOnly), DateTimeMode::TimeOnly);
1481 assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::HmOnly, DateTimeMode::DateOnly), DateTimeMode::HmOnly);
1482 }
1483
1484 #[test]
1485 fn test_source_key_override_casts_native_datetime_cell_to_date_only() {
1486 let sample_path = "data/sample-data-1.xlsx";
1491 let mut opts = OptionSet::new(sample_path).max_row_count(1);
1492 opts.rows.columns = vec![
1493 Column::from_source_key_with_format("start_time", None, Format::Date, None, DateTimeMode::Full, false),
1494 ];
1495
1496 let result = process_spreadsheet_direct(&opts).unwrap();
1497 let rows = result.to_vec();
1498 let first = rows.first().expect("at least one row");
1499 let start_time = first.get("start_time").expect("start_time column").as_str().unwrap();
1500 assert_eq!(start_time, "2023-06-15");
1501 assert!(!start_time.contains('T'), "should be date-only, got: {}", start_time);
1502 }
1503
1504 #[test]
1505 fn test_time_only_excel_cell_does_not_carry_the_epoch_placeholder_date() {
1506 use rust_xlsxwriter::{ExcelDateTime, Format as XlsxFormat, Workbook};
1513
1514 let mut workbook = Workbook::new();
1515 let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1516 let time_fmt = XlsxFormat::new().set_num_format("hh:mm");
1517 sheet.write_string(0, 0, "meal").unwrap();
1518 sheet.write_string(0, 1, "start").unwrap();
1519 sheet.write_string(1, 0, "Breakfast").unwrap();
1520 let breakfast_time = ExcelDateTime::from_hms(6, 30, 0).unwrap();
1521 sheet.write_time_with_format(1, 1, breakfast_time, &time_fmt).unwrap();
1522 sheet.write_string(2, 0, "Meeting").unwrap();
1524 let meeting_dt = ExcelDateTime::from_ymd(2026, 3, 5).unwrap().and_hms(9, 0, 0).unwrap();
1525 let datetime_fmt = XlsxFormat::new().set_num_format("yyyy-mm-dd hh:mm");
1526 sheet.write_datetime_with_format(2, 1, meeting_dt, &datetime_fmt).unwrap();
1527 let path = std::env::temp_dir().join("time_only_cell.xlsx");
1528 workbook.save(&path).unwrap();
1529
1530 let opts = OptionSet::new(path.to_str().unwrap());
1531 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1532 assert_eq!(rows[0].get("start"), Some(&json!("06:30:00")));
1533 assert_eq!(rows[1].get("start"), Some(&json!("2026-03-05T09:00:00.000Z")));
1534 }
1535
1536 #[test]
1537 fn test_format_time_column_override_forces_time_only_even_for_a_full_datetime() {
1538 use rust_xlsxwriter::{ExcelDateTime, Format as XlsxFormat, Workbook};
1543
1544 let mut workbook = Workbook::new();
1545 let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1546 sheet.write_string(0, 0, "logged_at").unwrap();
1547 let dt = ExcelDateTime::from_ymd(2026, 3, 5).unwrap().and_hms(9, 15, 30).unwrap();
1548 let datetime_fmt = XlsxFormat::new().set_num_format("yyyy-mm-dd hh:mm:ss");
1549 sheet.write_datetime_with_format(1, 0, dt, &datetime_fmt).unwrap();
1550 let path = std::env::temp_dir().join("format_time_override_cell.xlsx");
1551 workbook.save(&path).unwrap();
1552
1553 let mut opts = OptionSet::new(path.to_str().unwrap());
1554 opts.rows.columns = vec![
1555 Column::from_source_key_with_format("logged_at", None, Format::Time, None, DateTimeMode::Full, false),
1556 ];
1557 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1558 assert_eq!(rows[0].get("logged_at"), Some(&json!("09:15:30")));
1559 }
1560
1561 #[test]
1562 fn test_row_wide_time_only_strips_the_date_from_a_native_iso_datetime_cell() {
1563 let row_opts = RowOptionSet { datetime_mode: DateTimeMode::TimeOnly, ..Default::default() };
1568 let cell = Data::DateTimeIso("2023-06-15T10:17:00.000Z".to_string());
1569 assert_eq!(
1570 workbook_cell_to_value(&cell, &row_opts, 0),
1571 Value::String("10:17:00".to_string())
1572 );
1573 }
1574
1575 #[test]
1576 fn test_row_wide_hm_only_truncates_seconds_from_a_native_datetime_cell() {
1577 let row_opts = RowOptionSet { datetime_mode: DateTimeMode::HmOnly, ..Default::default() };
1580 let cell = Data::DateTimeIso("2023-06-15T09:15:30.000Z".to_string());
1581 assert_eq!(
1582 workbook_cell_to_value(&cell, &row_opts, 0),
1583 Value::String("09:15".to_string())
1584 );
1585 }
1586
1587 #[test]
1588 fn test_csv_cell_format_time_extracts_time_of_day_from_a_plain_date_string() {
1589 let cols = vec![Column::new_format(Format::Time, None)];
1593 let row_opts = RowOptionSet::simple(&cols);
1594 assert_eq!(
1595 csv_cell_to_json_value("2023-06-15T10:17:00", &row_opts, 0),
1596 Value::String("10:17:00".to_string())
1597 );
1598 }
1599
1600 #[test]
1601 fn test_csv_cell_format_hm_drops_seconds_too() {
1602 let cols = vec![Column::new_format(Format::Hm, None)];
1605 let row_opts = RowOptionSet::simple(&cols);
1606 assert_eq!(
1607 csv_cell_to_json_value("2023-06-15T09:15:30", &row_opts, 0),
1608 Value::String("09:15".to_string())
1609 );
1610 }
1611
1612 #[test]
1613 fn test_parse_bare_time_string_extracts_and_validates_via_to_numbers() {
1614 assert_eq!(parse_bare_time_string("11:39"), Some("11:39".to_string()));
1620 assert_eq!(parse_bare_time_string("9:5:3"), Some("09:05:03".to_string()));
1622 assert_eq!(parse_bare_time_string(" 23:59:59 "), Some("23:59:59".to_string()));
1623 assert_eq!(parse_bare_time_string("12.30"), Some("12:30".to_string()));
1625 assert_eq!(parse_bare_time_string("27:45:00"), Some("27:45:00".to_string()));
1629 assert_eq!(parse_bare_time_string("100:00"), Some("100:00".to_string()));
1630 assert_eq!(parse_bare_time_string("11:60"), None);
1633 assert_eq!(parse_bare_time_string("not a time"), None);
1635 assert_eq!(parse_bare_time_string("12"), None);
1636 assert_eq!(parse_bare_time_string("3.14"), Some("03:14".to_string()));
1641 }
1642
1643 #[test]
1644 fn test_parse_bare_time_string_converts_a_trailing_am_pm_marker_to_24_hour() {
1645 assert_eq!(parse_bare_time_string("2:30 PM"), Some("14:30".to_string()));
1648 assert_eq!(parse_bare_time_string("2:30PM"), Some("14:30".to_string()));
1649 assert_eq!(parse_bare_time_string("2:30pm"), Some("14:30".to_string()));
1650 assert_eq!(parse_bare_time_string("12:45am"), Some("00:45".to_string()));
1652 assert_eq!(parse_bare_time_string("12:00 PM"), Some("12:00".to_string()));
1653 assert_eq!(parse_bare_time_string("11:39 AM"), Some("11:39".to_string()));
1654 assert_eq!(parse_bare_time_string("11:39:05 PM"), Some("23:39:05".to_string()));
1656 assert_eq!(parse_bare_time_string("14:00pm"), Some("14:00".to_string()));
1660 assert_eq!(parse_bare_time_string("14:30pm"), Some("14:30".to_string()));
1661 assert_eq!(parse_bare_time_string("0:30am"), Some("00:30".to_string()));
1662 }
1663
1664 #[test]
1665 fn test_parse_bare_time_string_rejects_anything_else_with_letters_rather_than_guess() {
1666 assert_eq!(parse_bare_time_string("2h45m"), None);
1672 assert_eq!(parse_bare_time_string("not a time"), None);
1673 }
1674
1675 #[test]
1676 fn test_format_time_falls_back_to_a_bare_time_string_with_no_date_component() {
1677 let time_cols = vec![Column::new_format(Format::Time, None)];
1686 let time_opts = RowOptionSet::simple(&time_cols);
1687 assert_eq!(csv_cell_to_json_value("11:39", &time_opts, 0), Value::String("11:39".to_string()));
1688
1689 let hm_cols = vec![Column::new_format(Format::Hm, None)];
1690 let hm_opts = RowOptionSet::simple(&hm_cols);
1691 assert_eq!(csv_cell_to_json_value("11:39", &hm_opts, 0), Value::String("11:39".to_string()));
1692
1693 assert_eq!(
1696 csv_cell_to_json_value("2023-06-15T09:15:30", &time_opts, 0),
1697 Value::String("09:15:30".to_string())
1698 );
1699
1700 assert_eq!(process_string_value("11:39", Format::Time, None), Value::String("11:39".to_string()));
1702 assert_eq!(process_string_value("11:39", Format::Hm, None), Value::String("11:39".to_string()));
1703 }
1704
1705 #[test]
1706 fn test_decimal_to_hm_string_reads_the_fraction_as_literal_minutes_not_a_scaled_fraction() {
1707 assert_eq!(decimal_to_hm_string(12.3), Some("12:30".to_string()));
1713 assert_eq!(decimal_to_hm_string(9.05), Some("09:05".to_string()));
1714 assert_eq!(decimal_to_hm_string(0.0), Some("00:00".to_string()));
1715 assert_eq!(decimal_to_hm_string(23.59), Some("23:59".to_string()));
1716 assert_eq!(decimal_to_hm_string(24.0), Some("24:00".to_string()));
1720 assert_eq!(decimal_to_hm_string(100.3), Some("100:30".to_string()));
1721 assert_eq!(decimal_to_hm_string(12.75), None);
1725 assert_eq!(decimal_to_hm_string(-1.0), None);
1726 }
1727
1728 #[test]
1729 fn test_format_time_reconstructs_hm_from_a_decimal_disguised_time() {
1730 let time_cols = vec![Column::new_format(Format::Time, None)];
1742 let time_opts = RowOptionSet::simple(&time_cols);
1743 assert_eq!(csv_cell_to_json_value("12.30", &time_opts, 0), Value::String("12:30".to_string()));
1744 assert_eq!(csv_cell_to_json_value("12.3", &time_opts, 0), Value::String("12:03".to_string()));
1745
1746 let hm_cols = vec![Column::new_format(Format::Hm, None)];
1747 let hm_opts = RowOptionSet::simple(&hm_cols);
1748 assert_eq!(csv_cell_to_json_value("12.30", &hm_opts, 0), Value::String("12:30".to_string()));
1749
1750 assert_eq!(process_string_value("12.30", Format::Time, None), Value::String("12:30".to_string()));
1752
1753 assert_eq!(csv_cell_to_json_value("12.75", &time_opts, 0), Value::Null);
1757
1758 assert_eq!(process_float_value(12.3, Format::Time, None), Value::String("12:30".to_string()));
1764 assert_eq!(process_float_value(12.3, Format::Hm, None), Value::String("12:30".to_string()));
1765
1766 assert_eq!(process_float_value(12.75, Format::Time, None), Value::Null);
1769 }
1770
1771 #[test]
1772 fn test_format_date_recognizes_slash_separated_dates_of_any_order() {
1773 let cols = vec![Column::new_format(Format::Date, None)];
1782 let row_opts = RowOptionSet::simple(&cols);
1783 for (value, expected) in [
1784 ("19/07/2026", "2026-07-19"), ("07/19/2026", "2026-07-19"), ("2026/07/19", "2026-07-19"), ("2026-07-19", "2026-07-19"), ] {
1789 assert_eq!(
1790 csv_cell_to_json_value(value, &row_opts, 0),
1791 Value::String(expected.to_string()),
1792 "{:?} should resolve to {:?}",
1793 value,
1794 expected
1795 );
1796 }
1797 }
1798
1799 #[test]
1800 fn test_format_datetime_recognizes_slash_separated_dates_too() {
1801 let cols = vec![Column::new_format(Format::DateTime, None)];
1802 let row_opts = RowOptionSet::simple(&cols);
1803 assert_eq!(
1804 csv_cell_to_json_value("19/07/2026", &row_opts, 0),
1805 Value::String("2026-07-19T00:00:00.000Z".to_string())
1806 );
1807 assert_eq!(
1809 process_string_value("19/07/2026", Format::Date, None),
1810 Value::String("2026-07-19".to_string())
1811 );
1812 }
1813
1814 #[test]
1815 fn test_format_date_recognizes_dot_separated_dates_too() {
1816 let cols = vec![Column::new_format(Format::Date, None)];
1819 let row_opts = RowOptionSet::simple(&cols);
1820 assert_eq!(
1821 csv_cell_to_json_value("19.07.2026", &row_opts, 0),
1822 Value::String("2026-07-19".to_string())
1823 );
1824 }
1825
1826 #[test]
1827 fn test_simplify_datetime_string_drops_milliseconds_and_trailing_z() {
1828 assert_eq!(simplify_datetime_string("2026-07-18T18:07:34.000Z"), "2026-07-18T18:07:34");
1829 assert_eq!(simplify_datetime_string("2026-07-18T18:07:34"), "2026-07-18T18:07:34");
1831 }
1832
1833 #[test]
1834 fn test_row_wide_simple_mode_strips_milliseconds_and_z_from_a_native_iso_datetime_cell() {
1835 let row_opts = RowOptionSet { datetime_mode: DateTimeMode::Simple, ..Default::default() };
1836 let cell = Data::DateTimeIso("2026-07-18T18:07:34.000Z".to_string());
1837 assert_eq!(
1838 workbook_cell_to_value(&cell, &row_opts, 0),
1839 Value::String("2026-07-18T18:07:34".to_string())
1840 );
1841 }
1842
1843 #[test]
1844 fn test_csv_cell_format_datetime_simple_drops_milliseconds_and_z() {
1845 let cols = vec![Column::new_format(Format::DateTimeSimple, None)];
1849 let row_opts = RowOptionSet::simple(&cols);
1850 assert_eq!(
1851 csv_cell_to_json_value("2026-07-18T18:07:34", &row_opts, 0),
1852 Value::String("2026-07-18T18:07:34".to_string())
1853 );
1854 }
1855
1856 #[test]
1857 fn test_simple_mode_still_avoids_the_epoch_placeholder_date_for_a_genuine_time_only_excel_cell() {
1858 use rust_xlsxwriter::{ExcelDateTime, Format as XlsxFormat, Workbook};
1863
1864 let mut workbook = Workbook::new();
1865 let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1866 let time_fmt = XlsxFormat::new().set_num_format("hh:mm");
1867 sheet.write_string(0, 0, "start").unwrap();
1868 let breakfast_time = ExcelDateTime::from_hms(6, 30, 0).unwrap();
1869 sheet.write_time_with_format(1, 0, breakfast_time, &time_fmt).unwrap();
1870 let path = std::env::temp_dir().join("simple_mode_time_only_cell.xlsx");
1871 workbook.save(&path).unwrap();
1872
1873 let mut opts = OptionSet::new(path.to_str().unwrap());
1874 opts.rows.datetime_mode = DateTimeMode::Simple;
1875 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1876 assert_eq!(rows[0].get("start"), Some(&json!("06:30:00")));
1877 }
1878
1879 #[test]
1880 fn test_column_datetime_mode_applies_only_to_genuine_datetime_cells_under_format_auto() {
1881 let cols = vec![Column::from_key_ref_with_format(None, Format::Auto, None, DateTimeMode::HmOnly, false)];
1887 let row_opts = RowOptionSet::simple(&cols);
1888 let datetime_cell = Data::DateTimeIso("2023-06-15T09:15:30.000Z".to_string());
1889 assert_eq!(
1890 workbook_cell_to_value(&datetime_cell, &row_opts, 0),
1891 Value::String("09:15".to_string())
1892 );
1893 let string_cell = Data::String("not a date".to_string());
1894 assert_eq!(
1895 workbook_cell_to_value(&string_cell, &row_opts, 0),
1896 Value::String("not a date".to_string())
1897 );
1898 }
1899
1900 #[test]
1901 fn test_native_float_cell_decimal_format_rounds_to_the_given_precision() {
1902 let cols = vec![Column::new_format(Format::Decimal(2), None)];
1906 let row_opts = RowOptionSet::simple(&cols);
1907 let cell = Data::Float(19.98765);
1908 assert_eq!(workbook_cell_to_value(&cell, &row_opts, 0), json!(19.99));
1909 }
1910
1911 #[test]
1912 fn test_csv_cell_integer_format_truncates_decimal_values() {
1913 let cols = vec![Column::new_format(Format::Integer, None)];
1917 let row_opts = RowOptionSet::simple(&cols);
1918 assert_eq!(csv_cell_to_json_value("58.2", &row_opts, 0), Value::Number(Number::from(58)));
1919 assert_eq!(csv_cell_to_json_value("82.5", &row_opts, 0), Value::Number(Number::from(82)));
1920 assert_eq!(csv_cell_to_json_value("100", &row_opts, 0), Value::Number(Number::from(100)));
1921 }
1922
1923 #[test]
1924 fn test_csv_cell_decimal_format_rounds_to_the_given_precision() {
1925 let cols = vec![Column::new_format(Format::Decimal(2), None)];
1929 let row_opts = RowOptionSet::simple(&cols);
1930 assert_eq!(csv_cell_to_json_value("19.98765", &row_opts, 0), json!(19.99));
1931 assert_eq!(csv_cell_to_json_value("5.4", &row_opts, 0), json!(5.4));
1932 assert_eq!(csv_cell_to_json_value("100", &row_opts, 0), json!(100.0));
1933 }
1934
1935 #[test]
1936 fn test_csv_cell_float_format_strips_a_currency_symbol_but_keeps_the_decimal() {
1937 let cols = vec![Column::new_format(Format::Float, None)];
1943 let row_opts = RowOptionSet::simple(&cols);
1944 assert_eq!(csv_cell_to_json_value("£1,999.99", &row_opts, 0), json!(1999.99));
1945 assert_eq!(csv_cell_to_json_value("$45.50", &row_opts, 0), json!(45.50));
1946 assert_eq!(csv_cell_to_json_value("1999.99", &row_opts, 0), json!(1999.99));
1947 assert_eq!(csv_cell_to_json_value("abc", &row_opts, 0), Value::String("abc".to_string()));
1948 }
1949
1950 #[test]
1951 fn test_format_array_splits_and_formats_each_element_via_to_segments() {
1952 let cols = vec![Column::new_format(Format::Array(Arc::new(Format::Float), Arc::from(",")), None)];
1953 let row_opts = RowOptionSet::simple(&cols);
1954 assert_eq!(csv_cell_to_json_value("34.8,78.3", &row_opts, 0), json!([34.8, 78.3]));
1955 assert_eq!(csv_cell_to_json_value(" 34.8 , 78.3 ", &row_opts, 0), json!([34.8, 78.3]));
1957 }
1958
1959 #[test]
1960 fn test_format_array_is_not_misread_as_a_bare_number_by_numeric_sniffing() {
1961 let cols = vec![Column::new_format(Format::Array(Arc::new(Format::Float), Arc::from(",")), None)];
1965 let row_opts = RowOptionSet::simple(&cols);
1966 let result = csv_cell_to_json_value("34.8,78.3", &row_opts, 0);
1967 assert_eq!(result.as_array().map(|a| a.len()), Some(2));
1968 }
1969
1970 #[test]
1971 fn test_format_array_with_text_elements_matches_the_original_tags_use_case() {
1972 let cols = vec![Column::new_format(Format::Array(Arc::new(Format::Text), Arc::from(",")), None)];
1973 let row_opts = RowOptionSet::simple(&cols);
1974 assert_eq!(csv_cell_to_json_value("lions,Kenya", &row_opts, 0), json!(["lions", "Kenya"]));
1975 }
1976
1977 #[test]
1978 fn test_format_from_str_array_syntax_round_trips_through_actual_cell_processing() {
1979 let cols = vec").unwrap(), None)];
1984 let row_opts = RowOptionSet::simple(&cols);
1985 assert_eq!(csv_cell_to_json_value("34|78|9", &row_opts, 0), json!([34, 78, 9]));
1986
1987 let text_cols = vec").unwrap(), None)];
1988 let text_row_opts = RowOptionSet::simple(&text_cols);
1989 assert_eq!(
1990 csv_cell_to_json_value("lions|Kenya", &text_row_opts, 0),
1991 json!(["lions", "Kenya"])
1992 );
1993 }
1994
1995 #[test]
1996 fn test_format_array_element_that_fails_to_parse_becomes_null_in_place() {
1997 let cols = vec![Column::new_format(Format::Array(Arc::new(Format::Float), Arc::from(",")), None)];
2000 let row_opts = RowOptionSet::simple(&cols);
2001 assert_eq!(csv_cell_to_json_value("34.8,not-a-number", &row_opts, 0), json!([34.8, null]));
2002 }
2003
2004 #[test]
2005 fn test_format_array_on_a_native_float_cell_has_nothing_to_split_so_stays_a_plain_number() {
2006 let cols = vec![Column::new_format(Format::Array(Arc::new(Format::Float), Arc::from(",")), None)];
2010 let row_opts = RowOptionSet::simple(&cols);
2011 let cell = Data::Float(34.8);
2012 assert_eq!(workbook_cell_to_value(&cell, &row_opts, 0), json!(34.8));
2013 }
2014
2015 #[test]
2016 fn test_csv_cell_does_not_coerce_ids_to_booleans() {
2017 let row_opts = RowOptionSet::default();
2022 assert_eq!(csv_cell_to_json_value("SKU001", &row_opts, 0), Value::String("SKU001".to_string()));
2023 assert_eq!(csv_cell_to_json_value("A1", &row_opts, 0), Value::String("A1".to_string()));
2024 assert_eq!(csv_cell_to_json_value("01/06/2024", &row_opts, 0), Value::String("01/06/2024".to_string()));
2025 assert_eq!(csv_cell_to_json_value("true", &row_opts, 0), Value::Bool(true));
2027 assert_eq!(csv_cell_to_json_value("false", &row_opts, 0), Value::Bool(false));
2028 }
2029
2030 #[test]
2031 fn test_direct_processing_csv() {
2032 let sample_path = "data/sample-data-1.csv";
2033
2034 let opts = OptionSet::new(sample_path).max_row_count(1_000);
2037
2038 let result = process_spreadsheet_direct(&opts);
2039
2040 assert_eq!(result.unwrap().num_rows, 401);
2042 }
2043
2044 fn write_csv_fixture(filename: &str, content: &str) -> String {
2048 let path = std::env::temp_dir().join(filename);
2049 std::fs::write(&path, content).unwrap();
2050 path.to_string_lossy().to_string()
2051 }
2052
2053 #[test]
2054 fn test_csv_header_row_and_data_row_index_skip_a_gap() {
2055 let path = write_csv_fixture(
2058 "csv_header_gap.csv",
2059 "Report Title\nGenerated 2026-01-01\nsku,qty\n,\nSKU001,10\nSKU002,20\n",
2060 );
2061
2062 let opts = OptionSet::new(&path).header_row(2);
2065 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
2066 assert_eq!(rows.len(), 3, "gap row, SKU001, SKU002");
2067 assert_eq!(rows[0].get("sku"), Some(&json!("")));
2068
2069 let opts = OptionSet::new(&path).header_row(2).data_row_index(4);
2071 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
2072 assert_eq!(rows.len(), 2, "just SKU001 and SKU002");
2073 assert_eq!(rows[0].get("sku"), Some(&json!("SKU001")));
2074 assert_eq!(rows[1].get("sku"), Some(&json!("SKU002")));
2075 }
2076
2077 #[test]
2078 fn test_csv_header_row_span_combines_a_merged_year_row_with_a_region_row() {
2079 let path = write_csv_fixture(
2082 "csv_multi_row_header.csv",
2083 "2015,,,2025,,\nNorth,Midlands,South,North,Midlands,South\n100,101,102,200,201,202\n",
2084 );
2085 let opts = OptionSet::new(&path).header_row(0).header_row_span(2);
2086 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
2087 assert_eq!(rows.len(), 1, "both header rows must be consumed, not leak through as data");
2088 let row = &rows[0];
2089 assert_eq!(row.get("2015_north"), Some(&json!(100)));
2092 assert_eq!(row.get("2015_midlands"), Some(&json!(101)));
2093 assert_eq!(row.get("2015_south"), Some(&json!(102)));
2094 assert_eq!(row.get("2025_north"), Some(&json!(200)));
2095 assert_eq!(row.get("2025_midlands"), Some(&json!(201)));
2096 assert_eq!(row.get("2025_south"), Some(&json!(202)));
2097 }
2098
2099 #[test]
2100 fn test_csv_omit_header_uses_fallback_keys_not_empty_rows() {
2101 let path = write_csv_fixture("csv_omit_header.csv", "SKU001,10\nSKU002,20\n");
2106 let opts = OptionSet::new(&path).omit_header();
2107 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
2108 assert_eq!(rows.len(), 2, "both rows present, including the former row 0");
2109 assert_eq!(rows[0].get("a"), Some(&json!("SKU001")));
2110 assert_eq!(rows[0].get("b"), Some(&json!(10)));
2111 assert_eq!(rows[1].get("a"), Some(&json!("SKU002")));
2112 }
2113
2114 #[test]
2115 fn test_csv_header_row_equals_data_row_index_for_predefined_headers() {
2116 let path = write_csv_fixture(
2120 "csv_predefined_headers.csv",
2121 "Report Title\nGenerated 2026-01-01\nSKU001,10\nSKU002,20\n",
2122 );
2123 let mut opts = OptionSet::new(&path).header_row(2).data_row_index(2).omit_header();
2124 opts.rows.columns = vec![
2125 Column::new(Some("sku")),
2126 Column::new(Some("qty")),
2127 ];
2128 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
2129 assert_eq!(rows.len(), 2, "both data rows present, notes rows skipped");
2130 assert_eq!(rows[0].get("sku"), Some(&json!("SKU001")));
2131 assert_eq!(rows[0].get("qty"), Some(&json!(10)));
2132 assert_eq!(rows[1].get("sku"), Some(&json!("SKU002")));
2133 }
2134
2135 #[test]
2136 fn test_multisheet_preview_ods() {
2137 let sample_path = "data/sample-data-2.ods";
2138
2139 let opts = OptionSet::new(sample_path)
2144 .max_row_count(10)
2145 .read_mode_preview();
2146
2147 let result = process_spreadsheet_direct(&opts);
2148
2149 let dataset = result.unwrap();
2151 assert_eq!(dataset.sheets.len(), 2);
2152 assert_eq!(dataset.num_rows, 118);
2154
2155 assert_eq!(dataset.data.first_sheet().len(), 10);
2157 }
2158
2159 #[test]
2160 fn test_column_override_1() {
2161 let sample_json = json!({
2162 "sku": "CHAIR16",
2163 "height": "112cm",
2164 "width": "69cm",
2165 "approved": "Y"
2166 });
2167
2168 let rows = json_object_to_calamine_data(sample_json);
2169
2170 let cols = vec![
2171 Column::new_format(Format::Text, Some(string_value(""))),
2172 Column::new_format(Format::Float, Some(float_value(95.0))),
2173 Column::new_format(Format::Float, Some(float_value(65.0))),
2174 Column::new_format(Format::Truthy, Some(bool_value(false))),
2175 ];
2176
2177 let opts = &RowOptionSet::simple(&cols);
2179 let result = workbook_row_to_values(&rows, opts);
2180 assert_eq!(result.get(1).unwrap(), 112.0);
2182 assert_eq!(result.get(2).unwrap(), 69.0);
2184 assert_eq!(result.get(3).unwrap(), true);
2186 }
2187
2188 #[test]
2189 fn test_column_override_2() {
2190 let sample_json = json!({
2191 "name": "Sophia",
2192 "dob": "2001-9-23",
2193 "weight": "62kg",
2194 "result": "GOOD"
2195 });
2196
2197 let rows = json_object_to_calamine_data(sample_json);
2198
2199 let cols = vec![
2200 Column::new_format(Format::Text, None),
2201 Column::new_format(Format::Date, None),
2202 Column::new_format(Format::Float, None),
2203 Column::new_format(
2205 Format::truthy_custom("good", "bad"),
2206 Some(bool_value(false)),
2207 ),
2208 ];
2209
2210 let opts = &RowOptionSet::simple(&cols);
2212 let result = workbook_row_to_values(&rows, opts);
2213 assert_eq!(result.get(1).unwrap(), "2001-09-23");
2214 assert_eq!(result.get(2).unwrap(), 62.0);
2215 assert_eq!(result.get(3).unwrap(), true);
2216 }
2217
2218 #[tokio::test]
2219 async fn test_read_workbook_info() {
2220 let sample_path = "data/sample-data-1.xlsx";
2221 let path_data = PathData::new(path::Path::new(sample_path));
2222 let info = read_workbook_sheet_info(&path_data).await;
2223 assert!(info.is_ok());
2224 }
2225
2226 #[tokio::test]
2227 async fn test_large_csv_file() {
2228 let sample_path = "data/large-datasheet.csv";
2229 let max_rows = 100_000;
2230 let opts = OptionSet::new(sample_path).max_row_count(max_rows);
2231 let result = process_spreadsheet_core(&opts, None, None).await;
2232 if let Ok(data) = result.clone() {
2233 assert_eq!(data.data.first_sheet().len(), max_rows as usize);
2234 } else {
2235 panic!("Failed to process large CSV file");
2236 }
2237 assert!(result.is_ok());
2238 }
2239
2240 #[tokio::test]
2241 async fn test_medium_excel_file() {
2242 let sample_path = "data/medium-spreadsheet-50_000.xlsx";
2243 let max_rows = 5_000;
2244 let opts = OptionSet::new(sample_path).max_row_count(max_rows);
2245 let result = process_spreadsheet_core(&opts, None, None).await;
2246 if let Ok(data) = result.clone() {
2247 assert_eq!(data.data.first_sheet().len(), max_rows as usize);
2248 } else {
2249 panic!("Failed to process large Excel file");
2250 }
2251 assert!(result.is_ok());
2252 }
2253}