1use calamine::{open_workbook_auto, Data, Reader, Sheets};
2use csv::{ReaderBuilder, StringRecord};
3use heck::ToSnakeCase;
4use indexmap::IndexMap;
5use serde_json::{Number, Value};
6use std::fs::File;
7use std::io::BufReader;
8use std::path::Path;
9use std::str::FromStr;
10
11use crate::data_set::*;
12use crate::detect::{resolve_header_and_data_rows, DETECT_SAMPLE_SIZE};
13use crate::error::GenericError;
14use alphanumeric::*;
15use simple_string_patterns::SimpleMatch;
16use crate::headers::*;
17use crate::helpers::float_value;
18use crate::helpers::string_value;
19use is_truthy::*;
20use crate::round_decimal::RoundDecimal;
21use crate::DateTimeMode;
22use crate::Extension;
23use crate::Format;
24use crate::OptionSet;
25use crate::PathData;
26use crate::RowOptionSet;
27use fuzzy_datetime::{fuzzy_to_date_string, fuzzy_to_datetime_string_opts, iso_fuzzy_to_date_string, iso_fuzzy_to_datetime_string};
28
29pub type SaveRowFn = Box<dyn Fn(IndexMap<String, Value>) -> Result<(), GenericError> + Send + Sync>;
31
32pub fn process_spreadsheet_direct(opts: &OptionSet) -> Result<ResultSet, GenericError> {
35 let rt = tokio::runtime::Runtime::new().unwrap();
36 rt.block_on(process_spreadsheet_core(opts, None, None))
37}
38
39pub async fn process_spreadsheet_immediate(opts: &OptionSet) -> Result<ResultSet, GenericError> {
43 process_spreadsheet_core(opts, None, None).await
44}
45
46#[deprecated(
47 since = "1.0.6",
48 note = "This function is a wrapper for the renamed function `process_spreadsheet_inline`"
49)]
50pub async fn render_spreadsheet_direct(opts: &OptionSet) -> Result<ResultSet, GenericError> {
51 process_spreadsheet_core(opts, None, None).await
52}
53
54pub async fn process_spreadsheet_async(
56 opts: &OptionSet,
57 save_func: SaveRowFn,
58 out_ref: Option<&str>,
59) -> Result<ResultSet, GenericError> {
60 process_spreadsheet_core(opts, Some(save_func), out_ref).await
61}
62
63pub async fn process_spreadsheet_core(
66 opts: &OptionSet,
67 save_opt: Option<SaveRowFn>,
68 out_ref: Option<&str>,
69) -> Result<ResultSet, GenericError> {
70 if let Some(filepath) = opts.path.clone() {
71 let path = Path::new(&filepath);
72 if !path.exists() {
73 #[allow(dead_code)]
74 return Err(GenericError("file_unavailable"));
75 }
76 let path_data = PathData::new(path);
77 if path_data.is_valid() {
78 if path_data.use_calamine() {
79 read_workbook_core(&path_data, opts, save_opt, out_ref).await
80 } else {
81 read_csv_core(&path_data, opts, save_opt, out_ref).await
82 }
83 } else {
84 Err(GenericError("unsupported_format"))
85 }
86 } else {
87 Err(GenericError("no_filepath_specified"))
88 }
89}
90
91#[deprecated(
92 since = "1.0.6",
93 note = "This function is a wrapper for the renamed function `process_spreadsheet_core`"
94)]
95pub async fn render_spreadsheet_core(
96 opts: &OptionSet,
97 save_opt: Option<SaveRowFn>,
98 out_ref: Option<&str>,
99) -> Result<ResultSet, GenericError> {
100 process_spreadsheet_core(opts, save_opt, out_ref).await
101}
102
103pub async fn read_workbook_core<'a>(
106 path_data: &PathData<'a>,
107 opts: &OptionSet,
108 save_opt: Option<SaveRowFn>,
109 out_ref: Option<&str>,
110) -> Result<ResultSet, GenericError> {
111 if let Ok(mut workbook) = open_workbook_auto(path_data.path()) {
112 let max_rows = opts.max_rows();
113 let (selected_names, sheet_names, _sheet_indices) =
114 match_sheet_name_and_index(&mut workbook, opts);
115
116 if !selected_names.is_empty() {
117 let info = WorkbookInfo::new(path_data, &selected_names, &sheet_names);
118
119 if opts.multimode() {
120 read_multiple_worksheets(&mut workbook, &sheet_names, opts, &info, max_rows).await
121 } else {
122 let sheet_ref = &selected_names[0];
123 read_single_worksheet(workbook, sheet_ref, opts, &info, save_opt, out_ref).await
124 }
125 } else {
126 Err(GenericError("workbook_with_no_sheets"))
127 }
128 } else {
129 Err(GenericError("cannot_open_workbook"))
130 }
131}
132
133async fn read_multiple_worksheets(
135 workbook: &mut Sheets<BufReader<File>>,
136 sheet_names: &[String],
137 opts: &OptionSet,
138 info: &WorkbookInfo,
139 max_rows: usize,
140) -> Result<ResultSet, GenericError> {
141 let mut sheets: Vec<SheetDataSet> = vec![];
142 let capture_rows = opts.capture_rows();
143 for (sheet_index, sheet_ref) in sheet_names.iter().enumerate() {
144 let range = workbook.worksheet_range(&sheet_ref.clone())?;
145 let mut headers: Vec<String> = vec![];
146 let mut has_headers = false;
147 let mut rows: Vec<IndexMap<String, Value>> =
148 Vec::with_capacity(if capture_rows { max_rows } else { 0 });
149 let mut row_index = 0;
150 let detected = resolve_header_and_data_rows(opts, || {
151 range.rows().take(DETECT_SAMPLE_SIZE)
152 .map(|row| row.iter().map(|c| c.to_string()).collect())
153 .collect()
154 });
155 let first_data_row_index = detected.data_index;
156 let capture_headers = detected.header_index.is_some();
157 let header_row_index = detected.header_index.unwrap_or(0);
158 let mut col_keys: Vec<String> = vec![];
159 let columns = if sheet_index == 0 {
160 opts.rows.columns.clone()
161 } else {
162 vec![]
163 };
164 let mut resolved_row_opts = opts.rows.clone();
165 let match_header_row_below = capture_headers && header_row_index > 0;
166 if capture_headers {
167 if let Some(first_row) = range.headers() {
168 let natural_keys = natural_column_keys(&first_row, &opts.field_mode);
169 let resolved_columns = resolve_columns(&columns, &natural_keys);
170 headers = build_header_keys(&first_row, &resolved_columns, &opts.field_mode);
171 resolved_row_opts.columns = resolved_columns;
172 has_headers = !match_header_row_below;
173 col_keys = first_row;
174 }
175 } else {
176 let num_cols = range.get_size().1;
177 let blank = vec![String::new(); num_cols];
178 let natural_keys = natural_column_keys(&blank, &opts.field_mode);
179 let resolved_columns = resolve_columns(&columns, &natural_keys);
180 headers = build_header_keys(&blank, &resolved_columns, &opts.field_mode.forced_fallback());
181 resolved_row_opts.columns = resolved_columns;
182 has_headers = true;
183 }
184 let total = range.get_size().0;
185 if capture_rows || match_header_row_below {
186 let max_row_count = if capture_rows {
187 max_rows
188 } else {
189 header_row_index + 2
190 };
191 let max_take = if total < max_row_count {
192 total
193 } else {
194 max_row_count + 1
195 };
196 for row in range.rows().take(max_take) {
197 if row_index > max_row_count {
198 break;
199 }
200 if match_header_row_below && row_index == header_row_index {
201 let h_row = row
202 .iter()
203 .map(|c| c.to_string().to_snake_case())
204 .collect::<Vec<String>>();
205 let natural_keys = natural_column_keys(&h_row, &opts.field_mode);
206 let resolved_columns = resolve_columns(&columns, &natural_keys);
207 headers = build_header_keys(&h_row, &resolved_columns, &opts.field_mode);
208 resolved_row_opts.columns = resolved_columns;
209 has_headers = true;
210 } else if (has_headers || !capture_headers) && capture_rows
211 && row_index >= first_data_row_index {
212 let is_real_data = if capture_headers {
213 let raw_values: Vec<String> = row.iter().map(|c| c.to_string()).collect();
214 is_not_header_row(&raw_values, row_index, &col_keys)
215 } else {
216 true
217 };
218 if is_real_data {
219 let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
220 rows.push(row_map);
221 }
222 }
223 row_index += 1;
224 }
225 }
226 sheets.push(SheetDataSet::new(sheet_ref, &headers, &rows, total));
227 }
228 Ok(ResultSet::from_multiple(&sheets, info, opts))
229}
230
231pub async fn read_single_worksheet(
233 mut workbook: Sheets<BufReader<File>>,
234 sheet_ref: &str,
235 opts: &OptionSet,
236 info: &WorkbookInfo,
237 save_opt: Option<SaveRowFn>,
238 out_ref: Option<&str>,
239) -> Result<ResultSet, GenericError> {
240 let range = workbook.worksheet_range(sheet_ref)?;
241 let capture_rows = opts.capture_rows();
242 let columns = opts.rows.columns.clone();
243 let max_rows = opts.max_rows();
244 let mut headers: Vec<String> = vec![];
245 let mut col_keys: Vec<String> = vec![];
246 let mut has_headers = false;
247 let mut rows: Vec<IndexMap<String, Value>> =
248 Vec::with_capacity(if capture_rows { max_rows } else { 0 });
249 let mut row_index = 0;
250 let detected = resolve_header_and_data_rows(opts, || {
251 range.rows().take(DETECT_SAMPLE_SIZE)
252 .map(|row| row.iter().map(|c| c.to_string()).collect())
253 .collect()
254 });
255 let first_data_row_index = detected.data_index;
256 let capture_headers = detected.header_index.is_some();
260 let header_row_index = detected.header_index.unwrap_or(0);
261 let match_header_row_below = capture_headers && header_row_index > 0;
262 let mut resolved_row_opts = opts.rows.clone();
263
264 if capture_headers {
265 if let Some(first_row) = range.headers() {
266 let natural_keys = natural_column_keys(&first_row, &opts.field_mode);
267 let resolved_columns = resolve_columns(&columns, &natural_keys);
268 headers = build_header_keys(&first_row, &resolved_columns, &opts.field_mode);
269 resolved_row_opts.columns = resolved_columns;
270 has_headers = !match_header_row_below;
271 col_keys = first_row;
272 }
273 } else {
274 let num_cols = range.get_size().1;
275 let blank = vec![String::new(); num_cols];
276 let natural_keys = natural_column_keys(&blank, &opts.field_mode);
277 let resolved_columns = resolve_columns(&columns, &natural_keys);
278 headers = build_header_keys(&blank, &resolved_columns, &opts.field_mode.forced_fallback());
279 resolved_row_opts.columns = resolved_columns;
280 has_headers = true;
281 }
282 let total = range.get_size().0;
283 if capture_rows || match_header_row_below {
284 let max_row_count = if capture_rows {
285 max_rows
286 } else {
287 header_row_index + 2
288 };
289 let max_take = if total < max_row_count {
290 total
291 } else {
292 max_row_count + 1
293 };
294 for row in range.rows().take(max_take) {
295 if row_index > max_row_count {
296 break;
297 }
298 if match_header_row_below && row_index == header_row_index {
299 let h_row = row
300 .iter()
301 .map(|c| c.to_string().to_snake_case())
302 .collect::<Vec<String>>();
303 let natural_keys = natural_column_keys(&h_row, &opts.field_mode);
304 let resolved_columns = resolve_columns(&columns, &natural_keys);
305 headers = build_header_keys(&h_row, &resolved_columns, &opts.field_mode);
306 resolved_row_opts.columns = resolved_columns;
307 has_headers = true;
308 } else if (has_headers || !capture_headers) && capture_rows
309 && row_index >= first_data_row_index {
310 let is_real_data = if capture_headers {
312 let raw_values: Vec<String> = row.iter().map(|c| c.to_string()).collect();
313 is_not_header_row(&raw_values, row_index, &col_keys)
314 } else {
315 true
318 };
319 if is_real_data {
320 let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
321 rows.push(row_map);
322 }
323 }
324 row_index += 1;
325 }
326 }
327 if let Some(save_method) = save_opt {
328 let mut save_count: usize = 0;
333 for (idx, row) in range.rows().enumerate() {
334 if save_count >= max_rows {
335 break;
336 }
337 if idx < first_data_row_index {
338 continue;
339 }
340 let is_real_data = if capture_headers {
341 let raw_values: Vec<String> = row.iter().map(|c| c.to_string()).collect();
342 is_not_header_row(&raw_values, idx, &col_keys)
343 } else {
344 true
345 };
346 if is_real_data {
347 let row_map = workbook_row_to_map(row, &resolved_row_opts, &headers);
348 save_method(row_map)?;
349 save_count += 1;
350 }
351 }
352 }
353
354 let ds = DataSet::from_count_and_rows(total, rows, opts);
355 Ok(ResultSet::new(info, &headers, ds, opts, out_ref, detected.header_index, first_data_row_index))
356}
357
358pub async fn read_csv_core<'a>(
368 path_data: &PathData<'a>,
369 opts: &OptionSet,
370 save_opt: Option<SaveRowFn>,
371 out_ref: Option<&str>,
372) -> Result<ResultSet, GenericError> {
373 let separator = match path_data.mode() {
374 Extension::Tsv => b't',
375 _ => b',',
376 };
377 if let Ok(mut rdr) = ReaderBuilder::new()
378 .delimiter(separator)
379 .has_headers(false)
380 .flexible(true)
385 .from_path(path_data.path())
386 {
387 let capture_rows = opts.capture_rows();
388 let max_line_usize = opts.max_rows();
389 let detected = resolve_header_and_data_rows(opts, || {
393 let mut sample_rows = Vec::new();
394 if let Ok(mut sample_rdr) = ReaderBuilder::new()
395 .delimiter(separator)
396 .has_headers(false)
397 .flexible(true)
398 .from_path(path_data.path())
399 {
400 for record in sample_rdr.records().take(DETECT_SAMPLE_SIZE).flatten() {
401 sample_rows.push(record.iter().map(|s| s.to_string()).collect());
402 }
403 }
404 sample_rows
405 });
406 let first_data_row_index = detected.data_index;
407 let capture_header = detected.header_index.is_some();
411 let header_row_index = detected.header_index.unwrap_or(0);
412
413 let mut rows: Vec<IndexMap<String, Value>> =
414 Vec::with_capacity(if capture_rows { max_line_usize } else { 0 });
415 let mut headers: Vec<String> = vec![];
416 let mut resolved_row_opts = opts.rows.clone();
417 let mut fallback_keys_built = false;
420
421 let mut total: usize = 0;
422 let mut line_count: usize = 0;
423 let mut row_index: usize = 0;
424
425 for result in rdr.records() {
426 let Ok(record) = result else {
427 row_index += 1;
428 continue;
429 };
430 total += 1;
434
435 if capture_header && row_index == header_row_index {
436 let raw: Vec<String> = record.iter().map(|s| s.to_string()).collect();
437 let natural_keys = natural_column_keys(&raw, &opts.field_mode);
438 let resolved_columns = resolve_columns(&opts.rows.columns, &natural_keys);
439 headers = build_header_keys(&raw, &resolved_columns, &opts.field_mode);
440 resolved_row_opts.columns = resolved_columns;
441 row_index += 1;
442 continue;
443 }
444
445 if row_index < first_data_row_index {
446 row_index += 1;
447 continue;
448 }
449
450 if !capture_header && !fallback_keys_built {
451 let blank: Vec<String> = record.iter().map(|_| String::new()).collect();
452 let resolved_columns = resolve_columns(&opts.rows.columns, &natural_column_keys(&blank, &opts.field_mode));
453 headers = build_header_keys(&blank, &resolved_columns, &opts.field_mode.forced_fallback());
454 resolved_row_opts.columns = resolved_columns;
455 fallback_keys_built = true;
456 }
457
458 if capture_rows {
459 if line_count < max_line_usize {
460 if let Some(row) = csv_row_result_to_values(Ok(record), &resolved_row_opts) {
461 rows.push(to_index_map(&row, &headers));
462 line_count += 1;
463 }
464 }
465 } else if let Some(save_method) = save_opt.as_ref() {
466 if let Some(row) = csv_row_result_to_values(Ok(record), &resolved_row_opts) {
467 let row_map = to_index_map(&row, &headers);
468 save_method(row_map)?;
469 }
470 }
471 row_index += 1;
472 }
473 let info = WorkbookInfo::simple(path_data);
474 let ds = DataSet::from_count_and_rows(total, rows, opts);
475 Ok(ResultSet::new(&info, &headers, ds, opts, out_ref, detected.header_index, first_data_row_index))
476 } else {
477 let error_msg = match path_data.ext() {
478 Extension::Tsv => "unreadable_tsv_file",
479 _ => "unreadable_csv_file",
480 };
481 Err(GenericError(error_msg))
482 }
483}
484
485fn workbook_row_to_map(
487 row: &[Data],
488 opts: &RowOptionSet,
489 headers: &[String],
490) -> IndexMap<String, Value> {
491 to_index_map(&workbook_row_to_values(row, opts), headers)
492}
493
494fn workbook_row_to_values(row: &[Data], opts: &RowOptionSet) -> Vec<Value> {
496 row.iter()
497 .enumerate()
498 .map(|(c_index, cell)| workbook_cell_to_value(cell, opts, c_index))
499 .collect()
500}
501
502fn workbook_cell_to_value(cell: &Data, opts: &RowOptionSet, c_index: usize) -> Value {
504 let col = opts.column(c_index);
505 let format = col.map_or(Format::Auto, |c| c.format.to_owned());
506 let def_val = col.and_then(|c| c.default.clone());
507 let col_mode = col.map_or(DateTimeMode::Full, |c| c.datetime_mode);
508
509 let mode = resolve_datetime_mode(&format, col_mode, opts.datetime_mode);
510
511 match cell {
512 Data::Int(i) => Value::Number(Number::from_i128(*i as i128).unwrap()),
513 Data::Float(f) => process_float_value(*f, format, def_val),
514 Data::DateTimeIso(d) => process_iso_datetime_value(d, def_val, mode),
515 Data::DateTime(d) => process_excel_datetime_value(d, def_val, mode),
516 Data::Bool(b) => Value::Bool(*b),
517 Data::String(s) => process_string_value(s, format, def_val),
518 Data::Empty => def_val.unwrap_or(Value::Null),
519 _ => Value::String(cell.to_string()),
520 }
521}
522
523fn resolve_datetime_mode(format: &Format, col_mode: DateTimeMode, row_mode: DateTimeMode) -> DateTimeMode {
531 match format {
532 Format::Date => DateTimeMode::DateOnly,
533 Format::Time => DateTimeMode::TimeOnly,
534 Format::Hm => DateTimeMode::HmOnly,
535 Format::DateTime => DateTimeMode::Full,
536 Format::DateTimeSimple => DateTimeMode::Simple,
537 _ if col_mode != DateTimeMode::Full => col_mode,
538 _ => row_mode,
539 }
540}
541
542fn process_float_value(value: f64, format: Format, def_val: Option<Value>) -> Value {
543 match format {
544 Format::Integer => Value::Number(Number::from_i128(value as i128).unwrap()),
545 Format::Boolean => Value::Bool(value >= 1.0),
546 Format::Text => Value::String(value.to_string()),
547 Format::Decimal(places) => float_value(value.round_decimal(places)),
548 Format::Time | Format::Hm => decimal_to_hm_string(value)
549 .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
550 _ => Value::Number(Number::from_f64(value).unwrap()),
551 }
552}
553
554fn process_excel_datetime_value(
555 datetime: &calamine::ExcelDateTime,
556 def_val: Option<Value>,
557 mode: DateTimeMode,
558) -> Value {
559 let auto_time_only = matches!(mode, DateTimeMode::Full | DateTimeMode::Simple) && datetime.as_f64() < 1.0;
568 datetime.as_datetime().map_or_else(
569 || def_val.unwrap_or(Value::Null),
570 |dt| {
571 let formatted_date = match mode {
578 DateTimeMode::DateOnly => dt.format("%Y-%m-%d").to_string(),
579 DateTimeMode::TimeOnly => dt.format("%H:%M:%S").to_string(),
580 DateTimeMode::HmOnly => dt.format("%H:%M").to_string(),
581 DateTimeMode::Simple if auto_time_only => dt.format("%H:%M:%S").to_string(),
582 DateTimeMode::Simple => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
583 DateTimeMode::Full if auto_time_only => dt.format("%H:%M:%S").to_string(),
584 DateTimeMode::Full => dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
585 };
586 Value::String(formatted_date)
587 },
588 )
589}
590
591fn process_iso_datetime_value(dt_str: &str, def_val: Option<Value>, mode: DateTimeMode) -> Value {
592 match mode {
593 DateTimeMode::DateOnly => iso_fuzzy_to_date_string(dt_str)
594 .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
595 DateTimeMode::TimeOnly => iso_fuzzy_to_datetime_string(dt_str)
596 .and_then(|full| extract_time_portion(&full, false))
597 .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
598 DateTimeMode::HmOnly => iso_fuzzy_to_datetime_string(dt_str)
599 .and_then(|full| extract_time_portion(&full, true))
600 .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
601 DateTimeMode::Simple => iso_fuzzy_to_datetime_string(dt_str)
602 .map(|full| simplify_datetime_string(&full))
603 .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
604 DateTimeMode::Full => iso_fuzzy_to_datetime_string(dt_str)
605 .map_or_else(|| def_val.unwrap_or(Value::Null), Value::String),
606 }
607}
608
609fn extract_time_portion(full_datetime_str: &str, hm_only: bool) -> Option<String> {
617 let time_part = full_datetime_str.split('T').nth(1)?;
618 let time_part = time_part.trim_end_matches('Z');
619 let len = if hm_only { 5 } else { 8 };
620 time_part.get(0..len).map(str::to_string)
621}
622
623fn simplify_datetime_string(full_datetime_str: &str) -> String {
628 let trimmed = full_datetime_str.trim_end_matches('Z');
629 match trimmed.split_once('.') {
630 Some((base, _)) => base.to_string(),
631 None => trimmed.to_string(),
632 }
633}
634
635fn guess_date_string(value: &str) -> Option<String> {
643 fuzzy_to_date_string(value, None)
644}
645
646fn guess_datetime_string(value: &str) -> Option<String> {
651 fuzzy_to_datetime_string_opts(value, 'T', None, Some(':'), true)
652}
653
654fn parse_bare_time_string(value: &str) -> Option<String> {
694 let has_am_suffix = value.ends_with_ci("am");
695 let has_pm_suffix = !has_am_suffix && value.ends_with_ci("pm");
696 let is_12_hour = has_am_suffix || has_pm_suffix;
697
698 if !is_12_hour && value.has_alphabetic() {
699 return None;
700 }
701
702 let numeric_part = if is_12_hour {
706 value[..value.len() - 2].trim_end()
707 } else {
708 value
709 };
710
711 let numbers: Vec<u8> = numeric_part.replace('.', ":").to_numbers();
712
713 let resolve_hour = |hrs: u8| -> u8 {
714 if !is_12_hour || !(1..=12).contains(&hrs) {
715 return hrs;
719 }
720 match (has_am_suffix, hrs) {
721 (true, 12) => 0, (true, _) => hrs, (false, 12) => 12, (false, _) => hrs + 12, }
726 };
727
728 match numbers.as_slice() {
729 [hrs, mins] if *mins < 60 => Some(format!("{:02}:{:02}", resolve_hour(*hrs), mins)),
730 [hrs, mins, secs] if *mins < 60 && *secs < 60 => {
731 Some(format!("{:02}:{:02}:{:02}", resolve_hour(*hrs), mins, secs))
732 }
733 _ => None,
734 }
735}
736
737fn decimal_to_hm_string(value: f64) -> Option<String> {
760 if value < 0.0 {
761 return None;
762 }
763 let hours = value.trunc() as u8;
764 let frac_digits = format!("{:.2}", value.fract().abs());
765 let minutes: u32 = frac_digits.split('.').nth(1)?.parse().ok()?;
766 if minutes >= 60 {
767 return None;
768 }
769 Some(format!("{:02}:{:02}", hours, minutes))
770}
771
772fn process_string_value(value: &str, format: Format, def_val: Option<Value>) -> Value {
773 match format {
774 Format::Boolean => process_truthy_value(value, def_val, |v, ef| v.is_truthy_core(ef)),
775 Format::Truthy => process_truthy_value(value, def_val, |v, ef| v.is_truthy_standard(ef)),
776 Format::TruthyCustom(opts) => process_truthy_value(value, def_val, |v, _| {
777 v.is_truthy_custom(&opts)
778 }),
779 Format::Decimal(places) => {
780 process_numeric_value(value, def_val, |n| float_value(n.round_decimal(places)))
781 }
782 Format::Float => process_numeric_value(value, def_val, float_value),
783 Format::Date => process_date_value(value, def_val, guess_date_string),
784 Format::DateTime => process_date_value(value, def_val, guess_datetime_string),
785 Format::DateTimeSimple => process_date_value(value, def_val, |s| {
786 guess_datetime_string(s).map(|full| simplify_datetime_string(&full))
787 }),
788 Format::Time => process_date_value(value, def_val, |s| {
789 guess_datetime_string(s)
790 .and_then(|full| extract_time_portion(&full, false))
791 .or_else(|| parse_bare_time_string(s))
792 }),
793 Format::Hm => process_date_value(value, def_val, |s| {
794 guess_datetime_string(s)
795 .and_then(|full| extract_time_portion(&full, true))
796 .or_else(|| parse_bare_time_string(s))
797 }),
798 _ => Value::String(value.to_owned()),
799 }
800}
801
802fn process_truthy_value<F>(value: &str, def_val: Option<Value>, truthy_fn: F) -> Value
803where
804 F: Fn(&str, bool) -> Option<bool>,
805{
806 if let Some(is_true) = truthy_fn(value, false) {
807 Value::Bool(is_true)
808 } else {
809 def_val.unwrap_or(Value::Null)
810 }
811}
812
813fn process_numeric_value<F>(value: &str, def_val: Option<Value>, numeric_fn: F) -> Value
814where
815 F: Fn(f64) -> Value,
816{
817 if let Some(n) = value.to_first_number::<f64>() {
818 numeric_fn(n)
819 } else {
820 def_val.unwrap_or(Value::Null)
821 }
822}
823
824fn process_date_value<F>(value: &str, def_val: Option<Value>, date_fn: F) -> Value
825where
826 F: Fn(&str) -> Option<String>,
827{
828 if let Some(date_str) = date_fn(value) {
829 string_value(&date_str)
830 } else {
831 def_val.unwrap_or(Value::Null)
832 }
833}
834
835fn csv_row_result_to_values(
837 result: Result<StringRecord, csv::Error>,
838 opts: &RowOptionSet,
839) -> Option<Vec<Value>> {
840 if let Ok(record) = result {
841 let row = record
842 .into_iter()
843 .enumerate()
844 .map(|(ci, cell)| csv_cell_to_json_value(cell, opts, ci))
845 .collect();
846 return Some(row);
847 }
848 None
849}
850
851fn csv_cell_to_json_value(cell: &str, opts: &RowOptionSet, index: usize) -> Value {
853 let col = opts.column(index);
855 let (fmt, euro_num_mode) = if let Some(c) = col {
856 (c.format.clone(), c.decimal_comma)
857 } else {
858 (Format::Auto, opts.decimal_comma)
859 };
860 let def_val = col.and_then(|c| c.default.clone());
866 match fmt {
867 Format::Date => return process_date_value(cell, def_val, guess_date_string),
868 Format::DateTime => return process_date_value(cell, def_val, guess_datetime_string),
869 Format::DateTimeSimple => {
870 return process_date_value(cell, def_val, |s| {
871 guess_datetime_string(s).map(|full| simplify_datetime_string(&full))
872 })
873 }
874 Format::Time => {
875 return process_date_value(cell, def_val, |s| {
876 guess_datetime_string(s)
877 .and_then(|full| extract_time_portion(&full, false))
878 .or_else(|| parse_bare_time_string(s))
879 })
880 }
881 Format::Hm => {
882 return process_date_value(cell, def_val, |s| {
883 guess_datetime_string(s)
884 .and_then(|full| extract_time_portion(&full, true))
885 .or_else(|| parse_bare_time_string(s))
886 })
887 }
888 _ => {}
889 }
890 let has_number = cell.to_first_number::<f64>().is_some();
891 let num_cell = if has_number {
892 let euro_num_mode = uses_decimal_comma(cell, euro_num_mode);
893 if euro_num_mode {
894 cell.replace(",", ".").replace(",", ".")
895 } else {
896 cell.replace(",", "")
897 }
898 } else {
899 cell.to_owned()
900 };
901 let mut new_cell = Value::Null;
902 if !num_cell.is_empty() && num_cell.is_numeric() {
903 if let Ok(float_val) = serde_json::Number::from_str(&num_cell) {
904 match fmt {
905 Format::Integer => {
906 if let Some(f) = float_val.as_f64() {
911 if let Some(int_val) = Number::from_i128(f as i128) {
912 new_cell = Value::Number(int_val);
913 }
914 }
915 }
916 Format::Boolean => {
917 new_cell = Value::Bool(float_val.as_f64().unwrap_or(0f64) >= 1.0);
919 }
920 Format::Decimal(places) => {
921 if let Some(f) = float_val.as_f64() {
922 new_cell = float_value(f.round_decimal(places));
923 }
924 }
925 _ => {
926 new_cell = Value::Number(float_val);
927 }
928 }
929 }
930 } else if let Some(is_true) = cell.is_truthy_core(false) {
931 new_cell = Value::Bool(is_true);
932 } else {
933 new_cell = match fmt {
934 Format::Truthy => {
935 if let Some(is_true) = cell.is_truthy_standard(false) {
936 Value::Bool(is_true)
937 } else {
938 Value::Null
939 }
940 }
941 _ => Value::String(cell.to_string()),
942 };
943 }
944 new_cell
945}
946
947pub async fn read_workbook_sheet_info<'a>(
948 path_data: &PathData<'a>,
949) -> Result<IndexMap<String, usize>, GenericError> {
950 if let Ok(mut workbook) = open_workbook_auto(path_data.path()) {
951 let mut im: IndexMap<String, usize> = IndexMap::new();
952 for name in workbook.sheet_names() {
953 if let Ok(range) = workbook.worksheet_range(&name) {
954 im.insert(name, range.rows().count());
955 }
956 }
957 Ok(im)
958 } else {
959 Err(GenericError("cannot_open_workbook"))
960 }
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use crate::{helpers::*, Column};
967 use serde_json::json;
968 use std::path;
969
970 fn gen_header_gap_fixture(filename: &str) -> String {
976 use rust_xlsxwriter::Workbook;
977 let mut workbook = Workbook::new();
978 let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
979 sheet.write_string(0, 0, "Report Title").unwrap();
980 sheet.write_string(1, 0, "Generated 2026-01-01").unwrap();
981 sheet.write_string(2, 0, "sku").unwrap();
982 sheet.write_string(2, 1, "qty").unwrap();
983 sheet.write_string(4, 0, "SKU001").unwrap();
985 sheet.write_number(4, 1, 10.0).unwrap();
986 sheet.write_string(5, 0, "SKU002").unwrap();
987 sheet.write_number(5, 1, 20.0).unwrap();
988 let path = std::env::temp_dir().join(filename);
989 workbook.save(&path).unwrap();
990 path.to_string_lossy().to_string()
991 }
992
993 fn gen_auto_detect_fixture(filename: &str) -> String {
997 use rust_xlsxwriter::Workbook;
998 let mut workbook = Workbook::new();
999 let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1000 sheet.write_string(0, 0, "Sales 2025").unwrap();
1001 sheet.write_string(1, 0, "region").unwrap();
1002 sheet.write_string(1, 1, "team size").unwrap();
1003 sheet.write_string(1, 2, "revenue").unwrap();
1004 sheet.write_string(2, 0, "long explanation about the data").unwrap();
1005 sheet.write_string(3, 0, "west").unwrap();
1006 sheet.write_number(3, 1, 12.0).unwrap();
1007 sheet.write_number(3, 2, 923456.0).unwrap();
1008 sheet.write_string(4, 0, "east").unwrap();
1009 sheet.write_number(4, 1, 7.0).unwrap();
1010 sheet.write_number(4, 2, 817285.0).unwrap();
1011 let path = std::env::temp_dir().join(filename);
1012 workbook.save(&path).unwrap();
1013 path.to_string_lossy().to_string()
1014 }
1015
1016 #[test]
1017 fn test_detect_header_opt_in_finds_header_and_data_row_xlsx() {
1018 let path = gen_auto_detect_fixture("auto_detect.xlsx");
1021 let opts = OptionSet::new(&path).detect_header();
1022 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1023 assert_eq!(rows.len(), 2, "the explanation row must not leak through as data");
1024 assert_eq!(rows[0].get("region"), Some(&json!("west")));
1025 assert_eq!(rows[0].get("team_size"), Some(&json!(12.0)));
1026 assert_eq!(rows[0].get("revenue"), Some(&json!(923456.0)));
1027 assert_eq!(rows[1].get("region"), Some(&json!("east")));
1028 }
1029
1030 #[test]
1031 fn test_detect_header_opt_in_finds_header_and_data_row_csv() {
1032 let path = write_csv_fixture(
1033 "auto_detect.csv",
1034 "Sales 2025\nregion,team size,revenue\nlong explanation about the data\nwest,12,923456\neast,7,817285\n",
1035 );
1036 let opts = OptionSet::new(&path).detect_header();
1037 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1038 assert_eq!(rows.len(), 2, "the explanation row must not leak through as data");
1039 assert_eq!(rows[0].get("region"), Some(&json!("west")));
1040 assert_eq!(rows[0].get("team_size"), Some(&json!(12)));
1041 assert_eq!(rows[1].get("region"), Some(&json!("east")));
1042 }
1043
1044 #[test]
1045 fn test_detect_header_falls_back_to_fallback_naming_for_headerless_text_csv() {
1046 let path = write_csv_fixture(
1051 "headerless_migration.csv",
1052 "welcome_msg,Welcome to our store,Bienvenue dans notre magasin\ngoodbye_msg,Thank you for visiting,Merci de votre visite\n",
1053 );
1054 let opts = OptionSet::new(&path).detect_header();
1055 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1056 assert_eq!(rows.len(), 2, "both rows must be present, none consumed as a header");
1057 assert_eq!(rows[0].get("a"), Some(&json!("welcome_msg")));
1058 assert_eq!(rows[0].get("b"), Some(&json!("Welcome to our store")));
1059 assert_eq!(rows[1].get("a"), Some(&json!("goodbye_msg")));
1060 }
1061
1062 #[test]
1063 fn test_detect_header_falls_back_to_fallback_naming_for_headerless_text_xlsx() {
1064 use rust_xlsxwriter::Workbook;
1065 let mut workbook = Workbook::new();
1066 let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1067 sheet.write_string(0, 0, "welcome_msg").unwrap();
1068 sheet.write_string(0, 1, "Welcome to our store").unwrap();
1069 sheet.write_string(0, 2, "Bienvenue dans notre magasin").unwrap();
1070 sheet.write_string(1, 0, "goodbye_msg").unwrap();
1071 sheet.write_string(1, 1, "Thank you for visiting").unwrap();
1072 sheet.write_string(1, 2, "Merci de votre visite").unwrap();
1073 let path = std::env::temp_dir().join("headerless_migration.xlsx");
1074 workbook.save(&path).unwrap();
1075
1076 let opts = OptionSet::new(path.to_str().unwrap()).detect_header();
1077 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1078 assert_eq!(rows.len(), 2, "both rows must be present, none consumed as a header");
1079 assert_eq!(rows[0].get("a"), Some(&json!("welcome_msg")));
1080 assert_eq!(rows[1].get("a"), Some(&json!("goodbye_msg")));
1081 }
1082
1083 #[test]
1084 fn test_omit_header_uses_fallback_names_for_xlsx_not_real_header_text() {
1085 let sample_path = "data/sample-data-1.xlsx";
1090 let opts = OptionSet::new(sample_path).omit_header();
1091 let result = process_spreadsheet_direct(&opts).unwrap();
1092 assert!(result.keys.contains(&"a".to_string()), "got: {:?}", result.keys);
1093 assert!(!result.keys.contains(&"id".to_string()), "got: {:?}", result.keys);
1094 let rows = result.to_vec();
1095 assert_eq!(rows[0].get("a"), Some(&json!("id")));
1098 }
1099
1100 #[test]
1101 fn test_detect_header_off_by_default_leaves_notes_rows_uncleaned() {
1102 let path = gen_auto_detect_fixture("auto_detect_no_opt_in.xlsx");
1106 let opts = OptionSet::new(&path);
1107 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1108 assert_eq!(rows.len(), 4);
1111 }
1112
1113 #[test]
1114 fn test_detect_header_does_not_change_output_for_a_normal_well_formed_file() {
1115 let sample_path = "data/sample-data-1.xlsx";
1120 let opts = OptionSet::new(sample_path).max_row_count(1_000).detect_header();
1121 let result = process_spreadsheet_direct(&opts).unwrap();
1122 assert_eq!(result.num_rows, 401);
1123 assert_eq!(result.to_vec()[0].get("first_name"), Some(&json!("Dulce")));
1124 }
1125
1126 #[test]
1127 fn test_header_row_without_data_row_index_captures_the_gap_row_as_data() {
1128 let path = gen_header_gap_fixture("header_gap_baseline.xlsx");
1132 let opts = OptionSet::new(&path).header_row(2);
1133 let result = process_spreadsheet_direct(&opts).unwrap();
1134 let rows = result.to_vec();
1135 assert_eq!(rows.len(), 3, "gap row, SKU001, SKU002");
1136 assert_eq!(rows[0].get("sku"), Some(&Value::Null));
1137 assert_eq!(rows[1].get("sku"), Some(&json!("SKU001")));
1138 assert_eq!(rows[2].get("sku"), Some(&json!("SKU002")));
1139 }
1140
1141 #[test]
1142 fn test_data_row_index_skips_the_gap_between_header_and_real_data() {
1143 let path = gen_header_gap_fixture("header_gap_with_data_row.xlsx");
1147 let opts = OptionSet::new(&path).header_row(2).data_row_index(4);
1148 let result = process_spreadsheet_direct(&opts).unwrap();
1149 let rows = result.to_vec();
1150 assert_eq!(rows.len(), 2, "just SKU001 and SKU002, no gap row");
1151 assert_eq!(rows[0].get("sku"), Some(&json!("SKU001")));
1152 assert_eq!(rows[0].get("qty"), Some(&json!(10.0)));
1153 assert_eq!(rows[1].get("sku"), Some(&json!("SKU002")));
1154 assert_eq!(rows[1].get("qty"), Some(&json!(20.0)));
1155 }
1156
1157 #[test]
1158 fn test_result_set_reports_the_resolved_header_and_body_start_indices() {
1159 let path = gen_header_gap_fixture("header_gap_reports_resolved_indices.xlsx");
1165 let opts = OptionSet::new(&path).header_row(2).data_row_index(4);
1166 let result = process_spreadsheet_direct(&opts).unwrap();
1167 assert_eq!(result.header_row_index, Some(2));
1168 assert_eq!(result.body_start_index, 4);
1169 }
1170
1171 #[test]
1172 fn test_result_set_reports_resolved_indices_from_auto_detection_too() {
1173 let path = gen_header_gap_fixture("header_gap_reports_detected_indices.xlsx");
1177 let opts = OptionSet::new(&path).detect_header();
1178 let result = process_spreadsheet_direct(&opts).unwrap();
1179 assert_eq!(result.header_row_index, Some(2));
1180 assert_eq!(result.body_start_index, 4);
1181 }
1182
1183 #[test]
1184 fn test_data_row_index_in_preview_multimode_skips_the_gap_too() {
1185 let path = gen_header_gap_fixture("header_gap_preview.xlsx");
1188 let opts = OptionSet::new(&path).header_row(2).data_row_index(4).read_mode_preview();
1189 let result = process_spreadsheet_direct(&opts).unwrap();
1190 let sheet_rows = result.data.first_sheet();
1191 assert_eq!(sheet_rows.len(), 2, "just SKU001 and SKU002, no gap row");
1192 assert_eq!(sheet_rows[0].get("sku"), Some(&json!("SKU001")));
1193 assert_eq!(sheet_rows[1].get("sku"), Some(&json!("SKU002")));
1194 }
1195
1196 #[test]
1197 fn test_direct_processing_xlsx() {
1198 let sample_path = "data/sample-data-1.xlsx";
1199
1200 let opts = OptionSet::new(sample_path).max_row_count(1_000);
1203
1204 let result = process_spreadsheet_direct(&opts);
1205
1206 assert_eq!(result.unwrap().num_rows, 401);
1208 }
1209
1210 #[test]
1211 fn test_source_key_override_renames_and_reformats_without_position() {
1212 let sample_path = "data/sample-data-1.csv";
1215 let mut opts = OptionSet::new(sample_path).max_row_count(2);
1216 opts.rows.columns = vec![
1217 Column::from_source_key_with_format("weight", Some("weight_lbs"), Format::Integer, None, DateTimeMode::Full, false),
1218 ];
1219
1220 let result = process_spreadsheet_direct(&opts).unwrap();
1221 assert!(result.keys.contains(&"id".to_string()));
1223 assert!(result.keys.contains(&"first_name".to_string()));
1224 assert!(result.keys.contains(&"weight_lbs".to_string()));
1225 assert!(!result.keys.contains(&"weight".to_string()));
1226
1227 let rows = result.to_vec();
1228 let first = rows.first().expect("at least one row");
1229 assert!(first.get("weight_lbs").is_some());
1230 assert!(first.get("weight").is_none());
1231 assert_eq!(first.get("id").unwrap(), 1);
1232 }
1233
1234 #[test]
1235 fn test_resolve_datetime_mode_prefers_column_format_over_row_defaults() {
1236 assert_eq!(resolve_datetime_mode(&Format::Date, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::DateOnly);
1240 assert_eq!(resolve_datetime_mode(&Format::Time, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::TimeOnly);
1241 assert_eq!(resolve_datetime_mode(&Format::Hm, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::HmOnly);
1242 assert_eq!(resolve_datetime_mode(&Format::DateTimeSimple, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::Simple);
1243 assert_eq!(resolve_datetime_mode(&Format::DateTime, DateTimeMode::DateOnly, DateTimeMode::TimeOnly), DateTimeMode::Full);
1244 assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::Full, DateTimeMode::Full), DateTimeMode::Full);
1245 assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::DateOnly, DateTimeMode::Full), DateTimeMode::DateOnly);
1246 assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::Full, DateTimeMode::TimeOnly), DateTimeMode::TimeOnly);
1247 assert_eq!(resolve_datetime_mode(&Format::Auto, DateTimeMode::HmOnly, DateTimeMode::DateOnly), DateTimeMode::HmOnly);
1248 }
1249
1250 #[test]
1251 fn test_source_key_override_casts_native_datetime_cell_to_date_only() {
1252 let sample_path = "data/sample-data-1.xlsx";
1257 let mut opts = OptionSet::new(sample_path).max_row_count(1);
1258 opts.rows.columns = vec![
1259 Column::from_source_key_with_format("start_time", None, Format::Date, None, DateTimeMode::Full, false),
1260 ];
1261
1262 let result = process_spreadsheet_direct(&opts).unwrap();
1263 let rows = result.to_vec();
1264 let first = rows.first().expect("at least one row");
1265 let start_time = first.get("start_time").expect("start_time column").as_str().unwrap();
1266 assert_eq!(start_time, "2023-06-15");
1267 assert!(!start_time.contains('T'), "should be date-only, got: {}", start_time);
1268 }
1269
1270 #[test]
1271 fn test_time_only_excel_cell_does_not_carry_the_epoch_placeholder_date() {
1272 use rust_xlsxwriter::{ExcelDateTime, Format as XlsxFormat, Workbook};
1279
1280 let mut workbook = Workbook::new();
1281 let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1282 let time_fmt = XlsxFormat::new().set_num_format("hh:mm");
1283 sheet.write_string(0, 0, "meal").unwrap();
1284 sheet.write_string(0, 1, "start").unwrap();
1285 sheet.write_string(1, 0, "Breakfast").unwrap();
1286 let breakfast_time = ExcelDateTime::from_hms(6, 30, 0).unwrap();
1287 sheet.write_time_with_format(1, 1, breakfast_time, &time_fmt).unwrap();
1288 sheet.write_string(2, 0, "Meeting").unwrap();
1290 let meeting_dt = ExcelDateTime::from_ymd(2026, 3, 5).unwrap().and_hms(9, 0, 0).unwrap();
1291 let datetime_fmt = XlsxFormat::new().set_num_format("yyyy-mm-dd hh:mm");
1292 sheet.write_datetime_with_format(2, 1, meeting_dt, &datetime_fmt).unwrap();
1293 let path = std::env::temp_dir().join("time_only_cell.xlsx");
1294 workbook.save(&path).unwrap();
1295
1296 let opts = OptionSet::new(path.to_str().unwrap());
1297 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1298 assert_eq!(rows[0].get("start"), Some(&json!("06:30:00")));
1299 assert_eq!(rows[1].get("start"), Some(&json!("2026-03-05T09:00:00.000Z")));
1300 }
1301
1302 #[test]
1303 fn test_format_time_column_override_forces_time_only_even_for_a_full_datetime() {
1304 use rust_xlsxwriter::{ExcelDateTime, Format as XlsxFormat, Workbook};
1309
1310 let mut workbook = Workbook::new();
1311 let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1312 sheet.write_string(0, 0, "logged_at").unwrap();
1313 let dt = ExcelDateTime::from_ymd(2026, 3, 5).unwrap().and_hms(9, 15, 30).unwrap();
1314 let datetime_fmt = XlsxFormat::new().set_num_format("yyyy-mm-dd hh:mm:ss");
1315 sheet.write_datetime_with_format(1, 0, dt, &datetime_fmt).unwrap();
1316 let path = std::env::temp_dir().join("format_time_override_cell.xlsx");
1317 workbook.save(&path).unwrap();
1318
1319 let mut opts = OptionSet::new(path.to_str().unwrap());
1320 opts.rows.columns = vec![
1321 Column::from_source_key_with_format("logged_at", None, Format::Time, None, DateTimeMode::Full, false),
1322 ];
1323 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1324 assert_eq!(rows[0].get("logged_at"), Some(&json!("09:15:30")));
1325 }
1326
1327 #[test]
1328 fn test_row_wide_time_only_strips_the_date_from_a_native_iso_datetime_cell() {
1329 let row_opts = RowOptionSet { datetime_mode: DateTimeMode::TimeOnly, ..Default::default() };
1334 let cell = Data::DateTimeIso("2023-06-15T10:17:00.000Z".to_string());
1335 assert_eq!(
1336 workbook_cell_to_value(&cell, &row_opts, 0),
1337 Value::String("10:17:00".to_string())
1338 );
1339 }
1340
1341 #[test]
1342 fn test_row_wide_hm_only_truncates_seconds_from_a_native_datetime_cell() {
1343 let row_opts = RowOptionSet { datetime_mode: DateTimeMode::HmOnly, ..Default::default() };
1346 let cell = Data::DateTimeIso("2023-06-15T09:15:30.000Z".to_string());
1347 assert_eq!(
1348 workbook_cell_to_value(&cell, &row_opts, 0),
1349 Value::String("09:15".to_string())
1350 );
1351 }
1352
1353 #[test]
1354 fn test_csv_cell_format_time_extracts_time_of_day_from_a_plain_date_string() {
1355 let cols = vec![Column::new_format(Format::Time, None)];
1359 let row_opts = RowOptionSet::simple(&cols);
1360 assert_eq!(
1361 csv_cell_to_json_value("2023-06-15T10:17:00", &row_opts, 0),
1362 Value::String("10:17:00".to_string())
1363 );
1364 }
1365
1366 #[test]
1367 fn test_csv_cell_format_hm_drops_seconds_too() {
1368 let cols = vec![Column::new_format(Format::Hm, None)];
1371 let row_opts = RowOptionSet::simple(&cols);
1372 assert_eq!(
1373 csv_cell_to_json_value("2023-06-15T09:15:30", &row_opts, 0),
1374 Value::String("09:15".to_string())
1375 );
1376 }
1377
1378 #[test]
1379 fn test_parse_bare_time_string_extracts_and_validates_via_to_numbers() {
1380 assert_eq!(parse_bare_time_string("11:39"), Some("11:39".to_string()));
1386 assert_eq!(parse_bare_time_string("9:5:3"), Some("09:05:03".to_string()));
1388 assert_eq!(parse_bare_time_string(" 23:59:59 "), Some("23:59:59".to_string()));
1389 assert_eq!(parse_bare_time_string("12.30"), Some("12:30".to_string()));
1391 assert_eq!(parse_bare_time_string("27:45:00"), Some("27:45:00".to_string()));
1395 assert_eq!(parse_bare_time_string("100:00"), Some("100:00".to_string()));
1396 assert_eq!(parse_bare_time_string("11:60"), None);
1399 assert_eq!(parse_bare_time_string("not a time"), None);
1401 assert_eq!(parse_bare_time_string("12"), None);
1402 assert_eq!(parse_bare_time_string("3.14"), Some("03:14".to_string()));
1407 }
1408
1409 #[test]
1410 fn test_parse_bare_time_string_converts_a_trailing_am_pm_marker_to_24_hour() {
1411 assert_eq!(parse_bare_time_string("2:30 PM"), Some("14:30".to_string()));
1414 assert_eq!(parse_bare_time_string("2:30PM"), Some("14:30".to_string()));
1415 assert_eq!(parse_bare_time_string("2:30pm"), Some("14:30".to_string()));
1416 assert_eq!(parse_bare_time_string("12:45am"), Some("00:45".to_string()));
1418 assert_eq!(parse_bare_time_string("12:00 PM"), Some("12:00".to_string()));
1419 assert_eq!(parse_bare_time_string("11:39 AM"), Some("11:39".to_string()));
1420 assert_eq!(parse_bare_time_string("11:39:05 PM"), Some("23:39:05".to_string()));
1422 assert_eq!(parse_bare_time_string("14:00pm"), Some("14:00".to_string()));
1426 assert_eq!(parse_bare_time_string("14:30pm"), Some("14:30".to_string()));
1427 assert_eq!(parse_bare_time_string("0:30am"), Some("00:30".to_string()));
1428 }
1429
1430 #[test]
1431 fn test_parse_bare_time_string_rejects_anything_else_with_letters_rather_than_guess() {
1432 assert_eq!(parse_bare_time_string("2h45m"), None);
1438 assert_eq!(parse_bare_time_string("not a time"), None);
1439 }
1440
1441 #[test]
1442 fn test_format_time_falls_back_to_a_bare_time_string_with_no_date_component() {
1443 let time_cols = vec![Column::new_format(Format::Time, None)];
1452 let time_opts = RowOptionSet::simple(&time_cols);
1453 assert_eq!(csv_cell_to_json_value("11:39", &time_opts, 0), Value::String("11:39".to_string()));
1454
1455 let hm_cols = vec![Column::new_format(Format::Hm, None)];
1456 let hm_opts = RowOptionSet::simple(&hm_cols);
1457 assert_eq!(csv_cell_to_json_value("11:39", &hm_opts, 0), Value::String("11:39".to_string()));
1458
1459 assert_eq!(
1462 csv_cell_to_json_value("2023-06-15T09:15:30", &time_opts, 0),
1463 Value::String("09:15:30".to_string())
1464 );
1465
1466 assert_eq!(process_string_value("11:39", Format::Time, None), Value::String("11:39".to_string()));
1468 assert_eq!(process_string_value("11:39", Format::Hm, None), Value::String("11:39".to_string()));
1469 }
1470
1471 #[test]
1472 fn test_decimal_to_hm_string_reads_the_fraction_as_literal_minutes_not_a_scaled_fraction() {
1473 assert_eq!(decimal_to_hm_string(12.3), Some("12:30".to_string()));
1479 assert_eq!(decimal_to_hm_string(9.05), Some("09:05".to_string()));
1480 assert_eq!(decimal_to_hm_string(0.0), Some("00:00".to_string()));
1481 assert_eq!(decimal_to_hm_string(23.59), Some("23:59".to_string()));
1482 assert_eq!(decimal_to_hm_string(24.0), Some("24:00".to_string()));
1486 assert_eq!(decimal_to_hm_string(100.3), Some("100:30".to_string()));
1487 assert_eq!(decimal_to_hm_string(12.75), None);
1491 assert_eq!(decimal_to_hm_string(-1.0), None);
1492 }
1493
1494 #[test]
1495 fn test_format_time_reconstructs_hm_from_a_decimal_disguised_time() {
1496 let time_cols = vec![Column::new_format(Format::Time, None)];
1508 let time_opts = RowOptionSet::simple(&time_cols);
1509 assert_eq!(csv_cell_to_json_value("12.30", &time_opts, 0), Value::String("12:30".to_string()));
1510 assert_eq!(csv_cell_to_json_value("12.3", &time_opts, 0), Value::String("12:03".to_string()));
1511
1512 let hm_cols = vec![Column::new_format(Format::Hm, None)];
1513 let hm_opts = RowOptionSet::simple(&hm_cols);
1514 assert_eq!(csv_cell_to_json_value("12.30", &hm_opts, 0), Value::String("12:30".to_string()));
1515
1516 assert_eq!(process_string_value("12.30", Format::Time, None), Value::String("12:30".to_string()));
1518
1519 assert_eq!(csv_cell_to_json_value("12.75", &time_opts, 0), Value::Null);
1523
1524 assert_eq!(process_float_value(12.3, Format::Time, None), Value::String("12:30".to_string()));
1530 assert_eq!(process_float_value(12.3, Format::Hm, None), Value::String("12:30".to_string()));
1531
1532 assert_eq!(process_float_value(12.75, Format::Time, None), Value::Null);
1535 }
1536
1537 #[test]
1538 fn test_format_date_recognizes_slash_separated_dates_of_any_order() {
1539 let cols = vec![Column::new_format(Format::Date, None)];
1548 let row_opts = RowOptionSet::simple(&cols);
1549 for (value, expected) in [
1550 ("19/07/2026", "2026-07-19"), ("07/19/2026", "2026-07-19"), ("2026/07/19", "2026-07-19"), ("2026-07-19", "2026-07-19"), ] {
1555 assert_eq!(
1556 csv_cell_to_json_value(value, &row_opts, 0),
1557 Value::String(expected.to_string()),
1558 "{:?} should resolve to {:?}",
1559 value,
1560 expected
1561 );
1562 }
1563 }
1564
1565 #[test]
1566 fn test_format_datetime_recognizes_slash_separated_dates_too() {
1567 let cols = vec![Column::new_format(Format::DateTime, None)];
1568 let row_opts = RowOptionSet::simple(&cols);
1569 assert_eq!(
1570 csv_cell_to_json_value("19/07/2026", &row_opts, 0),
1571 Value::String("2026-07-19T00:00:00.000Z".to_string())
1572 );
1573 assert_eq!(
1575 process_string_value("19/07/2026", Format::Date, None),
1576 Value::String("2026-07-19".to_string())
1577 );
1578 }
1579
1580 #[test]
1581 fn test_format_date_recognizes_dot_separated_dates_too() {
1582 let cols = vec![Column::new_format(Format::Date, None)];
1585 let row_opts = RowOptionSet::simple(&cols);
1586 assert_eq!(
1587 csv_cell_to_json_value("19.07.2026", &row_opts, 0),
1588 Value::String("2026-07-19".to_string())
1589 );
1590 }
1591
1592 #[test]
1593 fn test_simplify_datetime_string_drops_milliseconds_and_trailing_z() {
1594 assert_eq!(simplify_datetime_string("2026-07-18T18:07:34.000Z"), "2026-07-18T18:07:34");
1595 assert_eq!(simplify_datetime_string("2026-07-18T18:07:34"), "2026-07-18T18:07:34");
1597 }
1598
1599 #[test]
1600 fn test_row_wide_simple_mode_strips_milliseconds_and_z_from_a_native_iso_datetime_cell() {
1601 let row_opts = RowOptionSet { datetime_mode: DateTimeMode::Simple, ..Default::default() };
1602 let cell = Data::DateTimeIso("2026-07-18T18:07:34.000Z".to_string());
1603 assert_eq!(
1604 workbook_cell_to_value(&cell, &row_opts, 0),
1605 Value::String("2026-07-18T18:07:34".to_string())
1606 );
1607 }
1608
1609 #[test]
1610 fn test_csv_cell_format_datetime_simple_drops_milliseconds_and_z() {
1611 let cols = vec![Column::new_format(Format::DateTimeSimple, None)];
1615 let row_opts = RowOptionSet::simple(&cols);
1616 assert_eq!(
1617 csv_cell_to_json_value("2026-07-18T18:07:34", &row_opts, 0),
1618 Value::String("2026-07-18T18:07:34".to_string())
1619 );
1620 }
1621
1622 #[test]
1623 fn test_simple_mode_still_avoids_the_epoch_placeholder_date_for_a_genuine_time_only_excel_cell() {
1624 use rust_xlsxwriter::{ExcelDateTime, Format as XlsxFormat, Workbook};
1629
1630 let mut workbook = Workbook::new();
1631 let sheet = workbook.add_worksheet().set_name("Sheet1").unwrap();
1632 let time_fmt = XlsxFormat::new().set_num_format("hh:mm");
1633 sheet.write_string(0, 0, "start").unwrap();
1634 let breakfast_time = ExcelDateTime::from_hms(6, 30, 0).unwrap();
1635 sheet.write_time_with_format(1, 0, breakfast_time, &time_fmt).unwrap();
1636 let path = std::env::temp_dir().join("simple_mode_time_only_cell.xlsx");
1637 workbook.save(&path).unwrap();
1638
1639 let mut opts = OptionSet::new(path.to_str().unwrap());
1640 opts.rows.datetime_mode = DateTimeMode::Simple;
1641 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1642 assert_eq!(rows[0].get("start"), Some(&json!("06:30:00")));
1643 }
1644
1645 #[test]
1646 fn test_column_datetime_mode_applies_only_to_genuine_datetime_cells_under_format_auto() {
1647 let cols = vec![Column::from_key_ref_with_format(None, Format::Auto, None, DateTimeMode::HmOnly, false)];
1653 let row_opts = RowOptionSet::simple(&cols);
1654 let datetime_cell = Data::DateTimeIso("2023-06-15T09:15:30.000Z".to_string());
1655 assert_eq!(
1656 workbook_cell_to_value(&datetime_cell, &row_opts, 0),
1657 Value::String("09:15".to_string())
1658 );
1659 let string_cell = Data::String("not a date".to_string());
1660 assert_eq!(
1661 workbook_cell_to_value(&string_cell, &row_opts, 0),
1662 Value::String("not a date".to_string())
1663 );
1664 }
1665
1666 #[test]
1667 fn test_native_float_cell_decimal_format_rounds_to_the_given_precision() {
1668 let cols = vec![Column::new_format(Format::Decimal(2), None)];
1672 let row_opts = RowOptionSet::simple(&cols);
1673 let cell = Data::Float(19.98765);
1674 assert_eq!(workbook_cell_to_value(&cell, &row_opts, 0), json!(19.99));
1675 }
1676
1677 #[test]
1678 fn test_csv_cell_integer_format_truncates_decimal_values() {
1679 let cols = vec![Column::new_format(Format::Integer, None)];
1683 let row_opts = RowOptionSet::simple(&cols);
1684 assert_eq!(csv_cell_to_json_value("58.2", &row_opts, 0), Value::Number(Number::from(58)));
1685 assert_eq!(csv_cell_to_json_value("82.5", &row_opts, 0), Value::Number(Number::from(82)));
1686 assert_eq!(csv_cell_to_json_value("100", &row_opts, 0), Value::Number(Number::from(100)));
1687 }
1688
1689 #[test]
1690 fn test_csv_cell_decimal_format_rounds_to_the_given_precision() {
1691 let cols = vec![Column::new_format(Format::Decimal(2), None)];
1695 let row_opts = RowOptionSet::simple(&cols);
1696 assert_eq!(csv_cell_to_json_value("19.98765", &row_opts, 0), json!(19.99));
1697 assert_eq!(csv_cell_to_json_value("5.4", &row_opts, 0), json!(5.4));
1698 assert_eq!(csv_cell_to_json_value("100", &row_opts, 0), json!(100.0));
1699 }
1700
1701 #[test]
1702 fn test_csv_cell_does_not_coerce_ids_to_booleans() {
1703 let row_opts = RowOptionSet::default();
1708 assert_eq!(csv_cell_to_json_value("SKU001", &row_opts, 0), Value::String("SKU001".to_string()));
1709 assert_eq!(csv_cell_to_json_value("A1", &row_opts, 0), Value::String("A1".to_string()));
1710 assert_eq!(csv_cell_to_json_value("01/06/2024", &row_opts, 0), Value::String("01/06/2024".to_string()));
1711 assert_eq!(csv_cell_to_json_value("true", &row_opts, 0), Value::Bool(true));
1713 assert_eq!(csv_cell_to_json_value("false", &row_opts, 0), Value::Bool(false));
1714 }
1715
1716 #[test]
1717 fn test_direct_processing_csv() {
1718 let sample_path = "data/sample-data-1.csv";
1719
1720 let opts = OptionSet::new(sample_path).max_row_count(1_000);
1723
1724 let result = process_spreadsheet_direct(&opts);
1725
1726 assert_eq!(result.unwrap().num_rows, 401);
1728 }
1729
1730 fn write_csv_fixture(filename: &str, content: &str) -> String {
1734 let path = std::env::temp_dir().join(filename);
1735 std::fs::write(&path, content).unwrap();
1736 path.to_string_lossy().to_string()
1737 }
1738
1739 #[test]
1740 fn test_csv_header_row_and_data_row_index_skip_a_gap() {
1741 let path = write_csv_fixture(
1744 "csv_header_gap.csv",
1745 "Report Title\nGenerated 2026-01-01\nsku,qty\n,\nSKU001,10\nSKU002,20\n",
1746 );
1747
1748 let opts = OptionSet::new(&path).header_row(2);
1751 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1752 assert_eq!(rows.len(), 3, "gap row, SKU001, SKU002");
1753 assert_eq!(rows[0].get("sku"), Some(&json!("")));
1754
1755 let opts = OptionSet::new(&path).header_row(2).data_row_index(4);
1757 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1758 assert_eq!(rows.len(), 2, "just SKU001 and SKU002");
1759 assert_eq!(rows[0].get("sku"), Some(&json!("SKU001")));
1760 assert_eq!(rows[1].get("sku"), Some(&json!("SKU002")));
1761 }
1762
1763 #[test]
1764 fn test_csv_omit_header_uses_fallback_keys_not_empty_rows() {
1765 let path = write_csv_fixture("csv_omit_header.csv", "SKU001,10\nSKU002,20\n");
1770 let opts = OptionSet::new(&path).omit_header();
1771 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1772 assert_eq!(rows.len(), 2, "both rows present, including the former row 0");
1773 assert_eq!(rows[0].get("a"), Some(&json!("SKU001")));
1774 assert_eq!(rows[0].get("b"), Some(&json!(10)));
1775 assert_eq!(rows[1].get("a"), Some(&json!("SKU002")));
1776 }
1777
1778 #[test]
1779 fn test_csv_header_row_equals_data_row_index_for_predefined_headers() {
1780 let path = write_csv_fixture(
1784 "csv_predefined_headers.csv",
1785 "Report Title\nGenerated 2026-01-01\nSKU001,10\nSKU002,20\n",
1786 );
1787 let mut opts = OptionSet::new(&path).header_row(2).data_row_index(2).omit_header();
1788 opts.rows.columns = vec![
1789 Column::new(Some("sku")),
1790 Column::new(Some("qty")),
1791 ];
1792 let rows = process_spreadsheet_direct(&opts).unwrap().to_vec();
1793 assert_eq!(rows.len(), 2, "both data rows present, notes rows skipped");
1794 assert_eq!(rows[0].get("sku"), Some(&json!("SKU001")));
1795 assert_eq!(rows[0].get("qty"), Some(&json!(10)));
1796 assert_eq!(rows[1].get("sku"), Some(&json!("SKU002")));
1797 }
1798
1799 #[test]
1800 fn test_multisheet_preview_ods() {
1801 let sample_path = "data/sample-data-2.ods";
1802
1803 let opts = OptionSet::new(sample_path)
1808 .max_row_count(10)
1809 .read_mode_preview();
1810
1811 let result = process_spreadsheet_direct(&opts);
1812
1813 let dataset = result.unwrap();
1815 assert_eq!(dataset.sheets.len(), 2);
1816 assert_eq!(dataset.num_rows, 118);
1818
1819 assert_eq!(dataset.data.first_sheet().len(), 10);
1821 }
1822
1823 #[test]
1824 fn test_column_override_1() {
1825 let sample_json = json!({
1826 "sku": "CHAIR16",
1827 "height": "112cm",
1828 "width": "69cm",
1829 "approved": "Y"
1830 });
1831
1832 let rows = json_object_to_calamine_data(sample_json);
1833
1834 let cols = vec![
1835 Column::new_format(Format::Text, Some(string_value(""))),
1836 Column::new_format(Format::Float, Some(float_value(95.0))),
1837 Column::new_format(Format::Float, Some(float_value(65.0))),
1838 Column::new_format(Format::Truthy, Some(bool_value(false))),
1839 ];
1840
1841 let opts = &RowOptionSet::simple(&cols);
1843 let result = workbook_row_to_values(&rows, opts);
1844 assert_eq!(result.get(1).unwrap(), 112.0);
1846 assert_eq!(result.get(2).unwrap(), 69.0);
1848 assert_eq!(result.get(3).unwrap(), true);
1850 }
1851
1852 #[test]
1853 fn test_column_override_2() {
1854 let sample_json = json!({
1855 "name": "Sophia",
1856 "dob": "2001-9-23",
1857 "weight": "62kg",
1858 "result": "GOOD"
1859 });
1860
1861 let rows = json_object_to_calamine_data(sample_json);
1862
1863 let cols = vec![
1864 Column::new_format(Format::Text, None),
1865 Column::new_format(Format::Date, None),
1866 Column::new_format(Format::Float, None),
1867 Column::new_format(
1869 Format::truthy_custom("good", "bad"),
1870 Some(bool_value(false)),
1871 ),
1872 ];
1873
1874 let opts = &RowOptionSet::simple(&cols);
1876 let result = workbook_row_to_values(&rows, opts);
1877 assert_eq!(result.get(1).unwrap(), "2001-09-23");
1878 assert_eq!(result.get(2).unwrap(), 62.0);
1879 assert_eq!(result.get(3).unwrap(), true);
1880 }
1881
1882 #[tokio::test]
1883 async fn test_read_workbook_info() {
1884 let sample_path = "data/sample-data-1.xlsx";
1885 let path_data = PathData::new(path::Path::new(sample_path));
1886 let info = read_workbook_sheet_info(&path_data).await;
1887 assert!(info.is_ok());
1888 }
1889
1890 #[tokio::test]
1891 async fn test_large_csv_file() {
1892 let sample_path = "data/large-datasheet.csv";
1893 let max_rows = 100_000;
1894 let opts = OptionSet::new(sample_path).max_row_count(max_rows);
1895 let result = process_spreadsheet_core(&opts, None, None).await;
1896 if let Ok(data) = result.clone() {
1897 assert_eq!(data.data.first_sheet().len(), max_rows as usize);
1898 } else {
1899 panic!("Failed to process large CSV file");
1900 }
1901 assert!(result.is_ok());
1902 }
1903
1904 #[tokio::test]
1905 async fn test_medium_excel_file() {
1906 let sample_path = "data/medium-spreadsheet-50_000.xlsx";
1907 let max_rows = 5_000;
1908 let opts = OptionSet::new(sample_path).max_row_count(max_rows);
1909 let result = process_spreadsheet_core(&opts, None, None).await;
1910 if let Ok(data) = result.clone() {
1911 assert_eq!(data.data.first_sheet().len(), max_rows as usize);
1912 } else {
1913 panic!("Failed to process large Excel file");
1914 }
1915 assert!(result.is_ok());
1916 }
1917}