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 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
66pub 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}