query-forge 0.9.0

Run SQL queries and dataset diffs on XLSX/XML/CSV/JSON/JSONL/Markdown/HTML/Feather/Parquet inputs and export results as text, CSV, JSONL, Markdown, XML, HTML, XLSX, Feather, or Parquet
Documentation
use anyhow::{Context, Result, anyhow, bail};

use crate::{QueryValue, TypeInferenceOptions};

use super::{SheetData, normalize_text_headers, parse_scalar_value};

pub(super) fn load_markdown_sheet(
    markdown_path: &std::path::Path,
    requested_sheet: Option<&str>,
    inference_options: &TypeInferenceOptions,
    has_headers: bool,
) -> Result<SheetData> {
    let content = std::fs::read_to_string(markdown_path)
        .with_context(|| format!("failed to read {}", markdown_path.display()))?;
    load_markdown_sheet_from_str(
        markdown_path,
        &content,
        requested_sheet,
        inference_options,
        has_headers,
    )
}

pub(super) fn load_markdown_sheet_from_str(
    markdown_path: &std::path::Path,
    content: &str,
    requested_sheet: Option<&str>,
    inference_options: &TypeInferenceOptions,
    has_headers: bool,
) -> Result<SheetData> {
    let tables = parse_markdown_tables(&content);
    if tables.is_empty() {
        bail!(
            "Markdown input {} does not contain any table",
            markdown_path.display()
        );
    }

    let table_index = if let Some(key) = requested_sheet {
        let index = key
            .trim()
            .parse::<usize>()
            .map_err(|_| {
                anyhow!(
                    "invalid Markdown table selector '{key}' for {}. Use a 1-based numeric index such as ':1' or ':2'.",
                    markdown_path.display()
                )
            })?;
        if index == 0 {
            bail!(
                "invalid Markdown table selector '{key}' for {}. Table indexes are 1-based (:1, :2, ...).",
                markdown_path.display()
            );
        }
        index
    } else {
        1
    };

    let table_count = tables.len();
    let Some(table) = tables.into_iter().nth(table_index - 1) else {
        bail!(
            "Markdown table {} not found in {}. Found {} table(s); choose an index between 1 and {}.",
            table_index,
            markdown_path.display(),
            table_count,
            table_count
        );
    };

    let mut rows = table.rows;
    let columns = if has_headers {
        normalize_text_headers(&table.headers)
    } else {
        rows.insert(0, table.headers);
        let width = rows.iter().map(Vec::len).max().unwrap_or(0);
        (1..=width).map(|index| format!("column{index}")).collect()
    };

    if columns.is_empty() {
        bail!("Markdown table {} is empty", table_index);
    }

    let data_rows = rows
        .into_iter()
        .map(|row| {
            (0..columns.len())
                .map(|index| {
                    let value = row.get(index).map(String::as_str).unwrap_or("");
                    parse_scalar_value(value, inference_options)
                })
                .collect::<Vec<_>>()
        })
        .filter(|row| row.iter().any(|value| !matches!(value, QueryValue::Null)))
        .collect::<Vec<_>>();

    if data_rows.is_empty() {
        bail!(
            "Markdown table {} in {} is empty (no data rows)",
            table_index,
            markdown_path.display()
        );
    }

    Ok(SheetData {
        original_name: format!("table{table_index}"),
        columns,
        rows: data_rows,
    })
}

struct MarkdownTable {
    headers: Vec<String>,
    rows: Vec<Vec<String>>,
}

fn parse_markdown_tables(content: &str) -> Vec<MarkdownTable> {
    let lines = content.lines().collect::<Vec<_>>();
    let mut tables = Vec::new();
    let mut index = 0usize;

    while index + 1 < lines.len() {
        if !looks_like_markdown_row(lines[index]) || !is_markdown_separator(lines[index + 1]) {
            index += 1;
            continue;
        }

        let headers = parse_markdown_row(lines[index]);
        if headers.is_empty() {
            index += 1;
            continue;
        }

        let mut rows = Vec::new();
        index += 2;
        while index < lines.len() && looks_like_markdown_row(lines[index]) {
            if is_markdown_separator(lines[index]) {
                break;
            }
            let row = parse_markdown_row(lines[index]);
            if row.iter().any(|value| !value.trim().is_empty()) {
                rows.push(row);
            }
            index += 1;
        }

        tables.push(MarkdownTable { headers, rows });
    }

    tables
}

fn looks_like_markdown_row(line: &str) -> bool {
    line.contains('|')
}

fn is_markdown_separator(line: &str) -> bool {
    let parts = split_markdown_cells(line);
    if parts.is_empty() {
        return false;
    }

    parts.iter().all(|part| {
        let cell = part.trim();
        if cell.is_empty() {
            return false;
        }

        let inner = cell.trim_matches(':');
        !inner.is_empty() && inner.chars().all(|character| character == '-')
    })
}

fn parse_markdown_row(line: &str) -> Vec<String> {
    split_markdown_cells(line)
        .into_iter()
        .map(|cell| cell.replace("\\|", "|").trim().to_owned())
        .collect()
}

fn split_markdown_cells(line: &str) -> Vec<String> {
    let trimmed = line.trim();
    if trimmed.is_empty() {
        return Vec::new();
    }

    let without_prefix = trimmed.strip_prefix('|').unwrap_or(trimmed);
    let content = without_prefix.strip_suffix('|').unwrap_or(without_prefix);

    content.split('|').map(str::to_owned).collect()
}