1use crate::{CanonicalValue, Format};
2use std::error::Error;
3use std::path::Path;
4
5pub fn detect_content(input: &[u8]) -> Result<Format, Box<dyn Error>> {
27 if input.starts_with(b"PK\x03\x04") || input.starts_with(b"PK\x05\x06") {
28 return detect_zip(input);
29 }
30 if input.starts_with(b"PAR1") {
31 return Err("Parquet is not supported in this release".into());
32 }
33
34 let stripped = input.strip_prefix("\u{feff}".as_bytes()).unwrap_or(input);
35 let text = std::str::from_utf8(stripped)
36 .map_err(|_| "input is not valid UTF-8; format detection requires text")?;
37
38 if text.trim().is_empty() {
39 return Err("empty input; cannot detect format".into());
40 }
41
42 if serde_json::from_str::<serde_json::Value>(text).is_ok() {
43 return Ok(Format::Json);
44 }
45
46 if is_ndjson(text) {
47 return Ok(Format::Ndjson);
48 }
49
50 let trimmed = text.trim_start();
51 if trimmed.starts_with('<') && quick_xml::Reader::from_str(text).read_event().is_ok() {
52 return Ok(Format::Xml);
53 }
54
55 if looks_like_delimited(text, b',') && CanonicalValue::from_csv(text).is_ok() {
56 return Ok(Format::Csv);
57 }
58 if looks_like_delimited(text, b'\t') && CanonicalValue::from_tsv(text).is_ok() {
59 return Ok(Format::Tsv);
60 }
61
62 if toml::from_str::<toml::Value>(text).is_ok() {
63 return Ok(Format::Toml);
64 }
65
66 if CanonicalValue::from_yaml(text).is_ok() {
67 return Ok(Format::Yaml);
68 }
69
70 Err("could not detect format from content; use --from to specify".into())
71}
72
73fn detect_zip(input: &[u8]) -> Result<Format, Box<dyn Error>> {
85 use std::io::Cursor;
86 let cursor = Cursor::new(input.to_vec());
87 let mut archive =
88 zip::ZipArchive::new(cursor).map_err(|e| format!("not a valid ZIP archive: {e}"))?;
89
90 if let Ok(mut mimetype) = archive.by_name("mimetype") {
91 use std::io::Read;
92 let mut content = String::new();
93 mimetype
94 .read_to_string(&mut content)
95 .map_err(|e| format!("failed to read mimetype entry: {e}"))?;
96 return match content.trim() {
97 "application/vnd.oasis.opendocument.spreadsheet" => Ok(Format::Ods),
98 "application/vnd.oasis.opendocument.text" => {
99 Err("ODT (text document) is not a tabular format".into())
100 }
101 "application/vnd.oasis.opendocument.presentation" => {
102 Err("ODP (presentation) is not a tabular format".into())
103 }
104 other => Err(format!("unsupported ODF mimetype: {other}").into()),
105 };
106 }
107
108 if archive.by_name("[Content_Types].xml").is_ok() {
109 return Ok(Format::Xlsx);
110 }
111
112 Err("ZIP archive does not match any supported format".into())
113}
114
115pub fn detect_extension(path: &Path) -> Option<Format> {
117 let ext = path.extension()?.to_str()?.to_ascii_lowercase();
118 match ext.as_str() {
119 "csv" => Some(Format::Csv),
120 "tsv" | "tab" => Some(Format::Tsv),
121 "json" => Some(Format::Json),
122 "ndjson" | "jsonl" => Some(Format::Ndjson),
123 "yaml" | "yml" => Some(Format::Yaml),
124 "xml" => Some(Format::Xml),
125 "toml" => Some(Format::Toml),
126 "xlsx" => Some(Format::Xlsx),
127 "ods" => Some(Format::Ods),
128 "sql" => Some(Format::Sql),
129 _ => None,
130 }
131}
132
133fn is_ndjson(text: &str) -> bool {
134 let mut count = 0;
135 for line in text.lines() {
136 let trimmed = line.trim();
137 if trimmed.is_empty() {
138 continue;
139 }
140 if serde_json::from_str::<serde_json::Value>(trimmed).is_err() {
141 return false;
142 }
143 count += 1;
144 }
145 count > 0
146}
147
148fn looks_like_delimited(text: &str, delim: u8) -> bool {
149 let mut lines = text.lines().filter(|l| !l.trim().is_empty());
150 let first = match lines.next() {
151 Some(l) => l.as_bytes(),
152 None => return false,
153 };
154 let count_first = first.iter().filter(|&&b| b == delim).count();
155 if count_first == 0 {
156 return false;
157 }
158 lines.next().is_some() || count_first >= 1
159}