face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for `--format=json`, `--format=json-flat`,
//! `--format=jsonl-items` (§7, §7.1, §7.2 of `docs/design.md`).
//!
//! Pins:
//! - `Json`: pretty output round-trips back to an equal `Envelope`.
//! - `JsonFlat`: clusters carry `parent_id`; the flat clusters round-trip
//!   through `from_flat` to the original nested tree.
//! - `JsonlItems`: one JSON object per line, ignores clusters.
//! - `OutputFormat` serde wire form is kebab-case across all variants.

use face_core::{Envelope, FlatCluster, OutputFormat, RenderOptions, from_flat, render};
use serde_json::{Value, json};

/// Single-axis fixture (no nesting, has score ranges).
fn fixture_envelope() -> Envelope {
    serde_json::from_value(json!({
        "result": {
            "input_total": 38,
            "skipped": 0,
            "axes": [{ "field": "kind", "strategy": "exact", "auto": false }],
            "detection": {
                "format": "json",
                "items_path": ".",
                "score_path": null,
                "preset": null,
                "fallback_reason": null
            }
        },
        "meta": {},
        "clusters": [
            {
                "id": "kind:bug",
                "label": "bug",
                "axis": "kind",
                "value": "bug",
                "total": 20,
                "score_min": 0.7,
                "score_max": 0.95,
                "clusters": []
            },
            {
                "id": "kind:feat",
                "label": "feat",
                "axis": "kind",
                "value": "feat",
                "total": 18,
                "score_min": 0.5,
                "score_max": 0.85,
                "clusters": []
            }
        ],
        "page": { "cluster_id": null, "page": 1, "per_page": 20, "total_items": 0, "items": [] }
    }))
    .expect("fixture envelope")
}

/// Two-axis fixture: nested clusters under each top-level cluster.
fn fixture_two_axis() -> Envelope {
    serde_json::from_value(json!({
        "result": {
            "input_total": 38,
            "skipped": 0,
            "axes": [
                { "field": "kind", "strategy": "exact", "auto": false },
                { "field": "score", "strategy": "bands", "count": 3, "auto": true }
            ],
            "detection": {
                "format": "json",
                "items_path": ".",
                "score_path": "score",
                "preset": null,
                "fallback_reason": null
            }
        },
        "meta": {},
        "clusters": [
            {
                "id": "kind:bug",
                "label": "bug",
                "axis": "kind",
                "value": "bug",
                "total": 20,
                "score_min": 0.7,
                "score_max": 0.95,
                "clusters": [
                    {
                        "id": "kind:bug,score:excellent",
                        "label": "excellent",
                        "axis": "score",
                        "value": "excellent",
                        "total": 12,
                        "score_min": 0.85,
                        "score_max": 0.95,
                        "clusters": []
                    },
                    {
                        "id": "kind:bug,score:strong",
                        "label": "strong",
                        "axis": "score",
                        "value": "strong",
                        "total": 8,
                        "score_min": 0.7,
                        "score_max": 0.85,
                        "clusters": []
                    }
                ]
            },
            {
                "id": "kind:feat",
                "label": "feat",
                "axis": "kind",
                "value": "feat",
                "total": 18,
                "score_min": 0.5,
                "score_max": 0.85,
                "clusters": []
            }
        ],
        "page": { "cluster_id": null, "page": 1, "per_page": 20, "total_items": 0, "items": [] }
    }))
    .expect("two-axis fixture envelope")
}

/// Fixture with three items in `page.items` for jsonl-items tests.
fn fixture_with_three_items() -> Envelope {
    serde_json::from_value(json!({
        "result": {
            "input_total": 38,
            "skipped": 0,
            "axes": [{ "field": "kind", "strategy": "exact", "auto": false }],
            "detection": {
                "format": "json",
                "items_path": ".",
                "score_path": null,
                "preset": null,
                "fallback_reason": null
            }
        },
        "meta": {},
        "clusters": [
            {
                "id": "kind:bug",
                "label": "kind-cluster-label-must-not-leak",
                "axis": "kind",
                "value": "bug",
                "total": 3,
                "score_min": null,
                "score_max": null,
                "clusters": []
            }
        ],
        "page": {
            "cluster_id": "kind:bug",
            "page": 1,
            "per_page": 20,
            "total_items": 3,
            "items": [
                { "title": "alpha", "id": 1 },
                { "title": "beta",  "id": 2 },
                { "title": "gamma", "id": 3 }
            ]
        }
    }))
    .expect("fixture with three items")
}

fn fixture_empty_page() -> Envelope {
    serde_json::from_value(json!({
        "result": {
            "input_total": 0,
            "skipped": 0,
            "axes": [],
            "detection": {
                "format": "json",
                "items_path": ".",
                "score_path": null,
                "preset": null,
                "fallback_reason": null
            }
        },
        "meta": {},
        "clusters": [],
        "page": { "cluster_id": null, "page": 1, "per_page": 20, "total_items": 0, "items": [] }
    }))
    .expect("empty page fixture")
}

mod nested {
    //! `--format=json` — nested envelope, pretty-printed, round-trips
    //! back to the same `Envelope` value.

    use super::*;

    #[test]
    fn pretty_output_round_trips() {
        let env = fixture_two_axis();
        let s = render(&env, OutputFormat::Json, &RenderOptions::default()).expect("render json");
        let back: Envelope = serde_json::from_str(&s).expect("re-parse json");
        assert_eq!(env, back, "round-trip mismatch via:\n{s}");
    }

    #[test]
    fn output_starts_with_brace() {
        let env = fixture_envelope();
        let s = render(&env, OutputFormat::Json, &RenderOptions::default()).expect("render json");
        // Pretty JSON of an object starts with `{`.
        assert!(
            s.trim_start().starts_with('{'),
            "expected JSON object start; got:\n{s}",
        );
    }

    #[test]
    fn result_axes_serialized() {
        let env = fixture_envelope();
        let s = render(&env, OutputFormat::Json, &RenderOptions::default()).expect("render json");
        // The `axes` field name from `result` should be present.
        assert!(
            s.contains("\"axes\""),
            "expected axes field in serialized output; got:\n{s}",
        );
    }
}

mod flat {
    //! `--format=json-flat` — flat clusters with `parent_id`. Round-trip
    //! goes through `from_flat()` to confirm the wire form is the one
    //! the deserializer consumes.

    use super::*;

    #[test]
    fn flat_clusters_have_parent_id() {
        let env = fixture_two_axis();
        let s = render(&env, OutputFormat::JsonFlat, &RenderOptions::default())
            .expect("render json-flat");
        let v: Value = serde_json::from_str(&s).expect("parse flat output");
        let clusters = v
            .get("clusters")
            .and_then(|c| c.as_array())
            .expect("`clusters` is an array");

        assert!(
            clusters.iter().any(|c| c.get("parent_id").is_some()),
            "no `parent_id` key found in flat clusters; output:\n{s}",
        );
    }

    #[test]
    fn flat_round_trip_via_from_flat() {
        let env = fixture_two_axis();
        let s = render(&env, OutputFormat::JsonFlat, &RenderOptions::default())
            .expect("render json-flat");

        let v: Value = serde_json::from_str(&s).expect("parse flat output");
        let clusters_v = v
            .get("clusters")
            .cloned()
            .expect("`clusters` array present");
        let flat: Vec<FlatCluster> =
            serde_json::from_value(clusters_v).expect("flat clusters deserialize");
        let nested = from_flat(&flat).expect("from_flat reconstructs tree");

        assert_eq!(
            nested, env.clusters,
            "from_flat(JsonFlat output) must equal original nested tree",
        );
    }
}

mod jsonl_items {
    //! `--format=jsonl-items` — one JSON object per line, no envelope,
    //! ignores clusters.

    use super::*;

    #[test]
    fn one_object_per_line() {
        let env = fixture_with_three_items();
        let s = render(&env, OutputFormat::JsonlItems, &RenderOptions::default())
            .expect("render jsonl-items");

        let lines: Vec<&str> = s.split('\n').filter(|l| !l.is_empty()).collect();
        assert_eq!(
            lines.len(),
            3,
            "expected 3 non-empty lines; got {}; output:\n{s}",
            lines.len(),
        );

        for line in lines {
            let _: Value = serde_json::from_str(line)
                .unwrap_or_else(|e| panic!("line not JSON: {line:?}: {e}"));
        }
    }

    #[test]
    fn ignores_clusters() {
        let env = fixture_with_three_items();
        let s = render(&env, OutputFormat::JsonlItems, &RenderOptions::default())
            .expect("render jsonl-items");

        // The fixture's cluster label is a sentinel string that should
        // never appear if jsonl-items emits only `page.items`.
        assert!(
            !s.contains("kind-cluster-label-must-not-leak"),
            "jsonl-items must not emit cluster labels; output:\n{s}",
        );
    }

    #[test]
    fn empty_page_yields_empty_or_just_newline() {
        let env = fixture_empty_page();
        let s = render(&env, OutputFormat::JsonlItems, &RenderOptions::default())
            .expect("render jsonl-items");
        // Soft-match: empty, or a lone newline.
        assert!(
            s.is_empty() || s == "\n",
            "expected empty / single newline for empty items; got {s:?}",
        );
    }
}

mod output_format_serde {
    //! `OutputFormat` wire form: kebab-case round-trips through serde.

    use face_core::OutputFormat;

    #[test]
    fn kebab_case_round_trip() {
        let cases = [
            (OutputFormat::Human, "\"human\""),
            (OutputFormat::Json, "\"json\""),
            (OutputFormat::JsonFlat, "\"json-flat\""),
            (OutputFormat::JsonlItems, "\"jsonl-items\""),
            (OutputFormat::Tsv, "\"tsv\""),
            (OutputFormat::Csv, "\"csv\""),
            (OutputFormat::Markdown, "\"markdown\""),
        ];
        for (fmt, wire) in cases {
            let s =
                serde_json::to_string(&fmt).unwrap_or_else(|e| panic!("serialize {fmt:?}: {e}"));
            assert_eq!(s, wire, "serialize {fmt:?}");
            let back: OutputFormat =
                serde_json::from_str(wire).unwrap_or_else(|e| panic!("deserialize {wire}: {e}"));
            assert_eq!(back, fmt, "deserialize {wire}");
        }
    }
}