Skip to main content

query_forge/input/formats/
mod.rs

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/// Raw tabular data loaded from a single input sheet or file.
22#[derive(Debug)]
23pub struct SheetData {
24    /// Original name of the sheet, file, or tag as returned by the format loader.
25    pub original_name: String,
26    /// Column names in declaration order.
27    pub columns: Vec<String>,
28    /// Data rows; each row is a parallel `Vec` aligned with `columns`.
29    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        // Determine format: explicit |type takes priority, then fall back to extension.
42        let fmt = explicit_format
43            .or_else(|| input_path.extension().and_then(|e| e.to_str()))
44            .unwrap_or("");
45
46        // Validate format before reading clipboard content, to give a clear error
47        // message without consuming the clipboard unnecessarily.
48        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        // All valid formats are handled above; this is unreachable after the early
122        // validation check, but the compiler requires an exhaustive exit path.
123        unreachable!("clipboard format already validated above");
124    }
125
126    // Determine effective format: explicit |type takes priority over extension.
127    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
263/// Split `value` on `_` boundaries, lowercase every word, then join with the
264/// first word unchanged and all subsequent words title-cased.  Produces camelCase.
265fn 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
284/// Split `value` on `_` boundaries, lowercase every word, then join with all
285/// words title-cased.  Produces PascalCase.
286fn 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}