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