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    let stripped = input.strip_prefix("\u{feff}".as_bytes()).unwrap_or(input);
28    let text = std::str::from_utf8(stripped)
29        .map_err(|_| "input is not valid UTF-8; format detection requires text")?;
30
31    if text.trim().is_empty() {
32        return Err("empty input; cannot detect format".into());
33    }
34
35    if serde_json::from_str::<serde_json::Value>(text).is_ok() {
36        return Ok(Format::Json);
37    }
38
39    if is_ndjson(text) {
40        return Ok(Format::Ndjson);
41    }
42
43    let trimmed = text.trim_start();
44    if trimmed.starts_with('<') && quick_xml::Reader::from_str(text).read_event().is_ok() {
45        return Ok(Format::Xml);
46    }
47
48    if looks_like_delimited(text, b',') && CanonicalValue::from_csv(text).is_ok() {
49        return Ok(Format::Csv);
50    }
51    if looks_like_delimited(text, b'\t') && CanonicalValue::from_tsv(text).is_ok() {
52        return Ok(Format::Tsv);
53    }
54
55    if toml::from_str::<toml::Value>(text).is_ok() {
56        return Ok(Format::Toml);
57    }
58
59    if CanonicalValue::from_yaml(text).is_ok() {
60        return Ok(Format::Yaml);
61    }
62
63    Err("could not detect format from content; use --from to specify".into())
64}
65
66/// Maps a file extension to a `Format`, if recognized.
67pub fn detect_extension(path: &Path) -> Option<Format> {
68    let ext = path.extension()?.to_str()?.to_ascii_lowercase();
69    match ext.as_str() {
70        "csv" => Some(Format::Csv),
71        "tsv" | "tab" => Some(Format::Tsv),
72        "json" => Some(Format::Json),
73        "ndjson" | "jsonl" => Some(Format::Ndjson),
74        "yaml" | "yml" => Some(Format::Yaml),
75        "xml" => Some(Format::Xml),
76        "toml" => Some(Format::Toml),
77        "xlsx" => Some(Format::Xlsx),
78        "sql" => Some(Format::Sql),
79        _ => None,
80    }
81}
82
83fn is_ndjson(text: &str) -> bool {
84    let mut count = 0;
85    for line in text.lines() {
86        let trimmed = line.trim();
87        if trimmed.is_empty() {
88            continue;
89        }
90        if serde_json::from_str::<serde_json::Value>(trimmed).is_err() {
91            return false;
92        }
93        count += 1;
94    }
95    count > 0
96}
97
98fn looks_like_delimited(text: &str, delim: u8) -> bool {
99    let mut lines = text.lines().filter(|l| !l.trim().is_empty());
100    let first = match lines.next() {
101        Some(l) => l.as_bytes(),
102        None => return false,
103    };
104    let count_first = first.iter().filter(|&&b| b == delim).count();
105    if count_first == 0 {
106        return false;
107    }
108    lines.next().is_some() || count_first >= 1
109}