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"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
76fn 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
118pub 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}