sheeter-engine 1.5.0

Convert, inspect, and transform data files across eleven formats with streaming where it matters and auto-detection where it can.
Documentation
//! Table rendering and multi-source loading for `sheeter view`.
//!
//! [`expand`] reads a file, detects its format, decodes it into the canonical
//! hub, and returns one `(label, value)` entry per logical table. For
//! single-table formats (CSV, TSV, JSON, NDJSON, YAML, XML, TOML, single-table
//! SQL) the label is the filename stem. For XLSX and ODS, every sheet becomes
//! its own entry, labelled `<stem> (<sheet>)`.
//!
//! [`render`] produces either a sequence of formatted tables (one per entry)
//! or a JSON document. With `to_json = false`, each entry is prefixed with
//! `Output table No. N — <label>` and rendered as a UTF-8 box table. With
//! `to_json = true` and exactly one entry, the raw hub JSON is emitted; with
//! multiple entries, a JSON object keyed by label is emitted.
//!
//! # Examples
//!
//! ```
//! use sheeter_engine::view;
//! use std::fs;
//!
//! let dir = tempfile::tempdir().unwrap();
//! fs::write(dir.path().join("a.csv"), "name,age\nAlice,30\n").unwrap();
//! let expanded = view::expand(dir.path().join("a.csv").to_str().unwrap()).unwrap();
//! assert_eq!(expanded.len(), 1);
//! assert_eq!(expanded[0].0, "a");
//! let rendered = view::render(expanded, false).unwrap();
//! assert!(rendered.contains("Output table No. 1"));
//! assert!(rendered.contains("Alice"));
//! ```

use crate::{detect_content, detect_extension, CanonicalValue, Format, SqlDialect};
use comfy_table::{presets, Cell, CellAlignment, ContentArrangement, Table};
use std::error::Error;
use std::path::Path;

/// Loads a file and returns one `(label, value)` pair per logical table.
///
/// Multi-sheet XLSX and ODS inputs produce multiple entries, one per sheet.
/// All other formats produce exactly one entry. The label is the filename
/// stem for single-table inputs, and `<stem> (<sheet>)` for multi-sheet
/// inputs.
///
/// # Errors
///
/// Returns an error if the file cannot be read, the format cannot be
/// detected, or the format parser rejects the contents.
pub fn expand(path: &str) -> Result<Vec<(String, CanonicalValue)>, Box<dyn Error>> {
    let bytes = std::fs::read(path)?;
    let fmt = match detect_extension(Path::new(path)) {
        Some(f) => f,
        None => detect_content(&bytes)?,
    };
    let stem = Path::new(path)
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or(path)
        .to_string();

    match fmt {
        Format::Xlsx => {
            let sheets = CanonicalValue::from_xlsx_workbook(&bytes)?;
            Ok(sheets
                .into_iter()
                .map(|(name, v)| (format!("{stem} ({name})"), v))
                .collect())
        }
        Format::Ods => {
            let v = CanonicalValue::from_ods(&bytes)?;
            match v {
                CanonicalValue::Object(o) if o.len() > 1 => Ok(o
                    .into_iter()
                    .map(|(name, v)| (format!("{stem} ({name})"), v))
                    .collect()),
                other => Ok(vec![(stem, other)]),
            }
        }
        Format::Parquet => {
            let v = crate::from_parquet(&bytes)?;
            Ok(vec![(stem, v)])
        }
        other => {
            let text = std::str::from_utf8(&bytes)?;
            let hub = decode_text(other, text)?;
            if let CanonicalValue::Object(o) = &hub {
                if other == Format::Sql && o.len() > 1 {
                    let owned = hub.clone();
                    let CanonicalValue::Object(inner) = owned else {
                        unreachable!()
                    };
                    return Ok(inner
                        .into_iter()
                        .map(|(name, v)| (format!("{stem} ({name})"), v))
                        .collect());
                }
            }
            Ok(vec![(stem, hub)])
        }
    }
}

fn decode_text(fmt: Format, text: &str) -> Result<CanonicalValue, Box<dyn Error>> {
    match fmt {
        Format::Csv => CanonicalValue::from_csv(text),
        Format::Tsv => CanonicalValue::from_tsv(text),
        Format::Json => CanonicalValue::from_json(text),
        Format::Ndjson => CanonicalValue::from_ndjson(text),
        Format::Yaml => CanonicalValue::from_yaml(text),
        Format::Xml => CanonicalValue::from_xml(text),
        Format::Toml => CanonicalValue::from_toml(text),
        Format::Sql => {
            let sheets = CanonicalValue::from_sql_workbook(text, SqlDialect::Generic)?;
            if sheets.len() == 1 {
                let mut it = sheets.into_iter();
                Ok(it.next().unwrap().1)
            } else {
                Ok(CanonicalValue::Object(sheets))
            }
        }
        Format::Xlsx | Format::Ods | Format::Parquet => {
            Err(format!("{fmt:?} requires binary decode").into())
        }
    }
}

/// Renders the loaded sources as formatted tables or as JSON.
///
/// # Examples
///
/// ```
/// use sheeter_engine::view;
/// use std::fs;
///
/// let dir = tempfile::tempdir().unwrap();
/// fs::write(dir.path().join("a.csv"), "x\n1\n").unwrap();
/// let expanded = view::expand(dir.path().join("a.csv").to_str().unwrap()).unwrap();
/// let json = view::render(expanded, true).unwrap();
/// assert_eq!(json.trim(), r#"[{"x":"1"}]"#);
/// ```
pub fn render(
    sources: Vec<(String, CanonicalValue)>,
    to_json: bool,
) -> Result<String, Box<dyn Error>> {
    let mut out = String::new();
    if to_json {
        if sources.len() == 1 {
            out.push_str(&sources[0].1.to_json()?);
            out.push('\n');
        } else {
            let mut map = serde_json::Map::new();
            for (label, value) in &sources {
                map.insert(label.clone(), value.to_json_value());
            }
            out.push_str(&serde_json::to_string(&map)?);
            out.push('\n');
        }
        return Ok(out);
    }
    for (i, (label, value)) in sources.iter().enumerate() {
        if i > 0 {
            out.push('\n');
        }
        out.push_str(&format!("Output table No. {} — {}\n", i + 1, label));
        out.push_str(&render_table(value)?);
        out.push('\n');
    }
    Ok(out)
}

fn render_table(value: &CanonicalValue) -> Result<String, Box<dyn Error>> {
    let rows: Vec<CanonicalValue> = match value {
        CanonicalValue::Array(a) => a.clone(),
        CanonicalValue::Object(o) => vec![CanonicalValue::Object(o.clone())],
        other => {
            let mut t = Table::new();
            t.load_preset(presets::UTF8_FULL);
            t.add_row(vec![cell_from(other)]);
            return Ok(t.to_string());
        }
    };

    let mut headers: Vec<String> = Vec::new();
    for row in &rows {
        if let CanonicalValue::Object(o) = row {
            for (k, _) in o {
                if !headers.contains(k) {
                    headers.push(k.clone());
                }
            }
        }
    }

    if headers.is_empty() {
        return Ok(String::new());
    }

    let mut table = Table::new();
    table.load_preset(presets::UTF8_FULL);
    table.set_content_arrangement(ContentArrangement::Dynamic);
    table.set_header(headers.iter().map(Cell::new));

    for row in &rows {
        if let CanonicalValue::Object(o) = row {
            let cells: Vec<Cell> = headers
                .iter()
                .map(|h| match o.iter().find(|(k, _)| k == h).map(|(_, v)| v) {
                    Some(v) => cell_from(v),
                    None => Cell::new(""),
                })
                .collect();
            table.add_row(cells);
        }
    }

    Ok(table.to_string())
}

fn cell_from(v: &CanonicalValue) -> Cell {
    match v {
        CanonicalValue::Null => Cell::new(""),
        CanonicalValue::Bool(b) => Cell::new(b.to_string()),
        CanonicalValue::Number(n) => Cell::new(n.to_string()).set_alignment(CellAlignment::Right),
        CanonicalValue::String(s) => Cell::new(s),
        CanonicalValue::Bytes(b) => Cell::new(format!("<{} bytes>", b.len())),
        CanonicalValue::Array(_) | CanonicalValue::Object(_) => Cell::new("<nested>"),
    }
}