sheeter-engine 1.2.0

Convert, inspect, and transform data files across ten formats with streaming where it matters and auto-detection where it can.
Documentation
use crate::{CanonicalValue, Format};
use std::error::Error;
use std::path::Path;

/// Detects the format of a byte buffer by content.
///
/// Tries formats in order: JSON, NDJSON, XML (if it starts with `<`),
/// CSV, TSV, TOML, YAML. Returns the first format whose parser accepts
/// the input. The order matters: JSON beats NDJSON (since `[1,2]` is
/// both), and CSV/TSV beat YAML (which would otherwise happily eat a
/// tabular file as a multi-line scalar).
///
/// # Errors
/// Returns an error if the input is not valid UTF-8 or no parser accepts it.
///
/// # Examples
///
/// ```
/// use sheeter_engine::{detect_content, Format};
/// assert_eq!(detect_content(b"[1,2]").unwrap(), Format::Json);
/// assert_eq!(detect_content(b"a,b\n1,2\n").unwrap(), Format::Csv);
/// assert_eq!(detect_content(b"a\tb\n1\t2\n").unwrap(), Format::Tsv);
/// assert_eq!(detect_content(b"{\"a\":1}\n{\"a\":2}\n").unwrap(), Format::Ndjson);
/// assert_eq!(detect_content(b"<r><a>1</a></r>").unwrap(), Format::Xml);
/// ```
pub fn detect_content(input: &[u8]) -> Result<Format, Box<dyn Error>> {
    if input.starts_with(b"PK\x03\x04") || input.starts_with(b"PK\x05\x06") {
        return detect_zip(input);
    }
    if input.starts_with(b"PAR1") {
        return Err("Parquet is not supported in this release".into());
    }

    let stripped = input.strip_prefix("\u{feff}".as_bytes()).unwrap_or(input);
    let text = std::str::from_utf8(stripped)
        .map_err(|_| "input is not valid UTF-8; format detection requires text")?;

    if text.trim().is_empty() {
        return Err("empty input; cannot detect format".into());
    }

    if serde_json::from_str::<serde_json::Value>(text).is_ok() {
        return Ok(Format::Json);
    }

    if is_ndjson(text) {
        return Ok(Format::Ndjson);
    }

    let trimmed = text.trim_start();
    if trimmed.starts_with('<') && quick_xml::Reader::from_str(text).read_event().is_ok() {
        return Ok(Format::Xml);
    }

    if looks_like_delimited(text, b',') && CanonicalValue::from_csv(text).is_ok() {
        return Ok(Format::Csv);
    }
    if looks_like_delimited(text, b'\t') && CanonicalValue::from_tsv(text).is_ok() {
        return Ok(Format::Tsv);
    }

    if toml::from_str::<toml::Value>(text).is_ok() {
        return Ok(Format::Toml);
    }

    if CanonicalValue::from_yaml(text).is_ok() {
        return Ok(Format::Yaml);
    }

    Err("could not detect format from content; use --from to specify".into())
}

/// Detects the format of a ZIP-based document by inspecting its entries.
///
/// ODS files contain a `mimetype` entry with the ODF spreadsheet media type.
/// XLSX and other OOXML files contain `[Content_Types].xml`. Anything else
/// errors with a clear message instead of trying to guess.
///
/// # Examples
///
/// ```ignore
/// // Covered in tests/detect.rs via generated ODS/XLSX fixtures.
/// ```
fn detect_zip(input: &[u8]) -> Result<Format, Box<dyn Error>> {
    use std::io::Cursor;
    let cursor = Cursor::new(input.to_vec());
    let mut archive =
        zip::ZipArchive::new(cursor).map_err(|e| format!("not a valid ZIP archive: {e}"))?;

    if let Ok(mut mimetype) = archive.by_name("mimetype") {
        use std::io::Read;
        let mut content = String::new();
        mimetype
            .read_to_string(&mut content)
            .map_err(|e| format!("failed to read mimetype entry: {e}"))?;
        return match content.trim() {
            "application/vnd.oasis.opendocument.spreadsheet" => Ok(Format::Ods),
            "application/vnd.oasis.opendocument.text" => {
                Err("ODT (text document) is not a tabular format".into())
            }
            "application/vnd.oasis.opendocument.presentation" => {
                Err("ODP (presentation) is not a tabular format".into())
            }
            other => Err(format!("unsupported ODF mimetype: {other}").into()),
        };
    }

    if archive.by_name("[Content_Types].xml").is_ok() {
        return Ok(Format::Xlsx);
    }

    Err("ZIP archive does not match any supported format".into())
}

/// Maps a file extension to a `Format`, if recognized.
pub fn detect_extension(path: &Path) -> Option<Format> {
    let ext = path.extension()?.to_str()?.to_ascii_lowercase();
    match ext.as_str() {
        "csv" => Some(Format::Csv),
        "tsv" | "tab" => Some(Format::Tsv),
        "json" => Some(Format::Json),
        "ndjson" | "jsonl" => Some(Format::Ndjson),
        "yaml" | "yml" => Some(Format::Yaml),
        "xml" => Some(Format::Xml),
        "toml" => Some(Format::Toml),
        "xlsx" => Some(Format::Xlsx),
        "ods" => Some(Format::Ods),
        "sql" => Some(Format::Sql),
        _ => None,
    }
}

fn is_ndjson(text: &str) -> bool {
    let mut count = 0;
    for line in text.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        if serde_json::from_str::<serde_json::Value>(trimmed).is_err() {
            return false;
        }
        count += 1;
    }
    count > 0
}

fn looks_like_delimited(text: &str, delim: u8) -> bool {
    let mut lines = text.lines().filter(|l| !l.trim().is_empty());
    let first = match lines.next() {
        Some(l) => l.as_bytes(),
        None => return false,
    };
    let count_first = first.iter().filter(|&&b| b == delim).count();
    if count_first == 0 {
        return false;
    }
    lines.next().is_some() || count_first >= 1
}