face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for input format sniffing (§4.1 of `docs/design.md`).
//!
//! Pins the contract for [`face_core::input::sniff_format`]: BOM
//! handling, single-value JSON vs JSONL discrimination, face-envelope
//! signature recognition, and CSV/TSV delimiter detection.
//!
//! All inputs are inline byte slices wrapped in `std::io::Cursor`,
//! which implements `BufRead`.
//!
//! Public surface under test (locked by the developer brief):
//!
//! ```ignore
//! pub struct SniffOutcome { pub format: InputFormat, pub bom_bytes: usize }
//! pub fn sniff_format(reader: &mut impl BufRead) -> Result<SniffOutcome, FaceError>;
//! ```

use std::io::Cursor;

use face_core::input::sniff::SNIFF_PROBE_BYTES;
use face_core::input::{SniffOutcome, sniff_format};
use face_core::{FaceError, InputFormat};

/// Build a `Cursor` from a byte slice and run [`sniff_format`] over it.
fn sniff(bytes: &[u8]) -> Result<SniffOutcome, FaceError> {
    let mut reader = Cursor::new(bytes);
    sniff_format(&mut reader)
}

mod json {
    //! Single JSON document inputs sniff as [`InputFormat::Json`].

    use super::*;

    #[test]
    fn array_of_numbers() {
        let outcome = sniff(b"[1, 2, 3]").expect("array sniffs as json");
        assert_eq!(outcome.format, InputFormat::Json);
        assert_eq!(outcome.bom_bytes, 0);
    }

    #[test]
    fn single_object() {
        let outcome = sniff(b"{\"a\": 1}").expect("object sniffs as json");
        assert_eq!(outcome.format, InputFormat::Json);
        assert_eq!(outcome.bom_bytes, 0);
    }

    #[test]
    fn nested_object_with_array_field() {
        // A single value with an inner array is JSON, NOT JSONL — the
        // distinction is "multiple top-level values" not "contains an
        // array somewhere" (§4.1).
        let outcome = sniff(b"{\"items\": [1, 2]}").expect("nested-array object sniffs as json");
        assert_eq!(outcome.format, InputFormat::Json);
    }

    #[test]
    fn pretty_printed_with_leading_whitespace() {
        // Sniff skips leading whitespace before deciding on the first
        // non-whitespace byte (§4.1).
        let outcome =
            sniff(b"  \n  [1, 2, 3]\n").expect("leading whitespace then array sniffs as json");
        assert_eq!(outcome.format, InputFormat::Json);
    }

    #[test]
    fn large_pretty_array_past_probe() {
        let bytes = format!(
            r#"[{{"kind":"bug","text":"{}"}},{{"kind":"feat","text":"done"}}]"#,
            "x".repeat(SNIFF_PROBE_BYTES * 2)
        );
        let outcome = sniff(bytes.as_bytes()).expect("large array prefix sniffs as json");
        assert_eq!(
            outcome.format,
            InputFormat::Json,
            "a valid JSON value larger than the sniff probe should be parsed by the full JSON parser",
        );
    }
}

mod jsonl {
    //! Multiple top-level JSON values separated by newlines sniff as
    //! [`InputFormat::Jsonl`].

    use super::*;

    #[test]
    fn simple_two_records() {
        let outcome = sniff(b"{\"a\": 1}\n{\"a\": 2}\n")
            .expect("two newline-separated objects sniff as jsonl");
        assert_eq!(outcome.format, InputFormat::Jsonl);
        assert_eq!(outcome.bom_bytes, 0);
    }

    #[test]
    fn trailing_no_newline() {
        // The final record does not need a trailing newline.
        let outcome = sniff(b"{\"a\": 1}\n{\"a\": 2}")
            .expect("two records without trailing newline sniff as jsonl");
        assert_eq!(outcome.format, InputFormat::Jsonl);
    }

    #[test]
    fn singleline_object_followed_by_array() {
        // Two top-level values of mixed shape — still multiple values,
        // therefore JSONL.
        let outcome = sniff(b"{\"a\": 1}\n[1, 2, 3]").expect("object then array sniffs as jsonl");
        assert_eq!(outcome.format, InputFormat::Jsonl);
    }
}

mod face_envelope {
    //! §9 face-envelope signature: a JSON object whose top-level keys
    //! include `result` and `clusters` is detected as
    //! [`InputFormat::FaceEnvelope`].

    use super::*;

    /// A minimal envelope-shaped JSON, hand-written to avoid coupling to
    /// the full [`face_core::Envelope`] surface.
    const ENVELOPE_RESULT_FIRST: &[u8] = br#"{
        "result": {"input_total": 0, "skipped": 0, "axes": []},
        "clusters": []
    }"#;

    /// Same signature with `clusters` listed before `result`. JSON object
    /// key order is not significant, so this must still sniff as a face
    /// envelope.
    const ENVELOPE_CLUSTERS_FIRST: &[u8] = br#"{
        "clusters": [],
        "result": {"input_total": 0, "skipped": 0, "axes": []}
    }"#;

    /// `result`-only object — falls through to plain `Json` because the
    /// signature requires both keys.
    const RESULT_ONLY: &[u8] = br#"{
        "result": {"input_total": 0}
    }"#;

    #[test]
    fn minimal_envelope_signature() {
        let outcome = sniff(ENVELOPE_RESULT_FIRST).expect("envelope signature sniffs");
        assert_eq!(outcome.format, InputFormat::FaceEnvelope);
        assert_eq!(outcome.bom_bytes, 0);
    }

    #[test]
    fn clusters_first_then_result() {
        // Object key order is not significant.
        let outcome =
            sniff(ENVELOPE_CLUSTERS_FIRST).expect("envelope sniffs regardless of key order");
        assert_eq!(outcome.format, InputFormat::FaceEnvelope);
    }

    #[test]
    fn result_only_no_clusters() {
        // Missing `clusters` → not an envelope, falls back to plain JSON.
        let outcome = sniff(RESULT_ONLY).expect("partial signature sniffs as json");
        assert_eq!(
            outcome.format,
            InputFormat::Json,
            "object with `result` but no `clusters` is plain JSON, not a face envelope",
        );
    }

    /// §9 + §12.1: the sniff prefix is ~4 KiB. An envelope with a
    /// `meta` block that exceeds the prefix must still classify as a
    /// face envelope — the structural sniff sees the top-level
    /// `result` and `clusters` keys before the boundary, regardless of
    /// the envelope's full size.
    #[test]
    fn detects_large_envelope() {
        // Build an envelope-shaped JSON with `meta` padded past 8 KiB.
        // Keys appear in `result`, `meta`, `clusters` order so both
        // target keys are visible inside the first ~4 KiB.
        let padding = "x".repeat(8192);
        let mut bytes = String::new();
        bytes.push_str(r#"{"result":{"input_total":0,"skipped":0,"axes":[]},"#);
        bytes.push_str(r#""clusters":[],"#);
        bytes.push_str(&format!(r#""meta":{{"pad":"{padding}"}}}}"#));
        let outcome = sniff(bytes.as_bytes()).expect("large envelope sniffs");
        assert_eq!(
            outcome.format,
            InputFormat::FaceEnvelope,
            "envelope larger than the sniff prefix must still classify as face-envelope",
        );
    }
}

mod bom {
    //! UTF-8 BOM handling: sniffing reports `bom_bytes` so the parser
    //! can skip the prefix.

    use super::*;

    /// UTF-8 BOM bytes (`EF BB BF`).
    const BOM: &[u8] = b"\xEF\xBB\xBF";

    #[test]
    fn utf8_bom_then_json_array() {
        let mut bytes = Vec::from(BOM);
        bytes.extend_from_slice(b"[1, 2, 3]");
        let outcome = sniff(&bytes).expect("BOM + array sniffs as json");
        assert_eq!(outcome.format, InputFormat::Json);
        assert_eq!(
            outcome.bom_bytes, 3,
            "UTF-8 BOM is 3 bytes; sniff_format must report the consumed prefix length",
        );
    }

    #[test]
    fn utf8_bom_then_jsonl() {
        let mut bytes = Vec::from(BOM);
        bytes.extend_from_slice(b"{\"a\": 1}\n{\"a\": 2}\n");
        let outcome = sniff(&bytes).expect("BOM + jsonl sniffs as jsonl");
        assert_eq!(outcome.format, InputFormat::Jsonl);
        assert_eq!(outcome.bom_bytes, 3);
    }

    #[test]
    fn no_bom_returns_zero() {
        // Guard against a false-positive BOM strip when the input simply
        // starts with `[` or `{`.
        let outcome = sniff(b"[1, 2, 3]").expect("plain array");
        assert_eq!(
            outcome.bom_bytes, 0,
            "no BOM in input → bom_bytes must be 0"
        );
    }
}

mod delimited {
    //! CSV/TSV delimiter sniffing.

    use super::*;

    #[test]
    fn csv_input_sniffs() {
        let outcome = sniff(b"a,b,c\n1,2,3\n4,5,6").expect("CSV sniffs");
        assert_eq!(outcome.format, InputFormat::Csv);
    }

    #[test]
    fn tsv_input_sniffs() {
        let outcome = sniff(b"a\tb\tc\n1\t2\t3\n4\t5\t6").expect("TSV sniffs");
        assert_eq!(outcome.format, InputFormat::Tsv);
    }

    #[test]
    fn pipe_delimited_input_sniffs_as_csv() {
        let outcome = sniff(b"a|b|c\n1|2|3\n4|5|6").expect("pipe-delimited input sniffs");
        assert_eq!(outcome.format, InputFormat::Csv);
    }
}

mod errors {
    use super::*;

    #[test]
    fn unclassifiable_input_errors() {
        let result = sniff(b"not structured enough");
        assert!(
            result.is_err(),
            "unclassifiable input must error, got {result:?}"
        );
    }

    #[test]
    fn empty_input_errors() {
        // Variant choice between Empty / InputParse is the developer's
        // call; we assert only Err.
        let result = sniff(b"");
        assert!(result.is_err(), "empty input must error, got {result:?}");
    }
}