use std::io::BufRead;
use crate::{Envelope, FaceError, InputFormat};
pub const SNIFF_PROBE_BYTES: usize = 4096;
const UTF8_BOM: [u8; 3] = [0xEF, 0xBB, 0xBF];
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SniffOutcome {
pub format: InputFormat,
pub bom_bytes: usize,
}
pub fn sniff_format(reader: &mut impl BufRead) -> Result<SniffOutcome, FaceError> {
let bom_bytes = strip_bom(reader)?;
let probe = take_probe(reader)?;
if Envelope::looks_like_envelope(&probe) {
return Ok(SniffOutcome {
format: InputFormat::FaceEnvelope,
bom_bytes,
});
}
let first_non_ws = probe.iter().find(|b| !b.is_ascii_whitespace());
let format = match first_non_ws {
None => {
return Err(FaceError::InputParse {
format: InputFormat::Json,
message: "input is empty (no bytes to classify)".to_string(),
});
}
Some(b'{') | Some(b'[') => classify_brace_or_bracket(&probe)?,
Some(_) => {
if parses_as_json_value(&probe) {
InputFormat::Json
} else if parses_as_jsonl(&probe) {
InputFormat::Jsonl
} else if let Some(format) = sniff_delimited(&probe) {
format
} else {
return Err(FaceError::InputParse {
format: InputFormat::Json,
message: "could not classify input as JSON, JSONL, CSV, or TSV".to_string(),
});
}
}
};
Ok(SniffOutcome { format, bom_bytes })
}
fn classify_brace_or_bracket(probe: &[u8]) -> Result<InputFormat, FaceError> {
match json_value_probe_state(probe) {
JsonProbeState::Complete => Ok(InputFormat::Json),
JsonProbeState::Incomplete => Ok(InputFormat::Json),
JsonProbeState::Invalid => {
if parses_as_jsonl(probe) {
Ok(InputFormat::Jsonl)
} else {
Err(FaceError::InputParse {
format: InputFormat::Json,
message: "input begins with `{` or `[` but parses as neither \
JSON nor JSONL"
.to_string(),
})
}
}
}
}
fn strip_bom(reader: &mut impl BufRead) -> Result<usize, FaceError> {
let buf = reader.fill_buf()?;
if buf.starts_with(&UTF8_BOM) {
reader.consume(UTF8_BOM.len());
Ok(UTF8_BOM.len())
} else {
Ok(0)
}
}
fn take_probe(reader: &mut impl BufRead) -> Result<Vec<u8>, FaceError> {
let buf = reader.fill_buf()?;
let len = buf.len().min(SNIFF_PROBE_BYTES);
Ok(buf[..len].to_vec())
}
fn parses_as_json_value(probe: &[u8]) -> bool {
matches!(json_value_probe_state(probe), JsonProbeState::Complete)
}
enum JsonProbeState {
Complete,
Incomplete,
Invalid,
}
fn json_value_probe_state(probe: &[u8]) -> JsonProbeState {
match serde_json::from_slice::<serde_json::Value>(probe) {
Ok(_) => JsonProbeState::Complete,
Err(error) if error.is_eof() => JsonProbeState::Incomplete,
Err(_) => JsonProbeState::Invalid,
}
}
fn parses_as_jsonl(probe: &[u8]) -> bool {
let text = match std::str::from_utf8(probe) {
Ok(t) => t,
Err(_) => return false,
};
let lines: Vec<&str> = text
.split('\n')
.map(|l| l.trim_end_matches('\r'))
.filter(|l| !l.trim().is_empty())
.collect();
if lines.len() < 2 {
return false;
}
let ok_count = lines
.iter()
.filter(|line| serde_json::from_str::<serde_json::Value>(line).is_ok())
.count();
ok_count >= 2
}
fn sniff_delimited(probe: &[u8]) -> Option<InputFormat> {
const CANDIDATES: [u8; 4] = [b',', b';', b'\t', b'|'];
let lines = sample_nonempty_lines(probe);
let mut best = None;
for delimiter in CANDIDATES {
let counts = lines
.iter()
.map(|line| count_delimiter_outside_quotes(line, delimiter))
.collect::<Vec<_>>();
let Some((&first, rest)) = counts.split_first() else {
continue;
};
if first == 0 || rest.iter().any(|count| *count != first) {
continue;
}
if best.is_none_or(|(_, best_count)| first > best_count) {
best = Some((delimiter, first));
}
}
match best.map(|(delimiter, _)| delimiter) {
Some(b'\t') => Some(InputFormat::Tsv),
Some(_) => Some(InputFormat::Csv),
None => None,
}
}
fn sample_nonempty_lines(bytes: &[u8]) -> Vec<&[u8]> {
bytes
.split(|b| *b == b'\n')
.map(|line| line.strip_suffix(b"\r").unwrap_or(line))
.filter(|line| line.iter().any(|b| !b.is_ascii_whitespace()))
.take(8)
.collect()
}
fn count_delimiter_outside_quotes(line: &[u8], delimiter: u8) -> usize {
let mut count = 0usize;
let mut in_quotes = false;
let mut i = 0usize;
while let Some(&byte) = line.get(i) {
if byte == b'"' {
if in_quotes && line.get(i + 1) == Some(&b'"') {
i += 2;
continue;
}
in_quotes = !in_quotes;
} else if byte == delimiter && !in_quotes {
count += 1;
}
i += 1;
}
count
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::BufReader;
#[test]
fn sniffs_json_array() {
let mut r = BufReader::new(b"[1, 2, 3]" as &[u8]);
let out = sniff_format(&mut r).unwrap();
assert_eq!(out.format, InputFormat::Json);
assert_eq!(out.bom_bytes, 0);
let buf = r.fill_buf().unwrap();
assert_eq!(buf, b"[1, 2, 3]");
}
#[test]
fn sniffs_json_object() {
let mut r = BufReader::new(b"{\"a\": 1}" as &[u8]);
let out = sniff_format(&mut r).unwrap();
assert_eq!(out.format, InputFormat::Json);
}
#[test]
fn sniffs_jsonl() {
let mut r = BufReader::new(b"{\"a\":1}\n{\"a\":2}\n{\"a\":3}\n" as &[u8]);
let out = sniff_format(&mut r).unwrap();
assert_eq!(out.format, InputFormat::Jsonl);
}
#[test]
fn sniffs_face_envelope() {
let env = br#"{"result":{"input_total":0},"clusters":[]}"#;
let mut r = BufReader::new(&env[..]);
let out = sniff_format(&mut r).unwrap();
assert_eq!(out.format, InputFormat::FaceEnvelope);
}
#[test]
fn strips_utf8_bom() {
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(b"{\"a\": 1}");
let mut r = BufReader::new(&bytes[..]);
let out = sniff_format(&mut r).unwrap();
assert_eq!(out.format, InputFormat::Json);
assert_eq!(out.bom_bytes, 3);
let buf = r.fill_buf().unwrap();
assert_eq!(&buf[..8], b"{\"a\": 1}");
}
#[test]
fn sniffs_csv() {
let mut r = BufReader::new(b"a,b,c\n1,2,3\n4,5,6\n" as &[u8]);
let out = sniff_format(&mut r).unwrap();
assert_eq!(out.format, InputFormat::Csv);
}
#[test]
fn empty_input_errors() {
let mut r = BufReader::new(b"" as &[u8]);
let err = sniff_format(&mut r).unwrap_err();
match err {
FaceError::InputParse { .. } => {}
other => panic!("unexpected: {other:?}"),
}
}
}