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 std::path::PathBuf;

use anyhow::{Result, bail};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InputSpec {
    pub(crate) path: PathBuf,
    pub(crate) sheet_name: Option<String>,
    pub(crate) table_name: Option<String>,
    /// Explicit format type parsed from the `|type` suffix (e.g. `|csv`, `|json`).
    /// Case-normalized to lowercase. Takes priority over the file extension.
    pub(crate) explicit_format: Option<String>,
}

pub(crate) fn parse_input_specs(
    entries: &[String],
    default_sheet: Option<&str>,
) -> Result<Vec<InputSpec>> {
    entries
        .iter()
        .map(|entry| parse_input_entry(entry, default_sheet))
        .collect()
}

fn parse_input_entry(entry: &str, default_sheet: Option<&str>) -> Result<InputSpec> {
    let (table_name, path_and_rest) = split_table_name(entry);
    let (path_and_sheet, explicit_format) = split_explicit_format(path_and_rest);
    let (path, sheet_name) = split_input_entry(path_and_sheet)?;
    if path.trim().is_empty() {
        bail!("invalid --input '{entry}', file path cannot be empty");
    }

    let path = PathBuf::from(path);

    // For clipboard paths, the extension-based format syntax (e.g. @clipboard.csv)
    // is no longer allowed.  Users must use the `|type` suffix instead.
    if crate::clipboard::is_clipboard_path(&path) && path.extension().is_some() {
        let ext = path
            .extension()
            .unwrap_or_default()
            .to_string_lossy()
            .to_ascii_lowercase();
        bail!(
            "invalid --input '{entry}': clipboard paths must not use a file extension for \
             format selection; use '@clipboard|{ext}' instead"
        );
    }

    Ok(InputSpec {
        path,
        sheet_name: sheet_name
            .map(str::to_owned)
            .or_else(|| default_sheet.map(str::to_owned)),
        table_name: table_name.map(str::to_owned),
        explicit_format: explicit_format.map(|f| f.to_ascii_lowercase()),
    })
}

/// Strips an optional `|type` suffix from the end of a path-spec string.
///
/// Returns `(rest, Some(type))` when a non-empty type token follows `|`,
/// otherwise `(entry, None)`.
pub(crate) fn split_explicit_format(entry: &str) -> (&str, Option<&str>) {
    if let Some((rest, fmt)) = entry.rsplit_once('|') {
        let fmt = fmt.trim();
        if !fmt.is_empty() {
            return (rest, Some(fmt));
        }
    }
    (entry, None)
}

/// Splits an optional `NAME=` prefix from the entry string.
///
/// Returns `(Some(name), rest)` if a valid identifier prefix is found,
/// otherwise `(None, entry)`.
fn split_table_name(entry: &str) -> (Option<&str>, &str) {
    if let Some(eq_pos) = entry.find('=') {
        let candidate = &entry[..eq_pos];
        if is_valid_table_name(candidate) {
            return (Some(candidate), &entry[eq_pos + 1..]);
        }
    }
    (None, entry)
}

/// Returns `true` if `s` is a non-empty string composed of ASCII alphanumeric
/// characters or underscores, and starts with a letter or underscore.
fn is_valid_table_name(s: &str) -> bool {
    let mut chars = s.chars();
    match chars.next() {
        Some(c) if c.is_ascii_alphabetic() || c == '_' => {
            chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
        }
        _ => false,
    }
}

pub(crate) fn split_input_entry(entry: &str) -> Result<(&str, Option<&str>)> {
    if let Some((path, sheet_name)) = entry.rsplit_once('#') {
        let sheet_name = sheet_name.trim();
        if sheet_name.is_empty() {
            bail!("invalid --input '{entry}', sheet name cannot be empty");
        }

        return Ok((path, Some(sheet_name)));
    }

    let Some((path, sheet_name)) = entry.rsplit_once(':') else {
        return Ok((entry, None));
    };

    // Preserve Windows drive-letter paths like C:\data.xlsx.
    if path.len() == 1
        && path.as_bytes()[0].is_ascii_alphabetic()
        && (sheet_name.starts_with('\\') || sheet_name.starts_with('/'))
    {
        return Ok((entry, None));
    }

    let sheet_name = sheet_name.trim();
    if sheet_name.is_empty() {
        bail!("invalid --input '{entry}', sheet name cannot be empty");
    }

    Ok((path, Some(sheet_name)))
}

#[cfg(test)]
mod tests {
    use super::{parse_input_specs, split_explicit_format, split_input_entry, split_table_name};

    #[test]
    fn splits_input_path_and_sheet() {
        let (path, sheet) = split_input_entry("./data.xlsx:Consuntivo").expect("valid input");

        assert_eq!(path, "./data.xlsx");
        assert_eq!(sheet, Some("Consuntivo"));
    }

    #[test]
    fn splits_input_path_and_sheet_with_hash_separator() {
        let (path, sheet) = split_input_entry("./data.xlsx#Consuntivo").expect("valid input");

        assert_eq!(path, "./data.xlsx");
        assert_eq!(sheet, Some("Consuntivo"));
    }

    #[test]
    fn keeps_windows_drive_path_without_sheet() {
        let (path, sheet) =
            split_input_entry(r"C:\data\report.xlsx").expect("valid windows path input");

        assert_eq!(path, r"C:\data\report.xlsx");
        assert_eq!(sheet, None);
    }

    #[test]
    fn parses_windows_drive_path_with_hash_sheet() {
        let (path, sheet) =
            split_input_entry(r"C:\data\report.xlsx#WKL").expect("valid windows path input");

        assert_eq!(path, r"C:\data\report.xlsx");
        assert_eq!(sheet, Some("WKL"));
    }

    #[test]
    fn applies_default_sheet_when_not_specified_in_input() {
        let entries = vec!["./consuntivo.xlsx".to_owned(), "./wkl.xlsx:WKL".to_owned()];
        let parsed = parse_input_specs(&entries, Some("Sheet1")).expect("parsed inputs");

        assert_eq!(parsed[0].sheet_name.as_deref(), Some("Sheet1"));
        assert_eq!(parsed[1].sheet_name.as_deref(), Some("WKL"));
    }

    #[test]
    fn extracts_table_name_prefix() {
        let (name, rest) = split_table_name("sales=./sales.xlsx:Q1");

        assert_eq!(name, Some("sales"));
        assert_eq!(rest, "./sales.xlsx:Q1");
    }

    #[test]
    fn extracts_table_name_prefix_no_sheet() {
        let (name, rest) = split_table_name("costs=./costs.csv");

        assert_eq!(name, Some("costs"));
        assert_eq!(rest, "./costs.csv");
    }

    #[test]
    fn no_table_name_when_no_equals() {
        let (name, rest) = split_table_name("./data.xlsx:Sheet1");

        assert_eq!(name, None);
        assert_eq!(rest, "./data.xlsx:Sheet1");
    }

    #[test]
    fn no_table_name_when_invalid_identifier() {
        let (name, rest) = split_table_name("123bad=./data.csv");

        assert_eq!(name, None);
        assert_eq!(rest, "123bad=./data.csv");
    }

    #[test]
    fn parses_explicit_table_name_with_sheet() {
        let entries = vec![
            "sales=./sales.xlsx:Q1".to_owned(),
            "costs=./costs.csv".to_owned(),
        ];
        let parsed = parse_input_specs(&entries, None).expect("parsed inputs");

        assert_eq!(parsed[0].table_name.as_deref(), Some("sales"));
        assert_eq!(parsed[0].sheet_name.as_deref(), Some("Q1"));
        assert_eq!(parsed[1].table_name.as_deref(), Some("costs"));
        assert_eq!(parsed[1].sheet_name, None);
    }

    #[test]
    fn parses_no_table_name_when_not_specified() {
        let entries = vec!["./data.xlsx:Sheet1".to_owned()];
        let parsed = parse_input_specs(&entries, None).expect("parsed inputs");

        assert_eq!(parsed[0].table_name, None);
        assert_eq!(parsed[0].sheet_name.as_deref(), Some("Sheet1"));
    }

    #[test]
    fn table_name_with_underscore_is_valid() {
        let (name, rest) = split_table_name("my_data=./data.csv");

        assert_eq!(name, Some("my_data"));
        assert_eq!(rest, "./data.csv");
    }

    #[test]
    fn table_name_starting_with_underscore_is_valid() {
        let (name, rest) = split_table_name("_data=./data.csv");

        assert_eq!(name, Some("_data"));
        assert_eq!(rest, "./data.csv");
    }

    #[test]
    fn splits_explicit_format_from_path() {
        let (path, fmt) = split_explicit_format("./data.txt|csv");

        assert_eq!(path, "./data.txt");
        assert_eq!(fmt, Some("csv"));
    }

    #[test]
    fn splits_explicit_format_case_insensitive_stored_as_lowercase() {
        let entries = vec!["./data.txt|CSV".to_owned()];
        let parsed = parse_input_specs(&entries, None).expect("parsed inputs");

        assert_eq!(parsed[0].explicit_format.as_deref(), Some("csv"));
    }

    #[test]
    fn splits_explicit_format_with_sheet_and_format() {
        let (path_and_sheet, fmt) = split_explicit_format("./data.xlsx#Main|xlsx");

        assert_eq!(path_and_sheet, "./data.xlsx#Main");
        assert_eq!(fmt, Some("xlsx"));
    }

    #[test]
    fn no_explicit_format_when_no_pipe() {
        let (path, fmt) = split_explicit_format("./data.csv");

        assert_eq!(path, "./data.csv");
        assert_eq!(fmt, None);
    }

    #[test]
    fn explicit_format_overrides_no_format_in_spec() {
        let entries = vec!["./data.txt|json".to_owned()];
        let parsed = parse_input_specs(&entries, None).expect("parsed inputs");

        assert_eq!(parsed[0].path.to_str(), Some("./data.txt"));
        assert_eq!(parsed[0].explicit_format.as_deref(), Some("json"));
    }

    #[test]
    fn clipboard_with_pipe_format_is_valid() {
        let entries = vec!["@clipboard|csv".to_owned()];
        let parsed = parse_input_specs(&entries, None).expect("parsed inputs");

        assert_eq!(parsed[0].path.to_str(), Some("@clipboard"));
        assert_eq!(parsed[0].explicit_format.as_deref(), Some("csv"));
    }

    #[test]
    fn clipboard_with_extension_is_rejected() {
        let entries = vec!["@clipboard.json".to_owned()];
        let error = parse_input_specs(&entries, None).expect_err("should reject extension syntax");

        assert!(error.to_string().contains("@clipboard|json"));
    }
}