face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! §4.1 input format sniffing.
//!
//! [`sniff_format`] peeks the first ~4 KiB of a [`BufRead`] without
//! consuming the content bytes (BOM bytes are consumed). The same
//! buffered reader is then handed to a parser, which sees the
//! still-pristine stream.

use std::io::BufRead;

use crate::{Envelope, FaceError, InputFormat};

/// Maximum prefix size used for sniffing, per §4.1 ("first ~4 KiB").
pub const SNIFF_PROBE_BYTES: usize = 4096;

/// UTF-8 byte-order mark.
const UTF8_BOM: [u8; 3] = [0xEF, 0xBB, 0xBF];

/// Outcome of [`sniff_format`].
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SniffOutcome {
    /// Format chosen for the input.
    pub format: InputFormat,
    /// Number of leading bytes already consumed from the reader.
    /// Currently this is either `0` or `3` — set when a UTF-8 BOM was
    /// stripped. Content bytes are never consumed.
    pub bom_bytes: usize,
}

/// Peek the input and decide its format per §4.1.
///
/// The decision tree:
///
/// 1. Strip a leading UTF-8 BOM (`EF BB BF`) if present, advancing the
///    reader by exactly three bytes.
/// 2. If the prefix has the §9 face-envelope signature
///    (`{"result":...,"clusters":...}` at the structural level),
///    return [`InputFormat::FaceEnvelope`].
/// 3. If the first non-whitespace byte is `{` or `[` and the prefix
///    parses as a single JSON value or an incomplete prefix of one,
///    return [`InputFormat::Json`].
/// 4. If the prefix has multiple top-level JSON values separated by
///    newlines, return [`InputFormat::Jsonl`].
/// 5. Otherwise: try JSON, then JSONL on parse failure (§4.1's
///    ambiguity fallback), then CSV/TSV delimiter detection.
///
/// # Errors
///
/// - I/O failures from the underlying reader.
/// - [`FaceError::InputParse`] when the prefix cannot be classified.
///
/// # Examples
///
/// ```
/// use std::io::BufReader;
/// use face_core::input::sniff::sniff_format;
/// use face_core::InputFormat;
///
/// let mut reader = BufReader::new(b"[1, 2, 3]" as &[u8]);
/// let outcome = sniff_format(&mut reader).unwrap();
/// assert_eq!(outcome.format, InputFormat::Json);
/// assert_eq!(outcome.bom_bytes, 0);
/// ```
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 {
        // Empty / whitespace-only input is unclassifiable. Surface it as
        // a parse error rather than guessing — a downstream parser
        // would emit a less informative "unexpected end" message.
        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(_) => {
            // Per §4.1 ambiguity fallback: try JSON, then JSONL.
            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 })
}

/// Detect JSON vs JSONL when the first non-whitespace byte is `{` or `[`.
///
/// Strategy:
/// - If the probe parses as a single JSON value, return JSON.
/// - Otherwise, if the probe contains multiple newline-separated JSON
///   values, return JSONL.
/// - If the probe is an incomplete prefix of a larger JSON value, return
///   JSON and let the full parser consume stdin beyond the sniff limit.
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 {
                // This path began with `{` or `[`, so it is not a delimited
                // table. Report a clean parse error instead of guessing.
                Err(FaceError::InputParse {
                    format: InputFormat::Json,
                    message: "input begins with `{` or `[` but parses as neither \
                              JSON nor JSONL"
                        .to_string(),
                })
            }
        }
    }
}

/// Strip a UTF-8 BOM if present, advancing the reader past it.
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)
    }
}

/// Capture up to `SNIFF_PROBE_BYTES` of the reader's buffered prefix
/// without consuming any of it.
///
/// Note: a buffered reader may hand back fewer bytes than the probe
/// limit on the first `fill_buf` call (e.g. a small `BufReader` capacity
/// or a slow source). For the sniff probe we accept whatever the first
/// non-empty fill returns — small inputs often live entirely in the
/// first chunk, and partial probes still classify the obvious cases.
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())
}

/// Does the byte slice parse as a single JSON value?
///
/// `from_slice` ignores trailing whitespace but rejects trailing
/// non-whitespace bytes — so a JSONL stream (`{...}\n{...}`) yields
/// an error, which is what we want.
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,
    }
}

/// Does the byte slice look like JSONL — multiple newline-separated
/// JSON values?
///
/// We require **at least two** non-empty lines to parse, so a single
/// JSON line is still classified as JSON above. Parse failures on
/// individual lines are tolerated — the §11.1 skip-and-warn contract
/// applies once parsing begins, and sniff should not refuse a stream
/// just because one or two lines are malformed.
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);
        // The reader still sees the original bytes.
        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);
        // Reader is now positioned past the BOM.
        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:?}"),
        }
    }
}