1use std::{
2 collections::HashSet,
3 fmt::Write as _,
4 fs,
5 sync::OnceLock,
6};
7use std::path::Path;
8
9use anyhow::{Context, Result, anyhow, bail};
10use calamine::{Data, Reader, open_workbook_auto};
11use regex::Regex;
12use roxmltree::{Document, Node};
13use serde_json::Value as JsonValue;
14use rusqlite::{
15 Connection, params_from_iter,
16 types::{Value, ValueRef},
17};
18use rust_xlsxwriter::Workbook;
19
20#[derive(Debug, Clone, PartialEq)]
21pub enum QueryValue {
22 Null,
23 Integer(i64),
24 Real(f64),
25 Text(String),
26}
27
28#[derive(Debug, Clone, PartialEq)]
29pub struct QueryResult {
30 pub columns: Vec<String>,
31 pub rows: Vec<Vec<QueryValue>>,
32}
33
34#[derive(Debug, Clone, PartialEq)]
35pub struct QueryParam {
36 pub name: String,
37 pub value: QueryValue,
38}
39
40#[derive(Debug, Clone, Copy)]
41pub struct WorkbookInput<'a> {
42 pub path: &'a Path,
43 pub sheet_name: Option<&'a str>,
44 pub table_name: Option<&'a str>,
45}
46
47#[derive(Debug, Clone, PartialEq)]
49pub enum InferredType {
50 Integer,
51 Real,
52 Text,
53 Mixed,
55}
56
57impl InferredType {
58 pub fn as_str(&self) -> &'static str {
59 match self {
60 InferredType::Integer => "INTEGER",
61 InferredType::Real => "REAL",
62 InferredType::Text => "TEXT",
63 InferredType::Mixed => "MIXED",
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct ColumnInfo {
71 pub table_name: String,
72 pub column_name: String,
73 pub inferred_type: InferredType,
74 pub nullable: bool,
75}
76
77#[derive(Debug, Clone)]
79pub struct ColumnStats {
80 pub distinct_count: usize,
81 pub min_value: Option<String>,
82 pub max_value: Option<String>,
83}
84
85#[derive(Debug, Clone)]
87pub struct TableSummary {
88 pub table_name: String,
90 pub source_path: String,
92 pub source_selector: Option<String>,
94 pub row_count: usize,
95 pub columns: Vec<ColumnInfo>,
96 pub sample_rows: Vec<Vec<QueryValue>>,
98 pub warnings: Vec<String>,
100 pub column_stats: Option<Vec<ColumnStats>>,
102}
103
104pub fn load_table_summaries(
108 workbook_inputs: &[WorkbookInput<'_>],
109 sample: usize,
110 include_stats: bool,
111) -> Result<Vec<TableSummary>> {
112 let mut summaries = Vec::with_capacity(workbook_inputs.len());
113
114 for (index, workbook_input) in workbook_inputs.iter().enumerate() {
115 let sheet = load_input(workbook_input.path, workbook_input.sheet_name, true)?;
116
117 let table_name = workbook_input.table_name.map(str::to_owned).unwrap_or_else(|| {
118 if index == 0 { "table".to_owned() } else { format!("table{}", index + 1) }
119 });
120
121 let columns = infer_column_infos(&table_name, &sheet);
122 let mut warnings = collect_warnings(&table_name, &sheet, &columns);
123
124 let sample_rows = sheet.rows.iter().take(sample).cloned().collect();
125
126 let column_stats = if include_stats {
127 Some(compute_column_stats(&sheet))
128 } else {
129 None
130 };
131
132 if sheet.rows.is_empty() {
134 warnings.push(format!(
135 "table '{}' loaded from '{}' contains no data rows",
136 table_name,
137 workbook_input.path.display()
138 ));
139 }
140
141 summaries.push(TableSummary {
142 table_name,
143 source_path: workbook_input.path.display().to_string(),
144 source_selector: workbook_input.sheet_name.map(str::to_owned),
145 row_count: sheet.rows.len(),
146 columns,
147 sample_rows,
148 warnings,
149 column_stats,
150 });
151 }
152
153 Ok(summaries)
154}
155
156fn infer_column_infos(table_name: &str, sheet: &SheetData) -> Vec<ColumnInfo> {
158 sheet
159 .columns
160 .iter()
161 .enumerate()
162 .map(|(col_idx, col_name)| {
163 let mut has_integer = false;
164 let mut has_real = false;
165 let mut has_text = false;
166 let mut has_null = false;
167
168 for row in &sheet.rows {
169 match row.get(col_idx).unwrap_or(&QueryValue::Null) {
170 QueryValue::Null => has_null = true,
171 QueryValue::Integer(_) => has_integer = true,
172 QueryValue::Real(_) => has_real = true,
173 QueryValue::Text(_) => has_text = true,
174 }
175 }
176
177 let inferred_type = match (has_integer, has_real, has_text) {
178 (true, false, false) => InferredType::Integer,
179 (false, false, true) => InferredType::Text,
180 (false, false, false) => InferredType::Text,
181 (_, true, false) => InferredType::Real,
184 _ => InferredType::Mixed,
185 };
186
187 ColumnInfo {
188 table_name: table_name.to_owned(),
189 column_name: col_name.clone(),
190 inferred_type,
191 nullable: has_null,
192 }
193 })
194 .collect()
195}
196
197fn collect_warnings(
199 table_name: &str,
200 sheet: &SheetData,
201 columns: &[ColumnInfo],
202) -> Vec<String> {
203 let mut warnings = Vec::new();
204
205 for col_info in columns {
206 if col_info.inferred_type == InferredType::Mixed {
207 warnings.push(format!(
208 "column '{}' in table '{}' has mixed types (INTEGER/REAL and TEXT values)",
209 col_info.column_name, table_name
210 ));
211 }
212 }
213
214 let mut seen_names: std::collections::HashMap<String, usize> =
216 std::collections::HashMap::new();
217 for col in &sheet.columns {
218 *seen_names.entry(col.clone()).or_insert(0) += 1;
219 }
220 for (name, count) in &seen_names {
221 if *count > 1 {
222 warnings.push(format!(
223 "column name '{}' appears {} times in table '{}'; duplicates were renamed",
224 name, count, table_name
225 ));
226 }
227 }
228
229 warnings
230}
231
232fn compute_column_stats(sheet: &SheetData) -> Vec<ColumnStats> {
234 sheet
235 .columns
236 .iter()
237 .enumerate()
238 .map(|(col_idx, _)| {
239 let mut distinct: std::collections::HashSet<String> =
240 std::collections::HashSet::new();
241 let mut min_num: Option<f64> = None;
244 let mut max_num: Option<f64> = None;
245 let mut min_as_int: Option<i64> = None;
248 let mut max_as_int: Option<i64> = None;
249
250 for row in &sheet.rows {
251 let value = row.get(col_idx).unwrap_or(&QueryValue::Null);
252 if matches!(value, QueryValue::Null) {
253 continue;
254 }
255 distinct.insert(display_value(value));
256
257 let (as_f64, as_int) = match value {
258 QueryValue::Integer(n) => (*n as f64, Some(*n)),
259 QueryValue::Real(n) => (*n, None),
260 _ => continue,
261 };
262
263 if min_num.map_or(true, |m| as_f64 < m) {
264 min_num = Some(as_f64);
265 min_as_int = as_int;
266 }
267 if max_num.map_or(true, |m| as_f64 > m) {
268 max_num = Some(as_f64);
269 max_as_int = as_int;
270 }
271 }
272
273 let min_value = min_num.map(|v| {
274 min_as_int.map_or_else(|| v.to_string(), |i| i.to_string())
275 });
276 let max_value = max_num.map(|v| {
277 max_as_int.map_or_else(|| v.to_string(), |i| i.to_string())
278 });
279
280 ColumnStats {
281 distinct_count: distinct.len(),
282 min_value,
283 max_value,
284 }
285 })
286 .collect()
287}
288
289pub fn run_query(
290 workbook_path: &Path,
291 sheet_name: Option<&str>,
292 query: &str,
293 has_headers: bool,
294) -> Result<QueryResult> {
295 run_query_with_params(workbook_path, sheet_name, query, &[], has_headers)
296}
297
298pub fn run_query_with_params(
299 workbook_path: &Path,
300 sheet_name: Option<&str>,
301 query: &str,
302 params: &[QueryParam],
303 has_headers: bool,
304) -> Result<QueryResult> {
305 run_query_with_params_multi(&[workbook_path], sheet_name, query, params, has_headers)
306}
307
308pub fn run_query_with_params_multi(
309 workbook_paths: &[&Path],
310 sheet_name: Option<&str>,
311 query: &str,
312 params: &[QueryParam],
313 has_headers: bool,
314) -> Result<QueryResult> {
315 let workbook_inputs = workbook_paths
316 .iter()
317 .map(|path| WorkbookInput {
318 path,
319 sheet_name,
320 table_name: None,
321 })
322 .collect::<Vec<_>>();
323
324 run_query_with_params_multi_inputs(&workbook_inputs, query, params, has_headers)
325}
326
327pub fn run_query_with_params_multi_inputs(
328 workbook_inputs: &[WorkbookInput<'_>],
329 query: &str,
330 params: &[QueryParam],
331 has_headers: bool,
332) -> Result<QueryResult> {
333 if workbook_inputs.is_empty() {
334 bail!("at least one workbook input is required");
335 }
336
337 let connection =
338 Connection::open_in_memory().context("failed to create in-memory SQLite database")?;
339 let mut registered_sheet_views = HashSet::new();
340
341 for (index, workbook_input) in workbook_inputs.iter().enumerate() {
342 let sheet = load_input(
343 workbook_input.path,
344 workbook_input.sheet_name,
345 has_headers,
346 )?;
347 let table_name = workbook_input.table_name.map(str::to_owned).unwrap_or_else(|| {
348 if index == 0 {
349 "table".to_owned()
350 } else {
351 format!("table{}", index + 1)
352 }
353 });
354
355 register_sheet(
356 &connection,
357 &sheet,
358 &table_name,
359 &mut registered_sheet_views,
360 )?;
361 }
362
363 execute_query(&connection, query, params)
364}
365
366pub fn render_text(result: &QueryResult) -> String {
367 if result.columns.is_empty() {
368 return String::new();
369 }
370
371 let mut column_widths = result.columns.iter().map(|column| column.len()).collect::<Vec<_>>();
372 let mut rendered_rows = Vec::with_capacity(result.rows.len());
373
374 for row in &result.rows {
375 let mut rendered_row = Vec::with_capacity(result.columns.len());
376 for index in 0..result.columns.len() {
377 let rendered_value = row
378 .get(index)
379 .map(display_value)
380 .unwrap_or_default();
381 column_widths[index] = column_widths[index].max(rendered_value.len());
382 rendered_row.push(rendered_value);
383 }
384 rendered_rows.push(rendered_row);
385 }
386
387 let mut output = String::new();
388 write_aligned_row(&mut output, &result.columns, &column_widths);
389 output.push('\n');
390 output.push_str(
391 &column_widths
392 .iter()
393 .map(|width| "-".repeat(*width))
394 .collect::<Vec<_>>()
395 .join("-+-"),
396 );
397
398 for row in rendered_rows {
399 output.push('\n');
400 write_aligned_row(&mut output, &row, &column_widths);
401 }
402
403 output
404}
405
406fn write_aligned_row(output: &mut String, values: &[String], column_widths: &[usize]) {
407 for (index, value) in values.iter().enumerate() {
408 if index > 0 {
409 output.push_str(" | ");
410 }
411
412 let _ = write!(output, "{value:<width$}", width = column_widths[index]);
413 }
414}
415
416pub fn render_csv(result: &QueryResult) -> String {
417 let mut output = String::new();
418 output.push_str(
419 &result
420 .columns
421 .iter()
422 .map(|column| escape_csv_field(column))
423 .collect::<Vec<_>>()
424 .join(","),
425 );
426
427 for row in &result.rows {
428 output.push('\n');
429 output.push_str(
430 &row.iter()
431 .map(display_value)
432 .map(|value| escape_csv_field(&value))
433 .collect::<Vec<_>>()
434 .join(","),
435 );
436 }
437
438 output
439}
440
441pub fn render_jsonl(result: &QueryResult) -> String {
442 let mut output = String::new();
443
444 for (row_index, row) in result.rows.iter().enumerate() {
445 if row_index > 0 {
446 output.push('\n');
447 }
448 output.push('{');
449 for (column_index, column) in result.columns.iter().enumerate() {
450 if column_index > 0 {
451 output.push(',');
452 }
453 output.push_str(&escape_json_string(column));
454 output.push(':');
455 output.push_str(&to_json_value(
456 row.get(column_index).unwrap_or(&QueryValue::Null),
457 ));
458 }
459 output.push('}');
460 }
461
462 output
463}
464
465pub fn render_markdown(result: &QueryResult) -> String {
466 let mut output = String::new();
467
468 output.push('|');
469 for column in &result.columns {
470 output.push(' ');
471 output.push_str(&escape_markdown_cell(column));
472 output.push(' ');
473 output.push('|');
474 }
475 output.push('\n');
476
477 output.push('|');
478 for _ in &result.columns {
479 output.push_str(" --- |");
480 }
481
482 for row in &result.rows {
483 output.push('\n');
484 output.push('|');
485 for column_index in 0..result.columns.len() {
486 let value = row.get(column_index).unwrap_or(&QueryValue::Null);
487 output.push(' ');
488 output.push_str(&escape_markdown_cell(&display_value(value)));
489 output.push(' ');
490 output.push('|');
491 }
492 }
493
494 output
495}
496
497pub fn render_xml(result: &QueryResult) -> String {
498 let mut output = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data>\n");
499
500 for row in &result.rows {
501 output.push_str(" <row>\n");
502 for (column_index, column) in result.columns.iter().enumerate() {
503 let value = row.get(column_index).unwrap_or(&QueryValue::Null);
504 let xml_column = escape_xml_tag(column);
505 let xml_value = escape_xml_text(&display_value(value));
506 output.push_str(&format!(" <{}>{}</{}>
507", xml_column, xml_value, xml_column));
508 }
509 output.push_str(" </row>\n");
510 }
511
512 output.push_str("</data>");
513 output
514}
515
516pub fn write_xlsx(result: &QueryResult, output_path: &Path) -> Result<()> {
517 let mut workbook = Workbook::new();
518 let worksheet = workbook.add_worksheet();
519
520 for (column, header) in result.columns.iter().enumerate() {
521 worksheet.write_string(0, column as u16, header)?;
522 }
523
524 for (row_index, row) in result.rows.iter().enumerate() {
525 for (column_index, value) in row.iter().enumerate() {
526 let excel_row = (row_index + 1) as u32;
527 let excel_column = column_index as u16;
528
529 match value {
530 QueryValue::Null => {}
531 QueryValue::Integer(number) => {
532 worksheet.write_number(excel_row, excel_column, *number as f64)?;
533 }
534 QueryValue::Real(number) => {
535 worksheet.write_number(excel_row, excel_column, *number)?;
536 }
537 QueryValue::Text(text) => {
538 worksheet.write_string(excel_row, excel_column, text)?;
539 }
540 }
541 }
542 }
543
544 workbook
545 .save(output_path)
546 .with_context(|| format!("failed to write {}", output_path.display()))
547}
548
549#[derive(Debug)]
550struct SheetData {
551 original_name: String,
552 columns: Vec<String>,
553 rows: Vec<Vec<QueryValue>>,
554}
555
556fn load_input(
557 input_path: &Path,
558 requested_sheet: Option<&str>,
559 has_headers: bool,
560) -> Result<SheetData> {
561 if input_path
562 .extension()
563 .map(|extension| extension.to_string_lossy())
564 .is_some_and(|extension| extension.eq_ignore_ascii_case("xml"))
565 {
566 return load_xml_sheet(input_path, requested_sheet);
567 }
568
569 if input_path
570 .extension()
571 .map(|extension| extension.to_string_lossy())
572 .is_some_and(|extension| extension.eq_ignore_ascii_case("csv"))
573 {
574 return load_csv_sheet(input_path, requested_sheet, has_headers);
575 }
576
577 if input_path
578 .extension()
579 .map(|extension| extension.to_string_lossy())
580 .is_some_and(|extension| extension.eq_ignore_ascii_case("jsonl"))
581 {
582 return load_jsonl_sheet(input_path, requested_sheet);
583 }
584
585 if input_path
586 .extension()
587 .map(|extension| extension.to_string_lossy())
588 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
589 {
590 return load_json_sheet(input_path, requested_sheet);
591 }
592
593 if input_path
594 .extension()
595 .map(|extension| extension.to_string_lossy())
596 .is_some_and(|extension| {
597 extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown")
598 })
599 {
600 return load_markdown_sheet(input_path, requested_sheet, has_headers);
601 }
602
603 load_xlsx_sheet(input_path, requested_sheet, has_headers)
604}
605
606fn load_csv_sheet(
607 csv_path: &Path,
608 requested_sheet: Option<&str>,
609 has_headers: bool,
610) -> Result<SheetData> {
611 if let Some(selector) = requested_sheet {
612 bail!(
613 "CSV input {} does not support selector '{selector}'. Remove ':{selector}' from --input for CSV files.",
614 csv_path.display()
615 );
616 }
617
618 let mut reader = csv::ReaderBuilder::new()
619 .has_headers(has_headers)
620 .from_path(csv_path)
621 .with_context(|| format!("failed to open {}", csv_path.display()))?;
622
623 let mut records = Vec::new();
624 for record in reader.records() {
625 let record = record.with_context(|| {
626 format!("failed to read CSV record from {}", csv_path.display())
627 })?;
628 records.push(record.iter().map(str::to_owned).collect::<Vec<_>>());
629 }
630
631 let columns = if has_headers {
632 let headers = reader
633 .headers()
634 .with_context(|| format!("failed to read headers from {}", csv_path.display()))?
635 .iter()
636 .map(str::to_owned)
637 .collect::<Vec<_>>();
638 normalize_text_headers(&headers)
639 } else {
640 let width = records.iter().map(Vec::len).max().unwrap_or(0);
641 (1..=width).map(|index| format!("column{index}")).collect()
642 };
643
644 if columns.is_empty() {
645 bail!("CSV input {} is empty", csv_path.display());
646 }
647
648 let rows = records
649 .into_iter()
650 .map(|record| {
651 (0..columns.len())
652 .map(|index| {
653 let value = record.get(index).map(String::as_str).unwrap_or("");
654 parse_scalar_value(value)
655 })
656 .collect::<Vec<_>>()
657 })
658 .filter(|row| row.iter().any(|value| !matches!(value, QueryValue::Null)))
659 .collect::<Vec<_>>();
660
661 Ok(SheetData {
662 original_name: "csv".to_owned(),
663 columns,
664 rows,
665 })
666}
667
668fn load_jsonl_sheet(jsonl_path: &Path, requested_sheet: Option<&str>) -> Result<SheetData> {
669 if let Some(selector) = requested_sheet {
670 bail!(
671 "JSONL input {} does not support selector '{selector}'. Remove ':{selector}' from --input for JSONL files.",
672 jsonl_path.display()
673 );
674 }
675
676 let content = fs::read_to_string(jsonl_path)
677 .with_context(|| format!("failed to read {}", jsonl_path.display()))?;
678
679 let mut rows_maps = Vec::<Vec<(String, QueryValue)>>::new();
680 for (line_index, raw_line) in content.lines().enumerate() {
681 let line = raw_line.trim();
682 if line.is_empty() {
683 continue;
684 }
685
686 let json_value = serde_json::from_str::<JsonValue>(line).with_context(|| {
687 format!(
688 "failed to parse JSONL line {} in {}",
689 line_index + 1,
690 jsonl_path.display()
691 )
692 })?;
693
694 let JsonValue::Object(object) = json_value else {
695 bail!(
696 "JSONL line {} in {} is not an object",
697 line_index + 1,
698 jsonl_path.display()
699 );
700 };
701
702 rows_maps.push(
703 object
704 .into_iter()
705 .map(|(key, value)| (key, json_to_query_value(value)))
706 .collect(),
707 );
708 }
709
710 if rows_maps.is_empty() {
711 bail!("JSONL input {} is empty", jsonl_path.display());
712 }
713
714 Ok(rows_maps_to_sheet_data(rows_maps, "jsonl".to_owned()))
715}
716
717fn load_json_sheet(json_path: &Path, requested_sheet: Option<&str>) -> Result<SheetData> {
718 let content = fs::read_to_string(json_path)
719 .with_context(|| format!("failed to read {}", json_path.display()))?;
720 let root = serde_json::from_str::<JsonValue>(&content)
721 .with_context(|| format!("failed to parse JSON {}", json_path.display()))?;
722
723 let scope = if let Some(sheet_key) = requested_sheet {
724 let JsonValue::Object(mut object) = root else {
725 bail!(
726 "JSON input {} uses selector '{sheet_key}', but key selection requires a top-level object. Remove the selector or provide a JSON object at the root.",
727 json_path.display()
728 );
729 };
730
731 let mut available_keys = object.keys().cloned().collect::<Vec<_>>();
732 available_keys.sort();
733
734 object
735 .remove(sheet_key)
736 .ok_or_else(|| {
737 let available_suffix = if available_keys.is_empty() {
738 " The top-level object has no keys.".to_owned()
739 } else {
740 format!(" Available keys: {}.", available_keys.join(", "))
741 };
742 anyhow!(
743 "JSON key '{sheet_key}' not found in {}.{available_suffix}",
744 json_path.display()
745 )
746 })?
747 } else {
748 root
749 };
750
751 let rows_maps = json_scope_to_rows(scope);
752 if rows_maps.is_empty() {
753 if let Some(sheet_key) = requested_sheet {
754 bail!(
755 "JSON selector '{sheet_key}' in {} resolved to an empty table (no rows)",
756 json_path.display()
757 );
758 }
759 bail!("JSON input {} is empty", json_path.display());
760 }
761
762 Ok(rows_maps_to_sheet_data(
763 rows_maps,
764 requested_sheet.unwrap_or("json").to_owned(),
765 ))
766}
767
768fn load_markdown_sheet(
769 markdown_path: &Path,
770 requested_sheet: Option<&str>,
771 has_headers: bool,
772) -> Result<SheetData> {
773 let content = fs::read_to_string(markdown_path)
774 .with_context(|| format!("failed to read {}", markdown_path.display()))?;
775 let tables = parse_markdown_tables(&content);
776 if tables.is_empty() {
777 bail!("Markdown input {} does not contain any table", markdown_path.display());
778 }
779
780 let table_index = if let Some(key) = requested_sheet {
781 let index = key
782 .trim()
783 .parse::<usize>()
784 .map_err(|_| {
785 anyhow!(
786 "invalid Markdown table selector '{key}' for {}. Use a 1-based numeric index such as ':1' or ':2'.",
787 markdown_path.display()
788 )
789 })?;
790 if index == 0 {
791 bail!(
792 "invalid Markdown table selector '{key}' for {}. Table indexes are 1-based (:1, :2, ...).",
793 markdown_path.display()
794 );
795 }
796 index
797 } else {
798 1
799 };
800
801 let table_count = tables.len();
802 let Some(table) = tables.into_iter().nth(table_index - 1) else {
803 bail!(
804 "Markdown table {} not found in {}. Found {} table(s); choose an index between 1 and {}.",
805 table_index,
806 markdown_path.display(),
807 table_count,
808 table_count
809 );
810 };
811
812 let mut rows = table.rows;
813 let columns = if has_headers {
814 normalize_text_headers(&table.headers)
815 } else {
816 rows.insert(0, table.headers);
817 let width = rows.iter().map(Vec::len).max().unwrap_or(0);
818 (1..=width).map(|index| format!("column{index}")).collect()
819 };
820
821 if columns.is_empty() {
822 bail!("Markdown table {} is empty", table_index);
823 }
824
825 let data_rows = rows
826 .into_iter()
827 .map(|row| {
828 (0..columns.len())
829 .map(|index| {
830 let value = row.get(index).map(String::as_str).unwrap_or("");
831 parse_scalar_value(value)
832 })
833 .collect::<Vec<_>>()
834 })
835 .filter(|row| row.iter().any(|value| !matches!(value, QueryValue::Null)))
836 .collect::<Vec<_>>();
837
838 if data_rows.is_empty() {
839 bail!(
840 "Markdown table {} in {} is empty (no data rows)",
841 table_index,
842 markdown_path.display()
843 );
844 }
845
846 Ok(SheetData {
847 original_name: format!("table{table_index}"),
848 columns,
849 rows: data_rows,
850 })
851}
852
853fn load_xlsx_sheet(
854 workbook_path: &Path,
855 requested_sheet: Option<&str>,
856 has_headers: bool,
857) -> Result<SheetData> {
858 let mut workbook = open_workbook_auto(workbook_path)
859 .with_context(|| format!("failed to open {}", workbook_path.display()))?;
860
861 let sheet_name = match requested_sheet {
862 Some(name) => name.to_owned(),
863 None => workbook
864 .sheet_names()
865 .first()
866 .cloned()
867 .ok_or_else(|| anyhow!("workbook does not contain any sheets"))?,
868 };
869
870 let range = workbook
871 .worksheet_range(&sheet_name)
872 .with_context(|| format!("failed to read sheet {sheet_name}"))?;
873
874 if range.width() == 0 {
875 bail!("sheet {sheet_name} is empty");
876 }
877
878 let mut rows = range.rows();
879 let columns = if has_headers {
880 let header_row = rows
881 .next()
882 .ok_or_else(|| anyhow!("sheet {sheet_name} is empty"))?;
883 normalize_headers(header_row, range.width())
884 } else {
885 (1..=range.width())
886 .map(|index| format!("column{index}"))
887 .collect()
888 };
889
890 let data_rows = rows
891 .map(|row| {
892 (0..columns.len())
893 .map(|index| convert_cell(row.get(index).unwrap_or(&Data::Empty)))
894 .collect::<Vec<_>>()
895 })
896 .filter(|row| row.iter().any(|value| !matches!(value, QueryValue::Null)))
897 .collect();
898
899 Ok(SheetData {
900 original_name: sheet_name,
901 columns,
902 rows: data_rows,
903 })
904}
905
906fn load_xml_sheet(xml_path: &Path, requested_sheet: Option<&str>) -> Result<SheetData> {
907 let xml_content = fs::read_to_string(xml_path)
908 .with_context(|| format!("failed to read {}", xml_path.display()))?;
909 let document = Document::parse(&xml_content)
910 .with_context(|| format!("failed to parse XML {}", xml_path.display()))?;
911
912 let root = document.root_element();
913 let scope = if let Some(sheet_tag) = requested_sheet {
914 root.descendants()
915 .find(|node| node.is_element() && node.tag_name().name() == sheet_tag)
916 .ok_or_else(|| anyhow!("XML tag '{sheet_tag}' not found"))?
917 } else {
918 root
919 };
920
921 let mut records = collect_xml_records(scope);
922 if records.is_empty() {
923 let fallback = xml_row_from_children(scope);
924 if fallback.is_empty() {
925 let scope_name = requested_sheet.unwrap_or(scope.tag_name().name());
926 bail!("XML scope '{scope_name}' does not contain tabular data");
927 }
928 records.push(fallback);
929 }
930
931 let mut columns = Vec::new();
932 for row in &records {
933 for (column, _) in row {
934 if !columns.iter().any(|existing| existing == column) {
935 columns.push(column.clone());
936 }
937 }
938 }
939
940 let rows = records
941 .into_iter()
942 .map(|row| {
943 columns
944 .iter()
945 .map(|column| {
946 row.iter()
947 .find(|(key, _)| key == column)
948 .map(|(_, value)| value.clone())
949 .unwrap_or(QueryValue::Null)
950 })
951 .collect::<Vec<_>>()
952 })
953 .collect::<Vec<_>>();
954
955 let original_name = requested_sheet
956 .map(str::to_owned)
957 .unwrap_or_else(|| scope.tag_name().name().to_owned());
958
959 Ok(SheetData {
960 original_name,
961 columns,
962 rows,
963 })
964}
965
966fn collect_xml_records(scope: Node<'_, '_>) -> Vec<Vec<(String, QueryValue)>> {
967 let direct_row_nodes = scope
968 .children()
969 .filter(|node| node.is_element() && node.tag_name().name().eq_ignore_ascii_case("row"))
970 .collect::<Vec<_>>();
971 if !direct_row_nodes.is_empty() {
972 return direct_row_nodes
973 .into_iter()
974 .map(xml_row_from_children)
975 .filter(|row| !row.is_empty())
976 .collect();
977 }
978
979 scope
980 .descendants()
981 .filter(|node| node.is_element() && *node != scope)
982 .filter(|node| is_xml_record_candidate(*node))
983 .map(xml_row_from_children)
984 .filter(|row| !row.is_empty())
985 .collect()
986}
987
988fn is_xml_record_candidate(node: Node<'_, '_>) -> bool {
989 let children = node
990 .children()
991 .filter(|child| child.is_element())
992 .collect::<Vec<_>>();
993
994 !children.is_empty()
995 && children
996 .iter()
997 .all(|child| !child.children().any(|inner| inner.is_element()))
998}
999
1000fn xml_row_from_children(node: Node<'_, '_>) -> Vec<(String, QueryValue)> {
1001 node.children()
1002 .filter(|child| child.is_element())
1003 .map(|child| {
1004 let column = child.tag_name().name().to_owned();
1005 let value = xml_text_to_query_value(&xml_text_content(child));
1006 (column, value)
1007 })
1008 .collect()
1009}
1010
1011fn xml_text_content(node: Node<'_, '_>) -> String {
1012 node.text().unwrap_or_default().trim().to_owned()
1013}
1014
1015fn xml_text_to_query_value(raw: &str) -> QueryValue {
1016 parse_scalar_value(raw)
1017}
1018
1019fn parse_scalar_value(raw: &str) -> QueryValue {
1020 if raw.is_empty() {
1021 return QueryValue::Null;
1022 }
1023
1024 let trimmed = raw.trim();
1025 if trimmed.is_empty() {
1026 return QueryValue::Null;
1027 }
1028
1029 if trimmed.eq_ignore_ascii_case("true") {
1030 return QueryValue::Integer(1);
1031 }
1032
1033 if trimmed.eq_ignore_ascii_case("false") {
1034 return QueryValue::Integer(0);
1035 }
1036
1037 if let Ok(value) = trimmed.parse::<i64>() {
1038 return QueryValue::Integer(value);
1039 }
1040
1041 if let Ok(value) = trimmed.parse::<f64>() {
1042 return QueryValue::Real(value);
1043 }
1044
1045 QueryValue::Text(raw.to_owned())
1046}
1047
1048fn normalize_text_headers(headers: &[String]) -> Vec<String> {
1049 let mut seen = std::collections::HashMap::new();
1050
1051 headers
1052 .iter()
1053 .enumerate()
1054 .map(|(index, header)| {
1055 let base = if header.trim().is_empty() {
1056 format!("column{}", index + 1)
1057 } else {
1058 header.clone()
1059 };
1060
1061 let count = seen
1062 .entry(base.clone())
1063 .and_modify(|count| *count += 1)
1064 .or_insert(1usize);
1065
1066 if *count == 1 {
1067 base
1068 } else {
1069 format!("{base}_{count}")
1070 }
1071 })
1072 .collect()
1073}
1074
1075fn rows_maps_to_sheet_data(rows_maps: Vec<Vec<(String, QueryValue)>>, original_name: String) -> SheetData {
1076 let mut columns = Vec::<String>::new();
1077 for row in &rows_maps {
1078 for (column, _) in row {
1079 if !columns.iter().any(|existing| existing == column) {
1080 columns.push(column.clone());
1081 }
1082 }
1083 }
1084
1085 let rows = rows_maps
1086 .into_iter()
1087 .map(|row| {
1088 columns
1089 .iter()
1090 .map(|column| {
1091 row.iter()
1092 .find(|(key, _)| key == column)
1093 .map(|(_, value)| value.clone())
1094 .unwrap_or(QueryValue::Null)
1095 })
1096 .collect::<Vec<_>>()
1097 })
1098 .collect::<Vec<_>>();
1099
1100 SheetData {
1101 original_name,
1102 columns,
1103 rows,
1104 }
1105}
1106
1107#[derive(Debug)]
1108struct MarkdownTable {
1109 headers: Vec<String>,
1110 rows: Vec<Vec<String>>,
1111}
1112
1113fn parse_markdown_tables(content: &str) -> Vec<MarkdownTable> {
1114 let lines = content.lines().collect::<Vec<_>>();
1115 let mut tables = Vec::new();
1116 let mut index = 0usize;
1117
1118 while index + 1 < lines.len() {
1119 if !looks_like_markdown_row(lines[index]) || !is_markdown_separator(lines[index + 1]) {
1120 index += 1;
1121 continue;
1122 }
1123
1124 let headers = parse_markdown_row(lines[index]);
1125 if headers.is_empty() {
1126 index += 1;
1127 continue;
1128 }
1129
1130 let mut rows = Vec::new();
1131 index += 2;
1132 while index < lines.len() && looks_like_markdown_row(lines[index]) {
1133 if is_markdown_separator(lines[index]) {
1134 break;
1135 }
1136 let row = parse_markdown_row(lines[index]);
1137 if row.iter().any(|value| !value.trim().is_empty()) {
1138 rows.push(row);
1139 }
1140 index += 1;
1141 }
1142
1143 tables.push(MarkdownTable { headers, rows });
1144 }
1145
1146 tables
1147}
1148
1149fn looks_like_markdown_row(line: &str) -> bool {
1150 line.contains('|')
1151}
1152
1153fn is_markdown_separator(line: &str) -> bool {
1154 let parts = split_markdown_cells(line);
1155 if parts.is_empty() {
1156 return false;
1157 }
1158
1159 parts.iter().all(|part| {
1160 let cell = part.trim();
1161 if cell.is_empty() {
1162 return false;
1163 }
1164
1165 let inner = cell.trim_matches(':');
1166 !inner.is_empty() && inner.chars().all(|character| character == '-')
1167 })
1168}
1169
1170fn parse_markdown_row(line: &str) -> Vec<String> {
1171 split_markdown_cells(line)
1172 .into_iter()
1173 .map(|cell| cell.replace("\\|", "|").trim().to_owned())
1174 .collect()
1175}
1176
1177fn split_markdown_cells(line: &str) -> Vec<String> {
1178 let trimmed = line.trim();
1179 if trimmed.is_empty() {
1180 return Vec::new();
1181 }
1182
1183 let without_prefix = trimmed.strip_prefix('|').unwrap_or(trimmed);
1184 let content = without_prefix
1185 .strip_suffix('|')
1186 .unwrap_or(without_prefix);
1187
1188 content.split('|').map(str::to_owned).collect()
1189}
1190
1191fn json_scope_to_rows(scope: JsonValue) -> Vec<Vec<(String, QueryValue)>> {
1192 match scope {
1193 JsonValue::Array(items) => items
1194 .into_iter()
1195 .map(json_item_to_row)
1196 .collect::<Vec<_>>(),
1197 JsonValue::Object(object) => vec![object
1198 .into_iter()
1199 .map(|(key, value)| (key, json_to_query_value(value)))
1200 .collect()],
1201 scalar => vec![vec![("value".to_owned(), json_to_query_value(scalar))]],
1202 }
1203}
1204
1205fn json_item_to_row(item: JsonValue) -> Vec<(String, QueryValue)> {
1206 match item {
1207 JsonValue::Object(object) => object
1208 .into_iter()
1209 .map(|(key, value)| (key, json_to_query_value(value)))
1210 .collect(),
1211 scalar => vec![("value".to_owned(), json_to_query_value(scalar))],
1212 }
1213}
1214
1215fn json_to_query_value(value: JsonValue) -> QueryValue {
1216 match value {
1217 JsonValue::Null => QueryValue::Null,
1218 JsonValue::Bool(flag) => QueryValue::Integer(i64::from(flag)),
1219 JsonValue::Number(number) => {
1220 if let Some(integer) = number.as_i64() {
1221 QueryValue::Integer(integer)
1222 } else if let Some(real) = number.as_f64() {
1223 QueryValue::Real(real)
1224 } else {
1225 QueryValue::Text(number.to_string())
1226 }
1227 }
1228 JsonValue::String(text) => parse_scalar_value(&text),
1229 JsonValue::Array(_) | JsonValue::Object(_) => QueryValue::Text(value.to_string()),
1230 }
1231}
1232
1233fn normalize_headers(header_row: &[Data], width: usize) -> Vec<String> {
1234 let mut seen = std::collections::HashMap::new();
1235
1236 (0..width)
1237 .map(|index| {
1238 let base = header_row
1239 .get(index)
1240 .map(cell_to_string)
1241 .filter(|value| !value.trim().is_empty())
1242 .unwrap_or_else(|| format!("column{}", index + 1));
1243
1244 let count = seen
1245 .entry(base.clone())
1246 .and_modify(|count| *count += 1)
1247 .or_insert(1usize);
1248
1249 if *count == 1 {
1250 base
1251 } else {
1252 format!("{base}_{count}")
1253 }
1254 })
1255 .collect()
1256}
1257
1258fn convert_cell(cell: &Data) -> QueryValue {
1259 match cell {
1260 Data::Empty => QueryValue::Null,
1261 Data::Int(value) => QueryValue::Integer(*value),
1262 Data::Float(value) => QueryValue::Real(*value),
1263 Data::String(value) => QueryValue::Text(value.clone()),
1264 Data::Bool(value) => QueryValue::Integer(i64::from(*value)),
1265 Data::DateTime(value) => QueryValue::Text(value.to_string()),
1266 Data::DateTimeIso(value) => QueryValue::Text(value.clone()),
1267 Data::DurationIso(value) => QueryValue::Text(value.clone()),
1268 Data::Error(value) => QueryValue::Text(value.to_string()),
1269 }
1270}
1271
1272fn cell_to_string(cell: &Data) -> String {
1273 match convert_cell(cell) {
1274 QueryValue::Null => String::new(),
1275 QueryValue::Integer(value) => value.to_string(),
1276 QueryValue::Real(value) => value.to_string(),
1277 QueryValue::Text(value) => value,
1278 }
1279}
1280
1281fn register_sheet(
1282 connection: &Connection,
1283 sheet: &SheetData,
1284 table_name: &str,
1285 registered_views: &mut HashSet<String>,
1286) -> Result<()> {
1287 let columns = sheet
1288 .columns
1289 .iter()
1290 .map(|column| quote_identifier(column))
1291 .collect::<Vec<_>>()
1292 .join(", ");
1293
1294 connection
1295 .execute(
1296 &format!("CREATE TABLE {} ({columns})", quote_identifier(table_name)),
1297 [],
1298 )
1299 .context("failed to create sheet table")?;
1300
1301 let table1_key = normalize_view_key("table1");
1302 if table_name == "table" && !registered_views.contains(&table1_key) {
1303 connection
1304 .execute(
1305 &format!(
1306 "CREATE VIEW {} AS SELECT * FROM {}",
1307 quote_identifier("table1"),
1308 quote_identifier("table")
1309 ),
1310 [],
1311 )
1312 .context("failed to register alias view table1 for first input")?;
1313 registered_views.insert(table1_key);
1314 }
1315
1316 let sanitized_sheet_name = sanitize_table_name(&sheet.original_name);
1317 let sanitized_sheet_key = normalize_view_key(&sanitized_sheet_name);
1318 if sanitized_sheet_name != table_name && !registered_views.contains(&sanitized_sheet_key) {
1319 connection
1320 .execute(
1321 &format!(
1322 "CREATE VIEW {} AS SELECT * FROM {}",
1323 quote_identifier(&sanitized_sheet_name),
1324 quote_identifier(table_name)
1325 ),
1326 [],
1327 )
1328 .with_context(|| {
1329 format!("failed to register view for sheet {}", sheet.original_name)
1330 })?;
1331 registered_views.insert(sanitized_sheet_key);
1332 }
1333
1334 if sheet.rows.is_empty() {
1335 return Ok(());
1336 }
1337
1338 let placeholders = vec!["?"; sheet.columns.len()].join(", ");
1339 let insert_sql = format!(
1340 "INSERT INTO {} VALUES ({placeholders})",
1341 quote_identifier(table_name)
1342 );
1343 let mut statement = connection
1344 .prepare(&insert_sql)
1345 .context("failed to prepare insert statement")?;
1346
1347 for row in &sheet.rows {
1348 let values = row.iter().map(to_sql_value).collect::<Vec<_>>();
1349 statement
1350 .execute(params_from_iter(values))
1351 .context("failed to insert sheet row")?;
1352 }
1353
1354 Ok(())
1355}
1356
1357fn execute_query(connection: &Connection, query: &str, params: &[QueryParam]) -> Result<QueryResult> {
1358 let normalized_query = normalize_query_for_reserved_identifiers(query);
1359 let mut statement = connection
1360 .prepare(&normalized_query)
1361 .map_err(|error| enrich_query_prepare_error(connection, query, error))?;
1362
1363 if statement.column_count() == 0 {
1364 bail!("query must return rows");
1365 }
1366
1367 let columns = statement
1368 .column_names()
1369 .into_iter()
1370 .map(str::to_owned)
1371 .collect::<Vec<_>>();
1372
1373 bind_query_params(&mut statement, params)?;
1374
1375 let column_count = statement.column_count();
1376 let mut rows = statement.raw_query();
1377 let mut result_rows = Vec::new();
1378
1379 while let Some(row) = rows.next().context("failed to fetch query row")? {
1380 let mut values = Vec::with_capacity(column_count);
1381
1382 for index in 0..column_count {
1383 values.push(match row.get_ref(index)? {
1384 ValueRef::Null => QueryValue::Null,
1385 ValueRef::Integer(value) => QueryValue::Integer(value),
1386 ValueRef::Real(value) => QueryValue::Real(value),
1387 ValueRef::Text(value) => {
1388 QueryValue::Text(String::from_utf8_lossy(value).into_owned())
1389 }
1390 ValueRef::Blob(value) => QueryValue::Text(format_blob(value)),
1391 });
1392 }
1393
1394 result_rows.push(values);
1395 }
1396
1397 Ok(QueryResult {
1398 columns,
1399 rows: result_rows,
1400 })
1401}
1402
1403fn enrich_query_prepare_error(connection: &Connection, query: &str, error: rusqlite::Error) -> anyhow::Error {
1404 let raw_error = error.to_string();
1405
1406 if let Some(table) = raw_error.strip_prefix("no such table: ").map(str::trim) {
1407 let table = simplify_sqlite_missing_identifier(table);
1408 let available_tables = list_loaded_table_names(connection);
1409 let available_suffix = if available_tables.is_empty() {
1410 String::new()
1411 } else {
1412 format!(" Available tables/views: {}.", available_tables.join(", "))
1413 };
1414
1415 return anyhow!(
1416 "query references unknown table '{table}'.{available_suffix} Check your table names or run `qf tables --input ...` to inspect available tables.\nQuery: {query}"
1417 );
1418 }
1419
1420 if let Some(column) = raw_error.strip_prefix("no such column: ").map(str::trim) {
1421 let column = simplify_sqlite_missing_identifier(column);
1422 return anyhow!(
1423 "query references unknown column '{column}'. Check your column names or run `qf schema --input ...` to inspect available columns.\nQuery: {query}"
1424 );
1425 }
1426
1427 anyhow!(error).context(format!("failed to prepare query: {query}"))
1428}
1429
1430fn simplify_sqlite_missing_identifier(identifier: &str) -> &str {
1431 identifier
1432 .split_once(" in ")
1433 .map(|(name, _)| name)
1434 .unwrap_or(identifier)
1435 .trim()
1436}
1437
1438fn list_loaded_table_names(connection: &Connection) -> Vec<String> {
1439 let mut statement = match connection.prepare(
1440 "SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name",
1441 ) {
1442 Ok(statement) => statement,
1443 Err(_) => return Vec::new(),
1444 };
1445
1446 let names = match statement.query_map([], |row| row.get::<_, String>(0)) {
1447 Ok(names) => names,
1448 Err(_) => return Vec::new(),
1449 };
1450
1451 names.filter_map(|name| name.ok()).collect()
1452}
1453
1454fn normalize_query_for_reserved_identifiers(query: &str) -> String {
1455 static RESERVED_TABLE_RE: OnceLock<Regex> = OnceLock::new();
1456
1457 let re = RESERVED_TABLE_RE.get_or_init(|| {
1458 Regex::new(r"(?i)\b(from|join|update|into)\s+table\b")
1459 .expect("reserved table regex should compile")
1460 });
1461
1462 re.replace_all(query, |captures: ®ex::Captures<'_>| {
1463 format!("{} \"table\"", &captures[1])
1464 })
1465 .into_owned()
1466}
1467
1468fn bind_query_params(statement: &mut rusqlite::Statement<'_>, params: &[QueryParam]) -> Result<()> {
1469 for param in params {
1470 let mut bound = false;
1471
1472 for prefix in [":", "@", "$"] {
1473 let parameter_name = format!("{prefix}{}", param.name);
1474 if let Some(index) = statement
1475 .parameter_index(¶meter_name)
1476 .with_context(|| format!("failed to inspect parameter {parameter_name}"))?
1477 {
1478 statement
1479 .raw_bind_parameter(index, to_sql_value(¶m.value))
1480 .with_context(|| format!("failed to bind parameter {parameter_name}"))?;
1481 bound = true;
1482 break;
1483 }
1484 }
1485
1486 if !bound {
1487 bail!("query does not contain parameter :{}", param.name);
1488 }
1489 }
1490
1491 Ok(())
1492}
1493
1494fn to_sql_value(value: &QueryValue) -> Value {
1495 match value {
1496 QueryValue::Null => Value::Null,
1497 QueryValue::Integer(value) => Value::Integer(*value),
1498 QueryValue::Real(value) => Value::Real(*value),
1499 QueryValue::Text(value) => Value::Text(value.clone()),
1500 }
1501}
1502
1503fn display_value(value: &QueryValue) -> String {
1504 match value {
1505 QueryValue::Null => String::new(),
1506 QueryValue::Integer(value) => value.to_string(),
1507 QueryValue::Real(value) => value.to_string(),
1508 QueryValue::Text(value) => value.clone(),
1509 }
1510}
1511
1512fn escape_csv_field(value: &str) -> String {
1513 if value.contains([',', '"', '\n', '\r']) {
1514 format!("\"{}\"", value.replace('"', "\"\""))
1515 } else {
1516 value.to_owned()
1517 }
1518}
1519
1520fn escape_json_string(value: &str) -> String {
1521 let mut escaped = String::from("\"");
1522 for character in value.chars() {
1523 match character {
1524 '"' => escaped.push_str("\\\""),
1525 '\\' => escaped.push_str("\\\\"),
1526 '\n' => escaped.push_str("\\n"),
1527 '\r' => escaped.push_str("\\r"),
1528 '\t' => escaped.push_str("\\t"),
1529 '\u{08}' => escaped.push_str("\\b"),
1530 '\u{0C}' => escaped.push_str("\\f"),
1531 c if c <= '\u{1F}' => {
1532 let _ = write!(&mut escaped, "\\u{:04x}", c as u32);
1533 }
1534 c => escaped.push(c),
1535 }
1536 }
1537 escaped.push('"');
1538 escaped
1539}
1540
1541fn to_json_value(value: &QueryValue) -> String {
1542 match value {
1543 QueryValue::Null => "null".to_owned(),
1544 QueryValue::Integer(number) => number.to_string(),
1545 QueryValue::Real(number) if number.is_finite() => number.to_string(),
1546 QueryValue::Real(_) => "null".to_owned(),
1547 QueryValue::Text(text) => escape_json_string(text),
1548 }
1549}
1550
1551fn escape_markdown_cell(value: &str) -> String {
1552 value.replace('|', "\\|").replace('\n', "<br>")
1553}
1554
1555fn escape_xml_text(value: &str) -> String {
1556 value
1557 .replace('&', "&")
1558 .replace('<', "<")
1559 .replace('>', ">")
1560 .replace('"', """)
1561 .replace('\'', "'")
1562}
1563
1564fn escape_xml_tag(value: &str) -> String {
1565 let sanitized = value
1566 .chars()
1567 .map(|character| {
1568 if character.is_alphanumeric() || character == '_' || character == '-' {
1569 character
1570 } else {
1571 '_'
1572 }
1573 })
1574 .collect::<String>();
1575
1576 if sanitized.is_empty() {
1577 "element".to_owned()
1578 } else if sanitized.chars().next().unwrap_or('x').is_numeric() {
1579 format!("_{sanitized}")
1580 } else {
1581 sanitized
1582 }
1583}
1584
1585fn format_blob(value: &[u8]) -> String {
1586 let mut formatted = String::from("0x");
1587 for byte in value {
1588 let _ = write!(&mut formatted, "{byte:02x}");
1589 }
1590
1591 formatted
1592}
1593
1594fn quote_identifier(identifier: &str) -> String {
1595 format!("\"{}\"", identifier.replace('"', "\"\""))
1596}
1597
1598fn sanitize_table_name(name: &str) -> String {
1599 let sanitized = name
1600 .chars()
1601 .map(|character| {
1602 if character.is_alphanumeric() || character == '_' {
1603 character
1604 } else {
1605 '_'
1606 }
1607 })
1608 .collect::<String>()
1609 .trim_matches('_')
1610 .to_string();
1611
1612 if sanitized.is_empty() {
1613 "table_view".to_owned()
1614 } else {
1615 sanitized
1616 }
1617}
1618
1619fn normalize_view_key(name: &str) -> String {
1620 name.to_ascii_lowercase()
1621}
1622
1623#[cfg(test)]
1624mod tests {
1625 use std::{
1626 fs,
1627 path::PathBuf,
1628 time::{SystemTime, UNIX_EPOCH},
1629 };
1630
1631 use super::*;
1632
1633 fn temp_path(name: &str) -> PathBuf {
1634 let unique = SystemTime::now()
1635 .duration_since(UNIX_EPOCH)
1636 .expect("time should move forward")
1637 .as_nanos();
1638 std::env::temp_dir().join(format!("query-forge-{name}-{unique}.xlsx"))
1639 }
1640
1641 fn temp_xml_path(name: &str) -> PathBuf {
1642 let unique = SystemTime::now()
1643 .duration_since(UNIX_EPOCH)
1644 .expect("time should move forward")
1645 .as_nanos();
1646 std::env::temp_dir().join(format!("query-forge-{name}-{unique}.xml"))
1647 }
1648
1649 fn temp_csv_path(name: &str) -> PathBuf {
1650 let unique = SystemTime::now()
1651 .duration_since(UNIX_EPOCH)
1652 .expect("time should move forward")
1653 .as_nanos();
1654 std::env::temp_dir().join(format!("query-forge-{name}-{unique}.csv"))
1655 }
1656
1657 fn temp_jsonl_path(name: &str) -> PathBuf {
1658 let unique = SystemTime::now()
1659 .duration_since(UNIX_EPOCH)
1660 .expect("time should move forward")
1661 .as_nanos();
1662 std::env::temp_dir().join(format!("query-forge-{name}-{unique}.jsonl"))
1663 }
1664
1665 fn temp_json_path(name: &str) -> PathBuf {
1666 let unique = SystemTime::now()
1667 .duration_since(UNIX_EPOCH)
1668 .expect("time should move forward")
1669 .as_nanos();
1670 std::env::temp_dir().join(format!("query-forge-{name}-{unique}.json"))
1671 }
1672
1673 fn temp_markdown_path(name: &str) -> PathBuf {
1674 let unique = SystemTime::now()
1675 .duration_since(UNIX_EPOCH)
1676 .expect("time should move forward")
1677 .as_nanos();
1678 std::env::temp_dir().join(format!("query-forge-{name}-{unique}.md"))
1679 }
1680
1681 fn write_test_workbook(path: &Path) -> Result<()> {
1682 let mut workbook = Workbook::new();
1683 let worksheet = workbook.add_worksheet();
1684
1685 worksheet.write_string(0, 0, "name")?;
1686 worksheet.write_string(0, 1, "price")?;
1687 worksheet.write_string(0, 2, "active")?;
1688 worksheet.write_string(1, 0, "Keyboard")?;
1689 worksheet.write_number(1, 1, 12.5)?;
1690 worksheet.write_boolean(1, 2, true)?;
1691 worksheet.write_string(2, 0, "Cable")?;
1692 worksheet.write_number(2, 1, 5.0)?;
1693 worksheet.write_boolean(2, 2, false)?;
1694
1695 workbook.save(path)?;
1696 Ok(())
1697 }
1698
1699 fn write_test_workbook_on_sheet(path: &Path, sheet_name: &str) -> Result<()> {
1700 let mut workbook = Workbook::new();
1701 let worksheet = workbook.add_worksheet();
1702 worksheet.set_name(sheet_name)?;
1703
1704 worksheet.write_string(0, 0, "name")?;
1705 worksheet.write_string(0, 1, "price")?;
1706 worksheet.write_string(1, 0, "Keyboard")?;
1707 worksheet.write_number(1, 1, 12.5)?;
1708
1709 workbook.save(path)?;
1710 Ok(())
1711 }
1712
1713 fn write_test_xml(path: &Path) -> Result<()> {
1714 fs::write(
1715 path,
1716 r#"<?xml version="1.0" encoding="UTF-8"?>
1717<data>
1718 <row>
1719 <name>Keyboard</name>
1720 <price>12.5</price>
1721 <active>true</active>
1722 </row>
1723 <row>
1724 <name>Cable</name>
1725 <price>5</price>
1726 <active>false</active>
1727 </row>
1728</data>
1729"#,
1730 )?;
1731
1732 Ok(())
1733 }
1734
1735 fn write_test_xml_with_sections(path: &Path) -> Result<()> {
1736 fs::write(
1737 path,
1738 r#"<?xml version="1.0" encoding="UTF-8"?>
1739<root>
1740 <Inventory>
1741 <row>
1742 <name>Keyboard</name>
1743 <price>12.5</price>
1744 </row>
1745 </Inventory>
1746 <Archive>
1747 <row>
1748 <name>Legacy Cable</name>
1749 <price>3.5</price>
1750 </row>
1751 </Archive>
1752</root>
1753"#,
1754 )?;
1755
1756 Ok(())
1757 }
1758
1759 fn write_test_csv(path: &Path) -> Result<()> {
1760 fs::write(
1761 path,
1762 "name,price,active\nKeyboard,12.5,true\nCable,5,false\n",
1763 )?;
1764
1765 Ok(())
1766 }
1767
1768 fn write_test_csv_no_headers(path: &Path) -> Result<()> {
1769 fs::write(path, "Keyboard,12.5,true\nCable,5,false\n")?;
1770
1771 Ok(())
1772 }
1773
1774 fn write_test_jsonl(path: &Path) -> Result<()> {
1775 fs::write(
1776 path,
1777 "{\"name\":\"Keyboard\",\"price\":12.5,\"active\":true}\n{\"name\":\"Cable\",\"price\":5,\"active\":false}\n",
1778 )?;
1779
1780 Ok(())
1781 }
1782
1783 fn write_test_json(path: &Path) -> Result<()> {
1784 fs::write(
1785 path,
1786 "[{\"name\":\"Keyboard\",\"price\":12.5,\"active\":true},{\"name\":\"Cable\",\"price\":5,\"active\":false}]",
1787 )?;
1788
1789 Ok(())
1790 }
1791
1792 fn write_test_json_with_sections(path: &Path) -> Result<()> {
1793 fs::write(
1794 path,
1795 "{\"Inventory\":[{\"name\":\"Keyboard\",\"price\":12.5}],\"Archive\":[{\"name\":\"Legacy Cable\",\"price\":3.5}]}",
1796 )?;
1797
1798 Ok(())
1799 }
1800
1801 fn write_test_markdown_with_tables(path: &Path) -> Result<()> {
1802 fs::write(
1803 path,
1804 r#"# Inventory Report
1805
1806| name | price | active |
1807| --- | ---: | :---: |
1808| Keyboard | 12.5 | true |
1809| Cable | 5 | false |
1810
1811## Archive
1812
1813| name | price |
1814| --- | --- |
1815| Legacy Cable | 3.5 |
1816"#,
1817 )?;
1818
1819 Ok(())
1820 }
1821
1822 fn write_test_markdown_with_headers_only(path: &Path) -> Result<()> {
1823 fs::write(
1824 path,
1825 r#"| name | price |
1826| --- | --- |
1827"#,
1828 )?;
1829
1830 Ok(())
1831 }
1832
1833 #[test]
1834 fn normalizes_duplicate_and_blank_headers() {
1835 let headers = vec![
1836 Data::String("name".into()),
1837 Data::String(String::new()),
1838 Data::String("name".into()),
1839 ];
1840
1841 assert_eq!(
1842 normalize_headers(&headers, 3),
1843 vec!["name", "column2", "name_2"]
1844 );
1845 }
1846
1847 #[test]
1848 fn executes_sql_query_against_sheet() -> Result<()> {
1849 let workbook_path = temp_path("query");
1850 write_test_workbook(&workbook_path)?;
1851
1852 let result = run_query(
1853 &workbook_path,
1854 Some("Sheet1"),
1855 "SELECT name, price FROM table WHERE price > 10 ORDER BY price DESC",
1856 true,
1857 )?;
1858
1859 assert_eq!(result.columns, vec!["name", "price"]);
1860 assert_eq!(
1861 result.rows,
1862 vec![vec![
1863 QueryValue::Text("Keyboard".into()),
1864 QueryValue::Real(12.5)
1865 ]]
1866 );
1867
1868 fs::remove_file(workbook_path)?;
1869 Ok(())
1870 }
1871
1872 #[test]
1873 fn executes_sql_query_against_sheet1_alias() -> Result<()> {
1874 let workbook_path = temp_path("query-sheet1-alias");
1875 write_test_workbook(&workbook_path)?;
1876
1877 let result = run_query(
1878 &workbook_path,
1879 Some("Sheet1"),
1880 "SELECT name, price FROM table1 WHERE price > 10 ORDER BY price DESC",
1881 true,
1882 )?;
1883
1884 assert_eq!(result.columns, vec!["name", "price"]);
1885 assert_eq!(
1886 result.rows,
1887 vec![vec![
1888 QueryValue::Text("Keyboard".into()),
1889 QueryValue::Real(12.5)
1890 ]]
1891 );
1892
1893 fs::remove_file(workbook_path)?;
1894 Ok(())
1895 }
1896
1897 #[test]
1898 fn executes_sql_query_with_named_parameter() -> Result<()> {
1899 let workbook_path = temp_path("query-with-param");
1900 write_test_workbook(&workbook_path)?;
1901
1902 let result = run_query_with_params(
1903 &workbook_path,
1904 Some("Sheet1"),
1905 "SELECT name, price FROM table WHERE price > :min_price ORDER BY price DESC",
1906 &[QueryParam {
1907 name: "min_price".to_owned(),
1908 value: QueryValue::Real(10.0),
1909 }],
1910 true,
1911 )?;
1912
1913 assert_eq!(
1914 result,
1915 QueryResult {
1916 columns: vec!["name".to_owned(), "price".to_owned()],
1917 rows: vec![vec![
1918 QueryValue::Text("Keyboard".to_owned()),
1919 QueryValue::Real(12.5),
1920 ]],
1921 }
1922 );
1923
1924 fs::remove_file(&workbook_path)?;
1925 Ok(())
1926 }
1927
1928 #[test]
1929 fn executes_query_against_multiple_workbooks() -> Result<()> {
1930 let workbook_path_1 = temp_path("multi-1");
1931 let workbook_path_2 = temp_path("multi-2");
1932 write_test_workbook(&workbook_path_1)?;
1933 write_test_workbook(&workbook_path_2)?;
1934
1935 let result = run_query_with_params_multi(
1936 &[workbook_path_1.as_path(), workbook_path_2.as_path()],
1937 Some("Sheet1"),
1938 "SELECT COUNT(*) AS total_rows FROM table UNION ALL SELECT COUNT(*) AS total_rows FROM table2",
1939 &[],
1940 true,
1941 )?;
1942
1943 assert_eq!(result.columns, vec!["total_rows"]);
1944 assert_eq!(
1945 result.rows,
1946 vec![vec![QueryValue::Integer(2)], vec![QueryValue::Integer(2)]]
1947 );
1948
1949 fs::remove_file(&workbook_path_1)?;
1950 fs::remove_file(&workbook_path_2)?;
1951 Ok(())
1952 }
1953
1954 #[test]
1955 fn executes_query_against_multiple_workbooks_with_distinct_sheet_names() -> Result<()> {
1956 let workbook_path_1 = temp_path("multi-sheet-1");
1957 let workbook_path_2 = temp_path("multi-sheet-2");
1958 write_test_workbook_on_sheet(&workbook_path_1, "Consuntivo")?;
1959 write_test_workbook_on_sheet(&workbook_path_2, "WKL")?;
1960
1961 let result = run_query_with_params_multi_inputs(
1962 &[
1963 WorkbookInput {
1964 path: workbook_path_1.as_path(),
1965 sheet_name: Some("Consuntivo"),
1966 table_name: None,
1967 },
1968 WorkbookInput {
1969 path: workbook_path_2.as_path(),
1970 sheet_name: Some("WKL"),
1971 table_name: None,
1972 },
1973 ],
1974 "SELECT COUNT(*) AS total_rows FROM table UNION ALL SELECT COUNT(*) AS total_rows FROM table2",
1975 &[],
1976 true,
1977 )?;
1978
1979 assert_eq!(result.columns, vec!["total_rows"]);
1980 assert_eq!(
1981 result.rows,
1982 vec![vec![QueryValue::Integer(1)], vec![QueryValue::Integer(1)]]
1983 );
1984
1985 fs::remove_file(&workbook_path_1)?;
1986 fs::remove_file(&workbook_path_2)?;
1987 Ok(())
1988 }
1989
1990 #[test]
1991 fn executes_query_with_explicit_table_names() -> Result<()> {
1992 let sales_path = temp_path("explicit-name-sales");
1993 let costs_path = temp_path("explicit-name-costs");
1994 write_test_workbook_on_sheet(&sales_path, "Sheet1")?;
1995 write_test_workbook_on_sheet(&costs_path, "Sheet1")?;
1996
1997 let result = run_query_with_params_multi_inputs(
1998 &[
1999 WorkbookInput {
2000 path: sales_path.as_path(),
2001 sheet_name: Some("Sheet1"),
2002 table_name: Some("sales"),
2003 },
2004 WorkbookInput {
2005 path: costs_path.as_path(),
2006 sheet_name: Some("Sheet1"),
2007 table_name: Some("costs"),
2008 },
2009 ],
2010 "SELECT COUNT(*) AS total_rows FROM sales UNION ALL SELECT COUNT(*) AS total_rows FROM costs",
2011 &[],
2012 true,
2013 )?;
2014
2015 assert_eq!(result.columns, vec!["total_rows"]);
2016 assert_eq!(
2017 result.rows,
2018 vec![vec![QueryValue::Integer(1)], vec![QueryValue::Integer(1)]]
2019 );
2020
2021 fs::remove_file(&sales_path)?;
2022 fs::remove_file(&costs_path)?;
2023 Ok(())
2024 }
2025
2026 #[test]
2027 fn executes_sql_query_against_whole_xml_file() -> Result<()> {
2028 let xml_path = temp_xml_path("xml-whole");
2029 write_test_xml(&xml_path)?;
2030
2031 let result = run_query(
2032 &xml_path,
2033 None,
2034 "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
2035 true,
2036 )?;
2037
2038 assert_eq!(result.columns, vec!["name", "price"]);
2039 assert_eq!(
2040 result.rows,
2041 vec![vec![
2042 QueryValue::Text("Keyboard".into()),
2043 QueryValue::Real(12.5)
2044 ]]
2045 );
2046
2047 fs::remove_file(xml_path)?;
2048 Ok(())
2049 }
2050
2051 #[test]
2052 fn executes_sql_query_against_xml_sheet_tag() -> Result<()> {
2053 let xml_path = temp_xml_path("xml-sheet-tag");
2054 write_test_xml_with_sections(&xml_path)?;
2055
2056 let result = run_query(
2057 &xml_path,
2058 Some("Archive"),
2059 "SELECT name, price FROM table",
2060 true,
2061 )?;
2062
2063 assert_eq!(result.columns, vec!["name", "price"]);
2064 assert_eq!(
2065 result.rows,
2066 vec![vec![
2067 QueryValue::Text("Legacy Cable".into()),
2068 QueryValue::Real(3.5)
2069 ]]
2070 );
2071
2072 fs::remove_file(xml_path)?;
2073 Ok(())
2074 }
2075
2076 #[test]
2077 fn executes_query_against_heterogeneous_xlsx_and_xml_inputs() -> Result<()> {
2078 let workbook_path = temp_path("mixed-xlsx");
2079 let xml_path = temp_xml_path("mixed-xml");
2080 write_test_workbook(&workbook_path)?;
2081 write_test_xml(&xml_path)?;
2082
2083 let result = run_query_with_params_multi_inputs(
2084 &[
2085 WorkbookInput {
2086 path: workbook_path.as_path(),
2087 sheet_name: Some("Sheet1"),
2088 table_name: None,
2089 },
2090 WorkbookInput {
2091 path: xml_path.as_path(),
2092 sheet_name: None,
2093 table_name: None,
2094 },
2095 ],
2096 "SELECT COUNT(*) AS total_rows FROM table UNION ALL SELECT COUNT(*) AS total_rows FROM table2",
2097 &[],
2098 true,
2099 )?;
2100
2101 assert_eq!(result.columns, vec!["total_rows"]);
2102 assert_eq!(
2103 result.rows,
2104 vec![vec![QueryValue::Integer(2)], vec![QueryValue::Integer(2)]]
2105 );
2106
2107 fs::remove_file(&workbook_path)?;
2108 fs::remove_file(&xml_path)?;
2109 Ok(())
2110 }
2111
2112 #[test]
2113 fn executes_sql_query_against_csv_file() -> Result<()> {
2114 let csv_path = temp_csv_path("csv");
2115 write_test_csv(&csv_path)?;
2116
2117 let result = run_query(
2118 &csv_path,
2119 None,
2120 "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
2121 true,
2122 )?;
2123
2124 assert_eq!(result.columns, vec!["name", "price"]);
2125 assert_eq!(
2126 result.rows,
2127 vec![vec![
2128 QueryValue::Text("Keyboard".into()),
2129 QueryValue::Real(12.5)
2130 ]]
2131 );
2132
2133 fs::remove_file(csv_path)?;
2134 Ok(())
2135 }
2136
2137 #[test]
2138 fn executes_sql_query_against_csv_without_headers() -> Result<()> {
2139 let csv_path = temp_csv_path("csv-no-headers");
2140 write_test_csv_no_headers(&csv_path)?;
2141
2142 let result = run_query(
2143 &csv_path,
2144 None,
2145 "SELECT column1, column2 FROM table WHERE column3 = 1 ORDER BY column2 DESC",
2146 false,
2147 )?;
2148
2149 assert_eq!(result.columns, vec!["column1", "column2"]);
2150 assert_eq!(
2151 result.rows,
2152 vec![vec![
2153 QueryValue::Text("Keyboard".into()),
2154 QueryValue::Real(12.5)
2155 ]]
2156 );
2157
2158 fs::remove_file(csv_path)?;
2159 Ok(())
2160 }
2161
2162 #[test]
2163 fn executes_sql_query_against_jsonl_file() -> Result<()> {
2164 let jsonl_path = temp_jsonl_path("jsonl");
2165 write_test_jsonl(&jsonl_path)?;
2166
2167 let result = run_query(
2168 &jsonl_path,
2169 None,
2170 "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
2171 true,
2172 )?;
2173
2174 assert_eq!(result.columns, vec!["name", "price"]);
2175 assert_eq!(
2176 result.rows,
2177 vec![vec![
2178 QueryValue::Text("Keyboard".into()),
2179 QueryValue::Real(12.5)
2180 ]]
2181 );
2182
2183 fs::remove_file(jsonl_path)?;
2184 Ok(())
2185 }
2186
2187 #[test]
2188 fn executes_sql_query_against_json_file() -> Result<()> {
2189 let json_path = temp_json_path("json");
2190 write_test_json(&json_path)?;
2191
2192 let result = run_query(
2193 &json_path,
2194 None,
2195 "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
2196 true,
2197 )?;
2198
2199 assert_eq!(result.columns, vec!["name", "price"]);
2200 assert_eq!(
2201 result.rows,
2202 vec![vec![
2203 QueryValue::Text("Keyboard".into()),
2204 QueryValue::Real(12.5)
2205 ]]
2206 );
2207
2208 fs::remove_file(json_path)?;
2209 Ok(())
2210 }
2211
2212 #[test]
2213 fn executes_sql_query_against_json_sheet_key() -> Result<()> {
2214 let json_path = temp_json_path("json-sheet-key");
2215 write_test_json_with_sections(&json_path)?;
2216
2217 let result = run_query(
2218 &json_path,
2219 Some("Archive"),
2220 "SELECT name, price FROM table",
2221 true,
2222 )?;
2223
2224 assert_eq!(result.columns, vec!["name", "price"]);
2225 assert_eq!(
2226 result.rows,
2227 vec![vec![
2228 QueryValue::Text("Legacy Cable".into()),
2229 QueryValue::Real(3.5)
2230 ]]
2231 );
2232
2233 fs::remove_file(json_path)?;
2234 Ok(())
2235 }
2236
2237 #[test]
2238 fn executes_sql_query_against_first_markdown_table_by_default() -> Result<()> {
2239 let markdown_path = temp_markdown_path("markdown-default");
2240 write_test_markdown_with_tables(&markdown_path)?;
2241
2242 let result = run_query(
2243 &markdown_path,
2244 None,
2245 "SELECT name, price FROM table WHERE active = 1",
2246 true,
2247 )?;
2248
2249 assert_eq!(result.columns, vec!["name", "price"]);
2250 assert_eq!(
2251 result.rows,
2252 vec![vec![
2253 QueryValue::Text("Keyboard".into()),
2254 QueryValue::Real(12.5)
2255 ]]
2256 );
2257
2258 fs::remove_file(markdown_path)?;
2259 Ok(())
2260 }
2261
2262 #[test]
2263 fn reports_actionable_error_for_csv_selector() -> Result<()> {
2264 let csv_path = temp_csv_path("csv-selector-error");
2265 write_test_csv(&csv_path)?;
2266
2267 let error = run_query(&csv_path, Some("Sheet1"), "SELECT name FROM table", true)
2268 .expect_err("CSV selector should fail");
2269 let message = error.to_string();
2270 assert!(message.contains("does not support selector 'Sheet1'"));
2271 assert!(message.contains("Remove ':Sheet1'"));
2272
2273 fs::remove_file(csv_path)?;
2274 Ok(())
2275 }
2276
2277 #[test]
2278 fn reports_actionable_error_for_jsonl_selector() -> Result<()> {
2279 let jsonl_path = temp_jsonl_path("jsonl-selector-error");
2280 write_test_jsonl(&jsonl_path)?;
2281
2282 let error = run_query(
2283 &jsonl_path,
2284 Some("Records"),
2285 "SELECT name FROM table",
2286 true,
2287 )
2288 .expect_err("JSONL selector should fail");
2289 let message = error.to_string();
2290 assert!(message.contains("does not support selector 'Records'"));
2291 assert!(message.contains("Remove ':Records'"));
2292
2293 fs::remove_file(jsonl_path)?;
2294 Ok(())
2295 }
2296
2297 #[test]
2298 fn reports_json_key_error_with_available_keys() -> Result<()> {
2299 let json_path = temp_json_path("json-missing-key");
2300 write_test_json_with_sections(&json_path)?;
2301
2302 let error = run_query(
2303 &json_path,
2304 Some("Missing"),
2305 "SELECT name FROM table",
2306 true,
2307 )
2308 .expect_err("missing JSON key should fail");
2309 let message = error.to_string();
2310 assert!(message.contains("JSON key 'Missing' not found"));
2311 assert!(message.contains("Available keys: Archive, Inventory"));
2312
2313 fs::remove_file(json_path)?;
2314 Ok(())
2315 }
2316
2317 #[test]
2318 fn reports_invalid_markdown_selector_with_guidance() -> Result<()> {
2319 let markdown_path = temp_markdown_path("markdown-invalid-selector");
2320 write_test_markdown_with_tables(&markdown_path)?;
2321
2322 let error = run_query(
2323 &markdown_path,
2324 Some("abc"),
2325 "SELECT name FROM table",
2326 true,
2327 )
2328 .expect_err("non-numeric markdown selector should fail");
2329 let message = error.to_string();
2330 assert!(message.contains("invalid Markdown table selector 'abc'"));
2331 assert!(message.contains("':1'"));
2332
2333 fs::remove_file(markdown_path)?;
2334 Ok(())
2335 }
2336
2337 #[test]
2338 fn reports_empty_markdown_table_as_actionable_error() -> Result<()> {
2339 let markdown_path = temp_markdown_path("markdown-empty-table");
2340 write_test_markdown_with_headers_only(&markdown_path)?;
2341
2342 let error = run_query(&markdown_path, None, "SELECT name FROM table", true)
2343 .expect_err("empty markdown table should fail");
2344 let message = error.to_string();
2345 assert!(message.contains("is empty (no data rows)"));
2346
2347 fs::remove_file(markdown_path)?;
2348 Ok(())
2349 }
2350
2351 #[test]
2352 fn reports_unknown_table_with_inspection_hint() -> Result<()> {
2353 let workbook_path = temp_path("unknown-table-query");
2354 write_test_workbook(&workbook_path)?;
2355
2356 let error = run_query(&workbook_path, Some("Sheet1"), "SELECT * FROM missing_table", true)
2357 .expect_err("unknown table should fail");
2358 let message = error.to_string();
2359 assert!(message.contains("unknown table 'missing_table'"));
2360 assert!(message.contains("qf tables --input"));
2361
2362 fs::remove_file(workbook_path)?;
2363 Ok(())
2364 }
2365
2366 #[test]
2367 fn reports_unknown_column_with_schema_hint() -> Result<()> {
2368 let workbook_path = temp_path("unknown-column-query");
2369 write_test_workbook(&workbook_path)?;
2370
2371 let error = run_query(
2372 &workbook_path,
2373 Some("Sheet1"),
2374 "SELECT missing_column FROM table",
2375 true,
2376 )
2377 .expect_err("unknown column should fail");
2378 let message = error.to_string();
2379 assert!(message.contains("unknown column 'missing_column'"));
2380 assert!(message.contains("qf schema --input"));
2381
2382 fs::remove_file(workbook_path)?;
2383 Ok(())
2384 }
2385
2386 #[test]
2387 fn executes_sql_query_against_markdown_table_by_numeric_key() -> Result<()> {
2388 let markdown_path = temp_markdown_path("markdown-key");
2389 write_test_markdown_with_tables(&markdown_path)?;
2390
2391 let result = run_query(
2392 &markdown_path,
2393 Some("2"),
2394 "SELECT name, price FROM table",
2395 true,
2396 )?;
2397
2398 assert_eq!(result.columns, vec!["name", "price"]);
2399 assert_eq!(
2400 result.rows,
2401 vec![vec![
2402 QueryValue::Text("Legacy Cable".into()),
2403 QueryValue::Real(3.5)
2404 ]]
2405 );
2406
2407 fs::remove_file(markdown_path)?;
2408 Ok(())
2409 }
2410
2411 #[test]
2412 fn executes_query_against_heterogeneous_xlsx_xml_csv_jsonl_json_markdown_inputs() -> Result<()> {
2413 let workbook_path = temp_path("mixed4-xlsx");
2414 let xml_path = temp_xml_path("mixed4-xml");
2415 let csv_path = temp_csv_path("mixed4-csv");
2416 let jsonl_path = temp_jsonl_path("mixed4-jsonl");
2417 let json_path = temp_json_path("mixed4-json");
2418 let markdown_path = temp_markdown_path("mixed4-markdown");
2419 write_test_workbook(&workbook_path)?;
2420 write_test_xml(&xml_path)?;
2421 write_test_csv(&csv_path)?;
2422 write_test_jsonl(&jsonl_path)?;
2423 write_test_json(&json_path)?;
2424 write_test_markdown_with_tables(&markdown_path)?;
2425
2426 let result = run_query_with_params_multi_inputs(
2427 &[
2428 WorkbookInput {
2429 path: workbook_path.as_path(),
2430 sheet_name: Some("Sheet1"),
2431 table_name: None,
2432 },
2433 WorkbookInput {
2434 path: xml_path.as_path(),
2435 sheet_name: None,
2436 table_name: None,
2437 },
2438 WorkbookInput {
2439 path: csv_path.as_path(),
2440 sheet_name: None,
2441 table_name: None,
2442 },
2443 WorkbookInput {
2444 path: jsonl_path.as_path(),
2445 sheet_name: None,
2446 table_name: None,
2447 },
2448 WorkbookInput {
2449 path: json_path.as_path(),
2450 sheet_name: None,
2451 table_name: None,
2452 },
2453 WorkbookInput {
2454 path: markdown_path.as_path(),
2455 sheet_name: None,
2456 table_name: None,
2457 },
2458 ],
2459 "SELECT COUNT(*) AS total_rows FROM table UNION ALL SELECT COUNT(*) AS total_rows FROM table2 UNION ALL SELECT COUNT(*) AS total_rows FROM table3 UNION ALL SELECT COUNT(*) AS total_rows FROM table4 UNION ALL SELECT COUNT(*) AS total_rows FROM table5 UNION ALL SELECT COUNT(*) AS total_rows FROM table6",
2460 &[],
2461 true,
2462 )?;
2463
2464 assert_eq!(result.columns, vec!["total_rows"]);
2465 assert_eq!(
2466 result.rows,
2467 vec![
2468 vec![QueryValue::Integer(2)],
2469 vec![QueryValue::Integer(2)],
2470 vec![QueryValue::Integer(2)],
2471 vec![QueryValue::Integer(2)],
2472 vec![QueryValue::Integer(2)],
2473 vec![QueryValue::Integer(2)]
2474 ]
2475 );
2476
2477 fs::remove_file(&workbook_path)?;
2478 fs::remove_file(&xml_path)?;
2479 fs::remove_file(&csv_path)?;
2480 fs::remove_file(&jsonl_path)?;
2481 fs::remove_file(&json_path)?;
2482 fs::remove_file(&markdown_path)?;
2483 Ok(())
2484 }
2485
2486 #[test]
2487 fn writes_query_result_to_xlsx() -> Result<()> {
2488 let output_path = temp_path("output");
2489 let result = QueryResult {
2490 columns: vec!["item".into(), "total".into()],
2491 rows: vec![vec![
2492 QueryValue::Text("Mouse".into()),
2493 QueryValue::Integer(3),
2494 ]],
2495 };
2496
2497 write_xlsx(&result, &output_path)?;
2498
2499 let written = run_query(
2500 &output_path,
2501 Some("Sheet1"),
2502 "SELECT item, total FROM table",
2503 true,
2504 )?;
2505
2506 assert_eq!(written.columns, vec!["item", "total"]);
2507 assert_eq!(
2508 written.rows,
2509 vec![vec![
2510 QueryValue::Text("Mouse".into()),
2511 QueryValue::Real(3.0)
2512 ]]
2513 );
2514
2515 fs::remove_file(output_path)?;
2516 Ok(())
2517 }
2518
2519 #[test]
2520 fn renders_csv_with_escaping() {
2521 let result = QueryResult {
2522 columns: vec!["name".into(), "notes".into()],
2523 rows: vec![vec![
2524 QueryValue::Text("Mouse".into()),
2525 QueryValue::Text("line1,line2".into()),
2526 ]],
2527 };
2528
2529 assert_eq!(render_csv(&result), "name,notes\nMouse,\"line1,line2\"");
2530 }
2531
2532 #[test]
2533 fn renders_text_with_aligned_columns() {
2534 let result = QueryResult {
2535 columns: vec!["mese".into(), "totale_ore".into()],
2536 rows: vec![
2537 vec![QueryValue::Text("2026-01-01".into()), QueryValue::Real(10.5)],
2538 vec![QueryValue::Text("2026-12-01".into()), QueryValue::Integer(2)],
2539 ],
2540 };
2541
2542 assert_eq!(
2543 render_text(&result),
2544 "mese | totale_ore\n-----------+-----------\n2026-01-01 | 10.5 \n2026-12-01 | 2 "
2545 );
2546 }
2547
2548 #[test]
2549 fn renders_jsonl() {
2550 let result = QueryResult {
2551 columns: vec!["item".into(), "stock".into(), "note".into()],
2552 rows: vec![vec![
2553 QueryValue::Text("Desk".into()),
2554 QueryValue::Integer(8),
2555 QueryValue::Null,
2556 ]],
2557 };
2558
2559 assert_eq!(
2560 render_jsonl(&result),
2561 "{\"item\":\"Desk\",\"stock\":8,\"note\":null}"
2562 );
2563 }
2564
2565 #[test]
2566 fn renders_markdown() {
2567 let result = QueryResult {
2568 columns: vec!["category".into(), "total".into()],
2569 rows: vec![vec![
2570 QueryValue::Text("electronics".into()),
2571 QueryValue::Integer(47),
2572 ]],
2573 };
2574
2575 assert_eq!(
2576 render_markdown(&result),
2577 "| category | total |\n| --- | --- |\n| electronics | 47 |"
2578 );
2579 }
2580}