face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for the parse pipeline (§4.1, §4.2 of `docs/design.md`).
//!
//! Pins the contract for [`face_core::input::parse`]: items extraction,
//! meta preservation for object-shaped inputs, CSV/TSV row parsing,
//! JSONL skip-and-warn semantics (§11.1), and CLI-routed
//! face-envelope re-processing (§9).
//!
//! Public surface under test (locked by the developer brief):
//!
//! ```ignore
//! pub struct ParsedInput {
//!     pub items: Vec<serde_json::Value>,
//!     pub meta: Option<serde_json::Map<String, serde_json::Value>>,
//!     pub skips: Vec<SkipReport>,
//! }
//! pub fn parse(reader: &mut impl BufRead, format: InputFormat, items_path: Option<&str>)
//!     -> Result<ParsedInput, FaceError>;
//! pub fn parse_with_columns(..., columns: Option<&[String]>)
//!     -> Result<ParsedInput, FaceError>;
//! ```

use std::io::Cursor;

use face_core::input::{ParsedInput, parse, parse_with_columns};
use face_core::{FaceError, InputFormat, SkipReason};

/// Build a `Cursor` and call [`parse`] with the given format + optional
/// items path.
fn run(
    bytes: &[u8],
    format: InputFormat,
    items_path: Option<&str>,
) -> Result<ParsedInput, FaceError> {
    let mut reader = Cursor::new(bytes);
    parse(&mut reader, format, items_path)
}

fn run_with_columns(
    bytes: &[u8],
    format: InputFormat,
    columns: &[&str],
) -> Result<ParsedInput, FaceError> {
    let mut reader = Cursor::new(bytes);
    let columns = columns
        .iter()
        .map(|column| (*column).to_string())
        .collect::<Vec<_>>();
    parse_with_columns(&mut reader, format, None, Some(&columns))
}

mod json {
    //! JSON inputs — single value, items-array detection, explicit path.

    use super::*;

    #[test]
    fn top_level_array() {
        let parsed =
            run(br#"[{"x":1}, {"x":2}]"#, InputFormat::Json, None).expect("top-level array parses");
        assert_eq!(parsed.items.len(), 2);
        assert_eq!(parsed.items[0], serde_json::json!({"x": 1}));
        assert_eq!(parsed.items[1], serde_json::json!({"x": 2}));
        assert!(
            parsed.meta.is_none(),
            "top-level array carries no meta; got {:?}",
            parsed.meta,
        );
        assert!(parsed.skips.is_empty());
    }

    #[test]
    fn object_with_named_items() {
        let parsed = run(
            br#"{"items": [{"x":1}], "query": "TODO"}"#,
            InputFormat::Json,
            None,
        )
        .expect("object with named `items` parses");
        assert_eq!(parsed.items.len(), 1);
        assert_eq!(parsed.items[0], serde_json::json!({"x": 1}));

        let meta = parsed
            .meta
            .as_ref()
            .expect("scalar fields preserved as meta");
        assert_eq!(meta.get("query"), Some(&serde_json::json!("TODO")));
    }

    #[test]
    fn object_with_one_unnamed_array_field() {
        // §4.2: a single array-valued field is unambiguous even if its
        // name is not in the canonical candidate list.
        let parsed = run(br#"{"foo": [1, 2]}"#, InputFormat::Json, None)
            .expect("single unnamed array field is unambiguous");
        assert_eq!(parsed.items.len(), 2);
        assert_eq!(parsed.items[0], serde_json::json!(1));
        assert_eq!(parsed.items[1], serde_json::json!(2));
        // No scalar siblings → meta is None (no scalar fields preserved).
        assert!(parsed.meta.is_none());
    }

    #[test]
    fn explicit_items_path() {
        let parsed = run(
            br#"{"weird": {"nested": [1, 2, 3]}}"#,
            InputFormat::Json,
            Some(".weird.nested"),
        )
        .expect("explicit items_path resolves");
        assert_eq!(parsed.items.len(), 3);
        assert_eq!(parsed.items[0], serde_json::json!(1));
        assert_eq!(parsed.items[2], serde_json::json!(3));
    }
}

mod jsonl {
    //! JSONL inputs — record-by-record, skip-and-warn (§11.1).

    use super::*;

    #[test]
    fn three_records_all_valid() {
        let parsed = run(
            b"{\"a\":1}\n{\"a\":2}\n{\"a\":3}\n",
            InputFormat::Jsonl,
            None,
        )
        .expect("three valid jsonl records parse");
        assert_eq!(parsed.items.len(), 3);
        assert_eq!(parsed.items[0], serde_json::json!({"a": 1}));
        assert_eq!(parsed.items[2], serde_json::json!({"a": 3}));
        assert!(parsed.skips.is_empty(), "no skips on all-valid input");
    }

    #[test]
    fn skips_malformed_middle_record() {
        // The middle record is invalid JSON; the outer records still
        // make it into items, and one skip is recorded.
        let parsed = run(
            b"{\"a\":1}\n{not json}\n{\"a\":3}\n",
            InputFormat::Jsonl,
            None,
        )
        .expect("malformed middle record is skipped, not fatal");
        assert_eq!(parsed.items.len(), 2);
        assert_eq!(parsed.items[0], serde_json::json!({"a": 1}));
        assert_eq!(parsed.items[1], serde_json::json!({"a": 3}));

        assert_eq!(parsed.skips.len(), 1, "exactly one skip recorded");
        let skip = &parsed.skips[0];
        match &skip.reason {
            SkipReason::InvalidJson { .. } => {}
            other => panic!("expected SkipReason::InvalidJson for malformed JSON, got {other:?}",),
        }
        // Soft-match the index: brief specifies 1-based per §11.1, but
        // accept the developer's chosen indexing convention. Either way
        // the index must point at the second record.
        assert!(
            skip.record_index == 2 || skip.record_index == 1,
            "record_index should reference the second record (1-based: 2, 0-based: 1); got {}",
            skip.record_index,
        );
    }

    #[test]
    fn skips_blank_lines_silently() {
        // Blank lines between records are ignored — they are not skips,
        // they are just whitespace in the wire form.
        let parsed = run(b"{\"a\":1}\n\n{\"a\":2}\n", InputFormat::Jsonl, None)
            .expect("blank lines do not break parse");
        assert_eq!(parsed.items.len(), 2);
        assert_eq!(
            parsed.skips.len(),
            0,
            "blank lines should not generate skip reports; got {:?}",
            parsed.skips,
        );
    }
}

mod delimited {
    //! CSV/TSV inputs become one JSON object per row.

    use super::*;
    use serde_json::json;

    #[test]
    fn csv_with_header_parses_rows() {
        let parsed = run(
            b"kind,score,active\nbug,0.7,true\nfeat,0.2,false\n",
            InputFormat::Csv,
            None,
        )
        .expect("CSV with header parses");

        assert_eq!(parsed.items_path, ".");
        assert_eq!(parsed.items.len(), 2);
        assert_eq!(parsed.items[0]["kind"], json!("bug"));
        assert_eq!(parsed.items[0]["score"], json!(0.7));
        assert_eq!(parsed.items[0]["active"], json!(true));
        assert_eq!(parsed.items[1]["kind"], json!("feat"));
    }

    #[test]
    fn tsv_with_header_parses_rows() {
        let parsed = run(b"kind\tscore\nbug\t1\nfeat\t2\n", InputFormat::Tsv, None)
            .expect("TSV with header parses");
        assert_eq!(parsed.items.len(), 2);
        assert_eq!(parsed.items[0]["kind"], json!("bug"));
        assert_eq!(parsed.items[0]["score"], json!(1));
    }

    #[test]
    fn csv_headerless_uses_generated_columns() {
        let parsed =
            run(b"bug,high\nfeat,low\n", InputFormat::Csv, None).expect("headerless CSV parses");
        assert_eq!(parsed.items[0]["column1"], json!("bug"));
        assert_eq!(parsed.items[0]["column2"], json!("high"));
    }

    #[test]
    fn csv_headerless_uses_supplied_columns() {
        let parsed = run_with_columns(b"bug,high\nfeat,low\n", InputFormat::Csv, &["kind", "sev"])
            .expect("--columns names headerless CSV fields");
        assert_eq!(parsed.items[0]["kind"], json!("bug"));
        assert_eq!(parsed.items[0]["sev"], json!("high"));
    }

    #[test]
    fn semicolon_csv_is_supported() {
        let parsed = run(b"kind;score\nbug;3\nfeat;5\n", InputFormat::Csv, None)
            .expect("semicolon CSV parses");
        assert_eq!(parsed.items[1]["kind"], json!("feat"));
        assert_eq!(parsed.items[1]["score"], json!(5));
    }

    #[test]
    fn latin1_field_fallback() {
        let parsed =
            run(b"name\ncaf\xe9\n", InputFormat::Csv, None).expect("Latin-1 field fallback parses");
        assert_eq!(parsed.items[0]["name"], json!("caf\u{e9}"));
    }
}

mod face_envelope_routing {
    //! §9 envelope re-processing is handled by the CLI before raw
    //! parsing. Feeding a face envelope directly through `parse` is a
    //! routing error with a marker mentioning the re-processing path.

    use super::*;

    #[test]
    fn face_envelope_format_errors() {
        let envelope = br#"{
            "result": {"input_total": 0, "skipped": 0, "axes": []},
            "clusters": [],
            "page": {"page": 1, "per_page": 20, "total_items": 0, "items": [], "cluster_id": null}
        }"#;
        let err = run(envelope, InputFormat::FaceEnvelope, None)
            .expect_err("FaceEnvelope format must be routed before raw parsing");
        match &err {
            FaceError::InputParse { .. } => {}
            other => panic!(
                "expected FaceError::InputParse for raw envelope routing, got {other:?} ({other})",
            ),
        }
        let msg = err.to_string().to_ascii_lowercase();
        assert!(
            msg.contains("envelope") || msg.contains("re-process") || msg.contains("reprocess"),
            "routing error should mention envelope/re-processing; got: {err}",
        );
    }
}

mod items_path {
    //! `ParsedInput::items_path` mirrors the jq path that produced the
    //! items array, so the envelope's `detection.items_path` field can
    //! report the actual path rather than human prose.

    use super::*;

    #[test]
    fn populated_for_named_candidate() {
        let parsed = run(
            br#"{"items": [1, 2, 3], "took_ms": 5}"#,
            InputFormat::Json,
            None,
        )
        .expect("named items candidate parses");
        assert_eq!(
            parsed.items_path, ".items",
            "items_path mirrors the §4.2 candidate that was selected",
        );
    }

    #[test]
    fn populated_for_top_level_array() {
        let parsed = run(b"[1, 2, 3]", InputFormat::Json, None).expect("top-level array parses");
        assert_eq!(
            parsed.items_path, ".",
            "top-level array uses `.` as the items path",
        );
    }

    #[test]
    fn populated_for_jsonl_stream() {
        let parsed =
            run(b"{\"a\":1}\n{\"a\":2}\n", InputFormat::Jsonl, None).expect("jsonl stream parses");
        assert_eq!(
            parsed.items_path, ".",
            "JSONL stream uses `.` as the items path",
        );
    }

    #[test]
    fn echoes_user_supplied_path() {
        let parsed = run(
            br#"{"weird": {"nested": [1, 2, 3]}}"#,
            InputFormat::Json,
            Some(".weird.nested"),
        )
        .expect("explicit items path resolves");
        assert_eq!(
            parsed.items_path, ".weird.nested",
            "user-supplied --items path is echoed in items_path",
        );
    }
}

mod meta_preservation {
    //! §4.2 meta preservation: scalar fields on a JSON-object input are
    //! preserved under `meta`; non-items arrays are dropped.

    use super::*;

    #[test]
    fn scalar_fields_preserved() {
        let parsed = run(
            br#"{"items":[1], "elapsed_ms": 42, "tool": "rg", "version": null}"#,
            InputFormat::Json,
            None,
        )
        .expect("scalar fields preserved");
        assert_eq!(parsed.items, vec![serde_json::json!(1)]);

        let meta = parsed.meta.as_ref().expect("scalar fields populate meta");
        assert_eq!(meta.get("elapsed_ms"), Some(&serde_json::json!(42)));
        assert_eq!(meta.get("tool"), Some(&serde_json::json!("rg")));
        assert_eq!(meta.get("version"), Some(&serde_json::Value::Null));
        assert!(
            !meta.contains_key("items"),
            "the items field itself should not be duplicated into meta",
        );
    }

    #[test]
    fn nested_object_in_meta() {
        let parsed = run(
            br#"{"items":[1], "stats": {"hits": 5}}"#,
            InputFormat::Json,
            None,
        )
        .expect("nested object preserved as meta entry");
        assert_eq!(parsed.items, vec![serde_json::json!(1)]);

        let meta = parsed.meta.as_ref().expect("nested object populates meta");
        assert_eq!(meta.get("stats"), Some(&serde_json::json!({"hits": 5})));
    }

    #[test]
    fn non_items_arrays_dropped() {
        // Per the brief: "Other arrays at the root are NOT preserved".
        let parsed = run(
            br#"{"items":[1], "errors": [{"code": "x"}]}"#,
            InputFormat::Json,
            None,
        )
        .expect("non-items arrays are dropped, not fatal");
        assert_eq!(parsed.items, vec![serde_json::json!(1)]);

        // `meta` may be `None` (no scalar fields) or `Some` without
        // `errors`. Either is consistent with "non-items arrays dropped".
        if let Some(meta) = &parsed.meta {
            assert!(
                !meta.contains_key("errors"),
                "non-items arrays must not appear in meta; got {meta:?}",
            );
        }
    }
}