Skip to main content

sheeter_engine/
detect.rs

1use crate::{CanonicalValue, Format};
2use std::error::Error;
3use std::path::Path;
4
5/// Detects the format of a byte buffer by content.
6///
7/// Tries formats in order: JSON, NDJSON, XML (if it starts with `<`),
8/// CSV, TSV, TOML, YAML. Returns the first format whose parser accepts
9/// the input. The order matters: JSON beats NDJSON (since `[1,2]` is
10/// both), and CSV/TSV beat YAML (which would otherwise happily eat a
11/// tabular file as a multi-line scalar).
12///
13/// # Errors
14/// Returns an error if the input is not valid UTF-8 or no parser accepts it.
15///
16/// # Examples
17///
18/// ```
19/// use sheeter_engine::{detect_content, Format};
20/// assert_eq!(detect_content(b"[1,2]").unwrap(), Format::Json);
21/// assert_eq!(detect_content(b"a,b\n1,2\n").unwrap(), Format::Csv);
22/// assert_eq!(detect_content(b"a\tb\n1\t2\n").unwrap(), Format::Tsv);
23/// assert_eq!(detect_content(b"{\"a\":1}\n{\"a\":2}\n").unwrap(), Format::Ndjson);
24/// assert_eq!(detect_content(b"<r><a>1</a></r>").unwrap(), Format::Xml);
25/// ```
26pub fn detect_content(input: &[u8]) -> Result<Format, Box<dyn Error>> {
27    if input.starts_with(b"PAR1") {
28        return Ok(Format::Parquet);
29    }
30    if input.starts_with(b"PK\x03\x04") || input.starts_with(b"PK\x05\x06") {
31        return detect_zip(input);
32    }
33    if input.starts_with(b"PAR1") {
34        return Err("Parquet is not supported in this release".into());
35    }
36
37    let stripped = input.strip_prefix("\u{feff}".as_bytes()).unwrap_or(input);
38    let text = std::str::from_utf8(stripped)
39        .map_err(|_| "input is not valid UTF-8; format detection requires text")?;
40
41    if text.trim().is_empty() {
42        return Err("empty input; cannot detect format".into());
43    }
44
45    if serde_json::from_str::<serde_json::Value>(text).is_ok() {
46        return Ok(Format::Json);
47    }
48
49    if is_ndjson(text) {
50        return Ok(Format::Ndjson);
51    }
52
53    let trimmed = text.trim_start();
54    if trimmed.starts_with('<') && quick_xml::Reader::from_str(text).read_event().is_ok() {
55        return Ok(Format::Xml);
56    }
57
58    if looks_like_delimited(text, b',') && CanonicalValue::from_csv(text).is_ok() {
59        return Ok(Format::Csv);
60    }
61    if looks_like_delimited(text, b'\t') && CanonicalValue::from_tsv(text).is_ok() {
62        return Ok(Format::Tsv);
63    }
64
65    if toml::from_str::<toml::Value>(text).is_ok() {
66        return Ok(Format::Toml);
67    }
68
69    if CanonicalValue::from_yaml(text).is_ok() {
70        return Ok(Format::Yaml);
71    }
72
73    Err("could not detect format from content; use --from to specify".into())
74}
75
76/// Detects the format of a ZIP-based document by inspecting its entries.
77///
78/// ODS files contain a `mimetype` entry with the ODF spreadsheet media type.
79/// XLSX and other OOXML files contain `[Content_Types].xml`. Anything else
80/// errors with a clear message instead of trying to guess.
81///
82/// # Examples
83///
84/// ```ignore
85/// // Covered in tests/detect.rs via generated ODS/XLSX fixtures.
86/// ```
87fn detect_zip(input: &[u8]) -> Result<Format, Box<dyn Error>> {
88    use std::io::Cursor;
89    let cursor = Cursor::new(input.to_vec());
90    let mut archive =
91        zip::ZipArchive::new(cursor).map_err(|e| format!("not a valid ZIP archive: {e}"))?;
92
93    if let Ok(mut mimetype) = archive.by_name("mimetype") {
94        use std::io::Read;
95        let mut content = String::new();
96        mimetype
97            .read_to_string(&mut content)
98            .map_err(|e| format!("failed to read mimetype entry: {e}"))?;
99        return match content.trim() {
100            "application/vnd.oasis.opendocument.spreadsheet" => Ok(Format::Ods),
101            "application/vnd.oasis.opendocument.text" => {
102                Err("ODT (text document) is not a tabular format".into())
103            }
104            "application/vnd.oasis.opendocument.presentation" => {
105                Err("ODP (presentation) is not a tabular format".into())
106            }
107            other => Err(format!("unsupported ODF mimetype: {other}").into()),
108        };
109    }
110
111    if archive.by_name("[Content_Types].xml").is_ok() {
112        return Ok(Format::Xlsx);
113    }
114
115    Err("ZIP archive does not match any supported format".into())
116}
117
118/// Maps a file extension to a `Format`, if recognized.
119pub fn detect_extension(path: &Path) -> Option<Format> {
120    let ext = path.extension()?.to_str()?.to_ascii_lowercase();
121    match ext.as_str() {
122        "csv" => Some(Format::Csv),
123        "tsv" | "tab" => Some(Format::Tsv),
124        "json" => Some(Format::Json),
125        "ndjson" | "jsonl" => Some(Format::Ndjson),
126        "yaml" | "yml" => Some(Format::Yaml),
127        "xml" => Some(Format::Xml),
128        "toml" => Some(Format::Toml),
129        "xlsx" => Some(Format::Xlsx),
130        "ods" => Some(Format::Ods),
131        "sql" => Some(Format::Sql),
132        "parquet" | "pq" => Some(Format::Parquet),
133        _ => None,
134    }
135}
136
137fn is_ndjson(text: &str) -> bool {
138    let mut count = 0;
139    for line in text.lines() {
140        let trimmed = line.trim();
141        if trimmed.is_empty() {
142            continue;
143        }
144        if serde_json::from_str::<serde_json::Value>(trimmed).is_err() {
145            return false;
146        }
147        count += 1;
148    }
149    count > 0
150}
151
152fn looks_like_delimited(text: &str, delim: u8) -> bool {
153    let mut lines = text.lines().filter(|l| !l.trim().is_empty());
154    let first = match lines.next() {
155        Some(l) => l.as_bytes(),
156        None => return false,
157    };
158    let count_first = first.iter().filter(|&&b| b == delim).count();
159    if count_first == 0 {
160        return false;
161    }
162    lines.next().is_some() || count_first >= 1
163}