1use std::path::Path;
2
3use anyhow::{Result, bail};
4use chrono::{NaiveDate, NaiveDateTime};
5
6use crate::clipboard;
7use crate::{
8 ExtractionOptions, HeaderCase, InputNormalizationOptions, QueryValue, TypeInferenceOptions,
9};
10
11pub(crate) mod csv;
12pub(crate) mod feather;
13pub(crate) mod html;
14pub(crate) mod json;
15pub(crate) mod jsonl;
16pub(crate) mod markdown;
17pub(crate) mod parquet;
18pub(crate) mod xlsx;
19pub(crate) mod xml;
20
21#[derive(Debug)]
23pub struct SheetData {
24 pub original_name: String,
26 pub columns: Vec<String>,
28 pub rows: Vec<Vec<QueryValue>>,
30}
31
32pub fn load_input(
33 input_path: &Path,
34 requested_sheet: Option<&str>,
35 inference_options: &TypeInferenceOptions,
36 extraction_options: &ExtractionOptions,
37 has_headers: bool,
38 explicit_format: Option<&str>,
39) -> Result<SheetData> {
40 if clipboard::is_clipboard_path(input_path) {
41 let fmt = explicit_format
43 .or_else(|| input_path.extension().and_then(|e| e.to_str()))
44 .unwrap_or("");
45
46 let is_supported = ["csv", "json", "jsonl", "ndjson", "md", "markdown", "html", "htm", "xml"]
49 .iter()
50 .any(|candidate| fmt.eq_ignore_ascii_case(candidate));
51 if !is_supported {
52 bail!(
53 "clipboard input {} requires a format specifier such as @clipboard|csv, \
54 @clipboard|json, @clipboard|jsonl, @clipboard|md, @clipboard|html, or \
55 @clipboard|xml",
56 input_path.display()
57 );
58 }
59
60 let content = clipboard::read_text(input_path)?;
61
62 if fmt.eq_ignore_ascii_case("xml") {
63 return xml::load_xml_sheet_from_str(
64 input_path,
65 &content,
66 requested_sheet,
67 inference_options,
68 extraction_options,
69 );
70 }
71
72 if fmt.eq_ignore_ascii_case("csv") {
73 return csv::load_csv_sheet_from_str(
74 input_path,
75 &content,
76 requested_sheet,
77 inference_options,
78 has_headers,
79 );
80 }
81
82 if fmt.eq_ignore_ascii_case("jsonl") || fmt.eq_ignore_ascii_case("ndjson") {
83 return jsonl::load_jsonl_sheet_from_str(
84 input_path,
85 &content,
86 requested_sheet,
87 inference_options,
88 );
89 }
90
91 if fmt.eq_ignore_ascii_case("json") {
92 return json::load_json_sheet_from_str(
93 input_path,
94 &content,
95 requested_sheet,
96 inference_options,
97 extraction_options,
98 );
99 }
100
101 if fmt.eq_ignore_ascii_case("md") || fmt.eq_ignore_ascii_case("markdown") {
102 return markdown::load_markdown_sheet_from_str(
103 input_path,
104 &content,
105 requested_sheet,
106 inference_options,
107 has_headers,
108 );
109 }
110
111 if fmt.eq_ignore_ascii_case("html") || fmt.eq_ignore_ascii_case("htm") {
112 return html::load_html_sheet_from_str(
113 input_path,
114 &content,
115 requested_sheet,
116 inference_options,
117 has_headers,
118 );
119 }
120
121 unreachable!("clipboard format already validated above");
124 }
125
126 let fmt = explicit_format
128 .or_else(|| input_path.extension().and_then(|e| e.to_str()))
129 .unwrap_or("");
130
131 if fmt.eq_ignore_ascii_case("feather") {
132 return feather::load_feather_sheet(input_path, requested_sheet, inference_options);
133 }
134
135 if fmt.eq_ignore_ascii_case("parquet") {
136 return parquet::load_parquet_sheet(input_path, requested_sheet, inference_options);
137 }
138
139 if fmt.eq_ignore_ascii_case("xml") {
140 return xml::load_xml_sheet(
141 input_path,
142 requested_sheet,
143 inference_options,
144 extraction_options,
145 );
146 }
147
148 if fmt.eq_ignore_ascii_case("csv") {
149 return csv::load_csv_sheet(input_path, requested_sheet, inference_options, has_headers);
150 }
151
152 if fmt.eq_ignore_ascii_case("jsonl") || fmt.eq_ignore_ascii_case("ndjson") {
153 return jsonl::load_jsonl_sheet(input_path, requested_sheet, inference_options);
154 }
155
156 if fmt.eq_ignore_ascii_case("json") {
157 return json::load_json_sheet(
158 input_path,
159 requested_sheet,
160 inference_options,
161 extraction_options,
162 );
163 }
164
165 if fmt.eq_ignore_ascii_case("md") || fmt.eq_ignore_ascii_case("markdown") {
166 return markdown::load_markdown_sheet(
167 input_path,
168 requested_sheet,
169 inference_options,
170 has_headers,
171 );
172 }
173
174 if fmt.eq_ignore_ascii_case("html") || fmt.eq_ignore_ascii_case("htm") {
175 return html::load_html_sheet(input_path, requested_sheet, inference_options, has_headers);
176 }
177
178 xlsx::load_xlsx_sheet(input_path, requested_sheet, inference_options, has_headers)
179}
180
181pub(crate) fn apply_input_normalization(
182 sheet: &mut SheetData,
183 options: &InputNormalizationOptions,
184) {
185 if options.trim {
186 for column in &mut sheet.columns {
187 *column = column.trim().to_owned();
188 }
189
190 for row in &mut sheet.rows {
191 for value in row {
192 if let QueryValue::Text(text) = value {
193 let trimmed = text.trim();
194 if trimmed.is_empty() {
195 *value = QueryValue::Null;
196 } else {
197 *text = trimmed.to_owned();
198 }
199 }
200 }
201 }
202 }
203
204 if options.normalize_headers {
205 sheet.columns = sheet
206 .columns
207 .iter()
208 .enumerate()
209 .map(|(index, header)| {
210 normalize_header_value(header, index + 1, options.header_case.as_ref())
211 })
212 .collect();
213 }
214
215 if options.dedupe_headers {
216 dedupe_headers(&mut sheet.columns);
217 }
218
219 if options.skip_empty_rows {
220 sheet.rows.retain(|row| !is_row_empty(row));
221 }
222}
223
224pub(crate) fn normalize_header_value(
225 header: &str,
226 index: usize,
227 header_case: Option<&HeaderCase>,
228) -> String {
229 let normalized = header
230 .trim()
231 .chars()
232 .map(|character| {
233 if character.is_alphanumeric() || character == '_' {
234 character
235 } else {
236 '_'
237 }
238 })
239 .collect::<String>();
240 let collapsed = collapse_underscores(&normalized)
241 .trim_matches('_')
242 .to_owned();
243
244 if collapsed.is_empty() {
245 return format!("column{index}");
246 }
247
248 let mut value = collapsed;
249
250 if matches!(header_case, Some(HeaderCase::Snake)) {
251 value = value.to_ascii_lowercase();
252 } else if matches!(header_case, Some(HeaderCase::ScreamingSnake)) {
253 value = value.to_ascii_uppercase();
254 } else if matches!(header_case, Some(HeaderCase::Camel)) {
255 value = to_camel_case(&value);
256 } else if matches!(header_case, Some(HeaderCase::Pascal)) {
257 value = to_pascal_case(&value);
258 }
259
260 value
261}
262
263fn to_camel_case(value: &str) -> String {
266 let mut result = String::with_capacity(value.len());
267 for (i, word) in value.split('_').enumerate() {
268 if word.is_empty() {
269 continue;
270 }
271 if i == 0 {
272 result.push_str(&word.to_ascii_lowercase());
273 } else {
274 let mut chars = word.chars();
275 if let Some(first) = chars.next() {
276 result.push(first.to_ascii_uppercase());
277 result.push_str(&chars.as_str().to_ascii_lowercase());
278 }
279 }
280 }
281 result
282}
283
284fn to_pascal_case(value: &str) -> String {
287 let mut result = String::with_capacity(value.len());
288 for word in value.split('_') {
289 if word.is_empty() {
290 continue;
291 }
292 let mut chars = word.chars();
293 if let Some(first) = chars.next() {
294 result.push(first.to_ascii_uppercase());
295 result.push_str(&chars.as_str().to_ascii_lowercase());
296 }
297 }
298 result
299}
300
301fn collapse_underscores(value: &str) -> String {
302 let mut output = String::with_capacity(value.len());
303 let mut previous_was_underscore = false;
304
305 for character in value.chars() {
306 if character == '_' {
307 if !previous_was_underscore {
308 output.push(character);
309 }
310 previous_was_underscore = true;
311 } else {
312 output.push(character);
313 previous_was_underscore = false;
314 }
315 }
316
317 output
318}
319
320pub(crate) fn dedupe_headers(headers: &mut [String]) {
321 let mut seen = std::collections::HashMap::new();
322 let mut assigned = std::collections::HashSet::new();
323
324 for header in headers {
325 let base = header.clone();
326 let count = seen
327 .entry(base.clone())
328 .and_modify(|count| *count += 1)
329 .or_insert(1usize);
330
331 let mut candidate = if *count == 1 {
332 base.clone()
333 } else {
334 format!("{base}_{count}")
335 };
336
337 while assigned.contains(&candidate) {
338 *count += 1;
339 candidate = format!("{base}_{count}");
340 }
341
342 assigned.insert(candidate.clone());
343 *header = candidate;
344 }
345}
346
347fn is_row_empty(row: &[QueryValue]) -> bool {
348 row.iter().all(|value| match value {
349 QueryValue::Null => true,
350 QueryValue::Text(text) => text.trim().is_empty(),
351 QueryValue::Integer(_) | QueryValue::Real(_) => false,
352 })
353}
354
355pub(super) fn normalize_text_headers(headers: &[String]) -> Vec<String> {
356 let mut seen = std::collections::HashMap::new();
357
358 headers
359 .iter()
360 .enumerate()
361 .map(|(index, header)| {
362 let base = if header.trim().is_empty() {
363 format!("column{}", index + 1)
364 } else {
365 header.clone()
366 };
367
368 let count = seen
369 .entry(base.clone())
370 .and_modify(|count| *count += 1)
371 .or_insert(1usize);
372
373 if *count == 1 {
374 base
375 } else {
376 format!("{base}_{count}")
377 }
378 })
379 .collect()
380}
381
382pub(super) fn rows_maps_to_sheet_data(
383 rows_maps: Vec<Vec<(String, QueryValue)>>,
384 original_name: String,
385) -> SheetData {
386 let mut columns = Vec::<String>::new();
387 for row in &rows_maps {
388 for (column, _) in row {
389 if !columns.iter().any(|existing| existing == column) {
390 columns.push(column.clone());
391 }
392 }
393 }
394
395 let rows = rows_maps
396 .into_iter()
397 .map(|row| {
398 columns
399 .iter()
400 .map(|column| {
401 row.iter()
402 .find(|(key, _)| key == column)
403 .map(|(_, value)| value.clone())
404 .unwrap_or(QueryValue::Null)
405 })
406 .collect::<Vec<_>>()
407 })
408 .collect::<Vec<_>>();
409
410 SheetData {
411 original_name,
412 columns,
413 rows,
414 }
415}
416
417pub(super) fn parse_scalar_value(
418 raw: &str,
419 inference_options: &TypeInferenceOptions,
420) -> QueryValue {
421 let trimmed = raw.trim();
422 if matches_token(trimmed, &inference_options.null_values) {
423 return QueryValue::Null;
424 }
425
426 if !inference_options.infer_types {
427 return QueryValue::Text(raw.to_owned());
428 }
429
430 if matches_token(trimmed, &inference_options.true_values) {
431 return QueryValue::Integer(1);
432 }
433
434 if matches_token(trimmed, &inference_options.false_values) {
435 return QueryValue::Integer(0);
436 }
437
438 if let Ok(value) = trimmed.parse::<i64>() {
439 return QueryValue::Integer(value);
440 }
441
442 if let Some(value) = parse_decimal_value(trimmed, inference_options.decimal_comma) {
443 return QueryValue::Real(value);
444 }
445
446 if let Some(value) = parse_date_value(trimmed, inference_options.date_format.as_deref()) {
447 return QueryValue::Text(value);
448 }
449
450 QueryValue::Text(raw.to_owned())
451}
452
453fn matches_token(raw: &str, candidates: &[String]) -> bool {
454 candidates
455 .iter()
456 .any(|candidate| raw.eq_ignore_ascii_case(candidate.trim()))
457}
458
459fn parse_decimal_value(raw: &str, decimal_comma: bool) -> Option<f64> {
460 if let Ok(value) = raw.parse::<f64>() {
461 return Some(value);
462 }
463
464 if !decimal_comma {
465 return None;
466 }
467
468 let compact = raw.replace(' ', "");
469 if !compact.contains(',') {
470 return None;
471 }
472
473 let normalized = if compact.contains('.') {
474 compact.replace('.', "").replace(',', ".")
475 } else {
476 compact.replace(',', ".")
477 };
478
479 normalized.parse::<f64>().ok()
480}
481
482fn parse_date_value(raw: &str, date_format: Option<&str>) -> Option<String> {
483 let format = date_format?;
484
485 if let Ok(value) = NaiveDate::parse_from_str(raw, format) {
486 return Some(value.format("%Y-%m-%d").to_string());
487 }
488
489 if let Ok(value) = NaiveDateTime::parse_from_str(raw, format) {
490 return Some(value.format("%Y-%m-%d %H:%M:%S").to_string());
491 }
492
493 None
494}
495
496pub(super) fn apply_inference_overrides(
497 value: QueryValue,
498 inference_options: &TypeInferenceOptions,
499) -> QueryValue {
500 if inference_options.infer_types {
501 return value;
502 }
503
504 match value {
505 QueryValue::Null => QueryValue::Null,
506 QueryValue::Integer(number) => QueryValue::Text(number.to_string()),
507 QueryValue::Real(number) => QueryValue::Text(number.to_string()),
508 QueryValue::Text(text) => QueryValue::Text(text),
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 #[test]
517 fn normalizes_special_character_headers_to_column_name() {
518 assert_eq!(
519 normalize_header_value(" !!! ", 3, Some(&HeaderCase::Snake)),
520 "column3"
521 );
522 }
523
524 #[test]
525 fn header_case_snake_lowercases_all() {
526 assert_eq!(
527 normalize_header_value("First_Name", 1, Some(&HeaderCase::Snake)),
528 "first_name"
529 );
530 assert_eq!(
531 normalize_header_value("PRODUCT ID", 1, Some(&HeaderCase::Snake)),
532 "product_id"
533 );
534 }
535
536 #[test]
537 fn header_case_camel_produces_camel_case() {
538 assert_eq!(
539 normalize_header_value("first_name", 1, Some(&HeaderCase::Camel)),
540 "firstName"
541 );
542 assert_eq!(
543 normalize_header_value("PRODUCT ID", 1, Some(&HeaderCase::Camel)),
544 "productId"
545 );
546 assert_eq!(
547 normalize_header_value("product", 1, Some(&HeaderCase::Camel)),
548 "product"
549 );
550 }
551
552 #[test]
553 fn header_case_pascal_produces_pascal_case() {
554 assert_eq!(
555 normalize_header_value("first_name", 1, Some(&HeaderCase::Pascal)),
556 "FirstName"
557 );
558 assert_eq!(
559 normalize_header_value("PRODUCT ID", 1, Some(&HeaderCase::Pascal)),
560 "ProductId"
561 );
562 assert_eq!(
563 normalize_header_value("product", 1, Some(&HeaderCase::Pascal)),
564 "Product"
565 );
566 }
567
568 #[test]
569 fn header_case_screaming_snake_uppercases_all() {
570 assert_eq!(
571 normalize_header_value("first_name", 1, Some(&HeaderCase::ScreamingSnake)),
572 "FIRST_NAME"
573 );
574 assert_eq!(
575 normalize_header_value("PRODUCT ID", 1, Some(&HeaderCase::ScreamingSnake)),
576 "PRODUCT_ID"
577 );
578 }
579
580 #[test]
581 fn header_case_fallback_to_column_index_for_empty() {
582 assert_eq!(
583 normalize_header_value(" !!! ", 3, Some(&HeaderCase::Camel)),
584 "column3"
585 );
586 assert_eq!(
587 normalize_header_value(" !!! ", 5, Some(&HeaderCase::Pascal)),
588 "column5"
589 );
590 assert_eq!(
591 normalize_header_value(" !!! ", 2, Some(&HeaderCase::ScreamingSnake)),
592 "column2"
593 );
594 }
595
596 #[test]
597 fn dedupe_headers_handles_generated_name_collisions() {
598 let mut headers = vec![
599 "name".to_owned(),
600 "name".to_owned(),
601 "name_2".to_owned(),
602 "name".to_owned(),
603 ];
604
605 dedupe_headers(&mut headers);
606
607 assert_eq!(headers, vec!["name", "name_2", "name_2_2", "name_3"]);
608 }
609}