face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for two- and three-axis nesting via
//! `AxisPlan::within` (slice 5, §5.5 of `docs/design.md`).
//!
//! `--within` chains a second grouping axis; the nested form recurses
//! `plan.within` over each parent cluster's records. Sub-cluster ids
//! extend the parent's id with the new `(axis, value)` segment.

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")
}

fn exact(field: &str) -> Axis {
    axis(field, json!({"strategy": "exact"}))
}

fn run(plan: AxisPlan, records: Vec<Record>) -> Vec<Cluster> {
    let mut diag = NullDiagnostics;
    build_tree(&plan, records, &mut diag).expect("nested build_tree should succeed")
}

fn find<'a>(clusters: &'a [Cluster], label: &str) -> &'a Cluster {
    clusters
        .iter()
        .find(|c| c.label == label)
        .unwrap_or_else(|| {
            panic!(
                "cluster with label {label:?} not found among {:?}",
                clusters.iter().map(|c| c.label.clone()).collect::<Vec<_>>(),
            )
        })
}

mod two_axis_via_within {
    //! Plan: outer `kind`, inner `severity`. Verifies child counts,
    //! id extension, axis propagation, and that `total` accumulates
    //! recursively.
    //!
    //! Acceptance: slice 5 brief, §5.5.

    use super::*;

    fn dataset() -> Vec<Record> {
        let items = vec![
            json!({"kind": "bug", "severity": "high"}),
            json!({"kind": "bug", "severity": "high"}),
            json!({"kind": "bug", "severity": "low"}),
            json!({"kind": "feat", "severity": "high"}),
        ];
        Record::from_items(items, None)
    }

    fn plan() -> AxisPlan {
        AxisPlan::with(exact("kind"), AxisPlan::leaf(exact("severity")))
    }

    #[test]
    fn outer_cluster_count() {
        let clusters = run(plan(), dataset());
        // 2 outer clusters, sorted by count desc: bug (3), feat (1).
        assert_eq!(clusters.len(), 2);
        assert_eq!(clusters[0].label, "bug");
        assert_eq!(clusters[0].total, 3);
        assert_eq!(clusters[1].label, "feat");
        assert_eq!(clusters[1].total, 1);
    }

    #[test]
    fn bug_has_two_inner_clusters() {
        let clusters = run(plan(), dataset());
        let bug = find(&clusters, "bug");
        assert_eq!(
            bug.clusters.len(),
            2,
            "bug must have 2 severity sub-clusters"
        );
        // Inside `bug`: high (2), low (1) — count desc, then label asc.
        assert_eq!(bug.clusters[0].label, "high");
        assert_eq!(bug.clusters[0].total, 2);
        assert_eq!(bug.clusters[1].label, "low");
        assert_eq!(bug.clusters[1].total, 1);
    }

    #[test]
    fn feat_has_one_inner_cluster() {
        let clusters = run(plan(), dataset());
        let feat = find(&clusters, "feat");
        assert_eq!(feat.clusters.len(), 1);
        assert_eq!(feat.clusters[0].label, "high");
        assert_eq!(feat.clusters[0].total, 1);
    }

    #[test]
    fn inner_cluster_id_extends_outer() {
        let clusters = run(plan(), dataset());
        let bug = find(&clusters, "bug");
        let bug_high = &bug.clusters[0];
        let expected = ClusterId::parse_canonical("kind:bug,severity:high")
            .expect("canonical two-axis id parses");
        assert_eq!(bug_high.id, expected);
        assert_eq!(bug_high.id.depth(), 2);
    }

    #[test]
    fn inner_cluster_axis_propagates() {
        let clusters = run(plan(), dataset());
        let bug = find(&clusters, "bug");
        for c in &bug.clusters {
            assert_eq!(
                c.axis, "severity",
                "inner cluster's axis is the inner field path",
            );
        }
    }

    #[test]
    fn outer_total_includes_inner() {
        let clusters = run(plan(), dataset());
        let bug = find(&clusters, "bug");
        // 2 (high) + 1 (low) = 3.
        let inner_sum: u64 = bug.clusters.iter().map(|c| c.total).sum();
        assert_eq!(bug.total, inner_sum, "outer total = sum of inner totals");
        assert_eq!(bug.total, 3);
    }
}

mod three_axis_chain {
    //! Plan: A → within(B → within(C)). Verify three-deep nesting.

    use super::*;

    #[test]
    fn three_deep_nesting() {
        let items = vec![
            json!({"a": "x", "b": "p", "c": "1"}),
            json!({"a": "x", "b": "p", "c": "1"}),
            json!({"a": "x", "b": "q", "c": "2"}),
            json!({"a": "y", "b": "p", "c": "1"}),
        ];
        let plan = AxisPlan::with(
            exact("a"),
            AxisPlan::with(exact("b"), AxisPlan::leaf(exact("c"))),
        );
        let clusters = run(plan, Record::from_items(items, None));

        // top_level_count: x (3), y (1).
        assert_eq!(clusters.len(), 2, "two top-level clusters");
        let x = find(&clusters, "x");

        // mid_level_count_under_first_top: under x → p (2), q (1).
        assert_eq!(x.clusters.len(), 2);
        let xp = find(&x.clusters, "p");
        assert_eq!(xp.total, 2);

        // leaf_id_has_three_segments: pick xp's first leaf.
        assert!(!xp.clusters.is_empty(), "xp has at least one leaf");
        let leaf = &xp.clusters[0];
        assert_eq!(
            leaf.id.depth(),
            3,
            "leaf id should have three segments (a, b, c), got {}",
            leaf.id.depth(),
        );
        // Spot-check: the leaf id is `a:x,b:p,c:1`.
        let expected = ClusterId::parse_canonical("a:x,b:p,c:1").expect("three-axis id parses");
        assert_eq!(leaf.id, expected);
    }
}

mod within_with_siblings {
    //! Plan: outer A with TWO sibling within children — B and C, via
    //! `AxisPlan::with_many`.
    //!
    //! The outer cluster's `clusters` array contains BOTH the B-clustered
    //! and C-clustered sub-clusters. The brief allows the concatenation
    //! order to vary (B-then-C or C-then-B); we soft-match by asserting
    //! length and that both axis names appear.

    use super::*;

    #[test]
    fn outer_cluster_has_combined_children() {
        // Outer: kind. Inner siblings: severity (2 distinct: high, low) and
        // status (2 distinct: open, closed).
        let items = vec![
            json!({"kind": "bug", "severity": "high", "status": "open"}),
            json!({"kind": "bug", "severity": "low",  "status": "closed"}),
        ];
        let plan = AxisPlan::with_many(
            exact("kind"),
            vec![
                AxisPlan::leaf(exact("severity")),
                AxisPlan::leaf(exact("status")),
            ],
        );
        let clusters = run(plan, Record::from_items(items, None));

        assert_eq!(clusters.len(), 1, "one outer cluster");
        let bug = &clusters[0];
        assert_eq!(bug.label, "bug");

        // 2 distinct severity values + 2 distinct status values = 4 children.
        assert_eq!(
            bug.clusters.len(),
            4,
            "outer cluster's children should be the concatenation of both sibling axes",
        );
    }

    #[test]
    fn child_axes_distinguishable_by_axis_field() {
        let items = vec![
            json!({"kind": "bug", "severity": "high", "status": "open"}),
            json!({"kind": "bug", "severity": "low",  "status": "closed"}),
        ];
        let plan = AxisPlan::with_many(
            exact("kind"),
            vec![
                AxisPlan::leaf(exact("severity")),
                AxisPlan::leaf(exact("status")),
            ],
        );
        let clusters = run(plan, Record::from_items(items, None));

        let bug = &clusters[0];
        let axes: std::collections::HashSet<&str> =
            bug.clusters.iter().map(|c| c.axis.as_str()).collect();
        assert!(
            axes.contains("severity"),
            "at least one child has axis: severity, got {axes:?}",
        );
        assert!(
            axes.contains("status"),
            "at least one child has axis: status, got {axes:?}",
        );
    }
}