face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for `Strategy::Exact` via `build_tree` (slice 5,
//! §5.3 of `docs/design.md`).
//!
//! `Exact` produces one cluster per distinct value at the axis field,
//! sorted count-descending then label-ascending. Each cluster carries:
//! - `id`: parent_id extended with the new `(axis, value)` segment
//! - `label`: the value's string form
//! - `axis`: `plan.axis.field`
//! - `value`: `serde_json::Value::String(label.clone())`
//! - `total`: cluster's record count (recursive across children)
//! - `score_min` / `score_max`: across the cluster's records, `None` if
//!   none have a score
//! - `clusters`: child `Vec<Cluster>` from recursing on `plan.within`,
//!   or empty
//!
//! These tests exercise that contract end-to-end via `Record::from_items`
//! and `build_tree`. Records that can't be coerced to a string key (per
//! §5.3 categorical handling) are silently dropped and surface as
//! `SkipReport`s — verified separately in `record_from_items.rs`.

use face_core::{Axis, AxisPlan, Cluster, ClusterId, NullDiagnostics, Record, build_tree};
use serde_json::{Value, json};

/// Build an `Axis` via deserialization (the type is `#[non_exhaustive]`).
fn axis(field: &str, strategy: Value) -> Axis {
    let mut object = serde_json::Map::new();
    object.insert("field".into(), Value::String(field.into()));
    object.insert("auto".into(), Value::Bool(false));
    if let Value::Object(map) = strategy {
        for (k, v) in map {
            object.insert(k, v);
        }
    } else {
        panic!("strategy must be a JSON object");
    }
    serde_json::from_value(Value::Object(object)).expect("axis must deserialize")
}

/// Build a single `Exact` axis on `field`.
fn exact(field: &str) -> Axis {
    axis(field, json!({"strategy": "exact"}))
}

/// Drive `build_tree` with a `NullDiagnostics`, panic if it errors.
fn run(plan: AxisPlan, records: Vec<Record>) -> Vec<Cluster> {
    let mut diag = NullDiagnostics;
    build_tree(&plan, records, &mut diag).expect("Exact build_tree should succeed")
}

/// Convenience: build records from a list of inline JSON values.
fn records_from(items: Vec<Value>, score_path: Option<&str>) -> Vec<Record> {
    Record::from_items(items, score_path)
}

mod single_axis {
    //! Single-axis `Exact` over `kind`.
    //!
    //! Acceptance: slice 5 brief — `Strategy::Exact` §5.3.

    use super::*;

    #[test]
    fn groups_by_kind() {
        let items = vec![
            json!({"kind": "bug"}),
            json!({"kind": "bug"}),
            json!({"kind": "feat"}),
            json!({"kind": "docs"}),
        ];
        let plan = AxisPlan::leaf(exact("kind"));
        let clusters = run(plan, records_from(items, None));

        assert_eq!(clusters.len(), 3);
        // Sort: count-desc then label-asc → bug (2), docs (1), feat (1).
        assert_eq!(clusters[0].label, "bug");
        assert_eq!(clusters[0].total, 2);
        assert_eq!(clusters[1].label, "docs");
        assert_eq!(clusters[1].total, 1);
        assert_eq!(clusters[2].label, "feat");
        assert_eq!(clusters[2].total, 1);
    }

    #[test]
    fn single_value_single_cluster() {
        let items = vec![
            json!({"kind": "bug"}),
            json!({"kind": "bug"}),
            json!({"kind": "bug"}),
            json!({"kind": "bug"}),
        ];
        let plan = AxisPlan::leaf(exact("kind"));
        let clusters = run(plan, records_from(items, None));

        assert_eq!(clusters.len(), 1);
        assert_eq!(clusters[0].label, "bug");
        assert_eq!(clusters[0].total, 4);
    }

    #[test]
    fn cluster_id_extends_root() {
        let items = vec![json!({"kind": "bug"}), json!({"kind": "bug"})];
        let plan = AxisPlan::leaf(exact("kind"));
        let clusters = run(plan, records_from(items, None));

        let expected_id = ClusterId::parse_canonical("kind:bug").expect("canonical id parse");
        assert_eq!(clusters[0].id, expected_id);
    }

    #[test]
    fn cluster_label_is_value_string() {
        let items = vec![json!({"kind": "bug"})];
        let plan = AxisPlan::leaf(exact("kind"));
        let clusters = run(plan, records_from(items, None));
        assert_eq!(clusters[0].label, "bug");
    }

    #[test]
    fn cluster_axis_field_propagates() {
        // The cluster's `axis` is the field path, not the value.
        let items = vec![json!({"kind": "bug"})];
        let plan = AxisPlan::leaf(exact("kind"));
        let clusters = run(plan, records_from(items, None));
        assert_eq!(clusters[0].axis, "kind");
    }

    #[test]
    fn cluster_value_is_json_string() {
        let items = vec![json!({"kind": "bug"})];
        let plan = AxisPlan::leaf(exact("kind"));
        let clusters = run(plan, records_from(items, None));
        assert_eq!(clusters[0].value, json!("bug"));
    }
}

mod scores {
    //! `score_min` / `score_max` across a cluster's records.
    //!
    //! Acceptance: slice 5 brief — score min/max per cluster.

    use super::*;

    #[test]
    fn score_min_max_across_records() {
        let items = vec![
            json!({"kind": "bug", "score": 0.4}),
            json!({"kind": "bug", "score": 0.9}),
            json!({"kind": "bug", "score": 0.6}),
        ];
        let plan = AxisPlan::leaf(exact("kind"));
        let clusters = run(plan, records_from(items, Some(".score")));

        assert_eq!(clusters.len(), 1);
        assert_eq!(clusters[0].score_min, Some(0.4));
        assert_eq!(clusters[0].score_max, Some(0.9));
    }

    #[test]
    fn score_none_when_no_records_have_scores() {
        let items = vec![json!({"kind": "bug"}), json!({"kind": "bug"})];
        let plan = AxisPlan::leaf(exact("kind"));
        // No score_path → no record has a score.
        let clusters = run(plan, records_from(items, None));

        assert_eq!(clusters.len(), 1);
        assert_eq!(clusters[0].score_min, None);
        assert_eq!(clusters[0].score_max, None);
    }

    #[test]
    fn score_min_max_skip_record_with_no_score() {
        // Mixed presence: only the first and third records have numeric
        // scores; the middle one is non-numeric and yields score: None
        // per `Record::from_items`.
        let items = vec![
            json!({"kind": "bug", "score": 0.4}),
            json!({"kind": "bug", "score": "n/a"}),
            json!({"kind": "bug", "score": 0.9}),
        ];
        let plan = AxisPlan::leaf(exact("kind"));
        let clusters = run(plan, records_from(items, Some(".score")));

        assert_eq!(clusters.len(), 1);
        assert_eq!(clusters[0].score_min, Some(0.4));
        assert_eq!(clusters[0].score_max, Some(0.9));
        // Total still counts every record in the cluster — the record
        // with no score is in the cluster, just doesn't contribute to
        // min/max.
        assert_eq!(clusters[0].total, 3);
    }
}

mod ordering {
    //! Sort: count-descending, then label-ascending.
    //!
    //! Acceptance: slice 5 brief — deterministic ordering.

    use super::*;

    #[test]
    fn count_desc_then_label_asc() {
        let items = vec![
            json!({"kind": "a"}),
            json!({"kind": "a"}),
            json!({"kind": "a"}),
            json!({"kind": "b"}),
            json!({"kind": "b"}),
            json!({"kind": "c"}),
            json!({"kind": "d"}),
        ];
        let plan = AxisPlan::leaf(exact("kind"));
        let clusters = run(plan, records_from(items, None));

        let labels: Vec<&str> = clusters.iter().map(|c| c.label.as_str()).collect();
        let totals: Vec<u64> = clusters.iter().map(|c| c.total).collect();
        assert_eq!(labels, vec!["a", "b", "c", "d"]);
        assert_eq!(totals, vec![3, 2, 1, 1]);
    }

    #[test]
    fn tied_counts_alphabetic() {
        let items = vec![
            json!({"kind": "zebra"}),
            json!({"kind": "alpha"}),
            json!({"kind": "middle"}),
        ];
        let plan = AxisPlan::leaf(exact("kind"));
        let clusters = run(plan, records_from(items, None));

        let labels: Vec<&str> = clusters.iter().map(|c| c.label.as_str()).collect();
        assert_eq!(labels, vec!["alpha", "middle", "zebra"]);
    }
}

mod recursion_root_only {
    //! When `plan.within` is empty, every produced cluster has
    //! `clusters: []`. Acceptance: slice 5 brief — root-only recursion.

    use super::*;

    #[test]
    fn clusters_array_is_empty() {
        let items = vec![
            json!({"kind": "bug"}),
            json!({"kind": "feat"}),
            json!({"kind": "feat"}),
        ];
        let plan = AxisPlan::leaf(exact("kind"));
        let clusters = run(plan, records_from(items, None));

        assert!(!clusters.is_empty(), "must produce at least one cluster");
        for c in &clusters {
            assert!(
                c.clusters.is_empty(),
                "leaf clusters must have empty `clusters` array, got {} children",
                c.clusters.len(),
            );
        }
    }
}

mod ignored_records {
    //! Per §5.3: integers coerce to their decimal form; bools and null
    //! are kept as `"true"`/`"false"`/`"null"`; arrays, objects, and
    //! fractional numbers are uncoercible → dropped. Records missing
    //! the axis path are also dropped.

    use super::*;

    fn cluster_with_label<'a>(clusters: &'a [Cluster], label: &str) -> Option<&'a Cluster> {
        clusters.iter().find(|c| c.label == label)
    }

    #[test]
    fn non_string_keys_dropped() {
        // Mix of representable and uncoercible values:
        //  - "bug" (string)              → kept
        //  - 5 (integer)                 → kept as "5"
        //  - 5.5 (fractional float)      → dropped
        //  - [1, 2] (array)              → dropped
        //  - {} (object)                 → dropped
        //  - null                        → kept as "null"
        //  - true                        → kept as "true"
        let items = vec![
            json!({"kind": "bug"}),
            json!({"kind": 5}),
            json!({"kind": 5.5}),
            json!({"kind": [1, 2]}),
            json!({"kind": {}}),
            json!({"kind": Value::Null}),
            json!({"kind": true}),
        ];
        let plan = AxisPlan::leaf(exact("kind"));
        let clusters = run(plan, records_from(items, None));

        // Four kept records → four single-record clusters.
        let labels: std::collections::HashSet<&str> =
            clusters.iter().map(|c| c.label.as_str()).collect();

        assert!(
            labels.contains("bug"),
            "string `bug` must be kept; got {labels:?}"
        );
        assert!(
            labels.contains("5"),
            "integer `5` must coerce to \"5\"; got {labels:?}"
        );
        assert!(
            labels.contains("null"),
            "null must coerce to \"null\"; got {labels:?}"
        );
        assert!(
            labels.contains("true"),
            "true must coerce to \"true\"; got {labels:?}"
        );
        assert!(
            !labels.iter().any(|l| l.contains("5.5")),
            "fractional 5.5 must be dropped; got {labels:?}",
        );

        // None of the four kept clusters should be ≥ 2 records.
        for label in ["bug", "5", "null", "true"] {
            let c = cluster_with_label(&clusters, label).expect("kept cluster present");
            assert_eq!(c.total, 1, "kept cluster `{label}` should have one record");
        }

        // Total kept clusters: exactly four — no extras from arrays/objects/fractionals.
        assert_eq!(
            clusters.len(),
            4,
            "expected exactly 4 kept clusters (bug, 5, null, true), got {}: {labels:?}",
            clusters.len(),
        );
    }

    #[test]
    fn missing_path_dropped() {
        // First item lacks `kind` and must be dropped from clustering.
        let items = vec![
            json!({"name": "x"}),
            json!({"kind": "feat"}),
            json!({"kind": "feat"}),
        ];
        let plan = AxisPlan::leaf(exact("kind"));
        let clusters = run(plan, records_from(items, None));

        assert_eq!(clusters.len(), 1, "only `feat` should survive");
        assert_eq!(clusters[0].label, "feat");
        assert_eq!(clusters[0].total, 2);
    }
}