Skip to main content

query_forge/
lib.rs

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/// The SQL-compatible type inferred for a column.
48#[derive(Debug, Clone, PartialEq)]
49pub enum InferredType {
50    Integer,
51    Real,
52    Text,
53    /// Column contains a mix of numeric and text values.
54    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/// Per-column information returned by inspection commands.
69#[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/// Optional per-column statistics (enabled by `--stats`).
78#[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/// Summary of a single loaded table, returned by `load_table_summaries`.
86#[derive(Debug, Clone)]
87pub struct TableSummary {
88    /// The logical SQL table name (explicit or auto-generated).
89    pub table_name: String,
90    /// The original file path as provided on the command line.
91    pub source_path: String,
92    /// The sheet/tag/key/index selector, if any.
93    pub source_selector: Option<String>,
94    pub row_count: usize,
95    pub columns: Vec<ColumnInfo>,
96    /// Up to `sample` rows of actual data (column values in column order).
97    pub sample_rows: Vec<Vec<QueryValue>>,
98    /// Non-fatal warnings (e.g. mixed-type columns, duplicate header normalisations).
99    pub warnings: Vec<String>,
100    /// Per-column statistics; `None` when `--stats` is not requested.
101    pub column_stats: Option<Vec<ColumnStats>>,
102}
103
104/// Load metadata for every input without running a SQL query.
105///
106/// Returns one [`TableSummary`] per input in declaration order.
107pub 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        // Warn about empty tables (non-fatal for inspection).
133        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
156/// Infer the SQL-compatible type and nullability for each column in `sheet`.
157fn 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                // Integer+Real and Real-only both map to REAL: integers are
182                // promotable to real without loss, so REAL is the wider type.
183                (_, 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
197/// Collect non-fatal warnings for a loaded table.
198fn 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    // Detect duplicate original column names that were de-duplicated by normalisation.
215    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
232/// Compute per-column distinct counts and min/max values.
233fn 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            // Track min/max as f64 to handle both Integer and Real columns
242            // uniformly without reparsing strings.
243            let mut min_num: Option<f64> = None;
244            let mut max_num: Option<f64> = None;
245            // Remember whether the extremes came from an integer so we can
246            // render them without a spurious decimal point.
247            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: &regex::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(&parameter_name)
1476                .with_context(|| format!("failed to inspect parameter {parameter_name}"))?
1477            {
1478                statement
1479                    .raw_bind_parameter(index, to_sql_value(&param.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('&', "&amp;")
1558        .replace('<', "&lt;")
1559        .replace('>', "&gt;")
1560        .replace('"', "&quot;")
1561        .replace('\'', "&apos;")
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}