face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for `Strategy::Prefix` via `build_tree` (slice 6,
//! §5.3 of `docs/design.md`).
//!
//! `Prefix` groups path-like string values by directory depth.
//!
//! - `depth: Some(d)` — use exactly `d` segments of the path, split on
//!   `/`. A path with fewer segments groups at its actual depth.
//! - `depth: None` — auto-pick depth using a "biggest cluster ≤ 60% of
//!   records" heuristic; falls back to depth=1 when no depth qualifies.
//! - Label is the joined prefix (`src/cli` for depth=2 of `src/cli.rs/foo`).
//! - Sort: count desc, label asc (same as Exact).
//! - Non-string / missing path values → silent drop, with a `SkipReport`
//!   for present-but-uncoercible values (Exact's pattern).
//!
//! Soft-match: where the spec leaves room (e.g. exact label form when
//! the suffix segment is itself file-like), we assert structural shape
//! only (one `/` separator at depth=2, etc.).

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

/// Single `Prefix` axis on `field` with an optional fixed depth.
fn prefix(field: &str, depth: Option<u8>) -> Axis {
    let mut s = serde_json::Map::new();
    s.insert("strategy".into(), Value::String("prefix".into()));
    if let Some(d) = depth {
        s.insert("depth".into(), Value::Number(serde_json::Number::from(d)));
    }
    axis(field, Value::Object(s))
}

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

/// Drive `build_tree` with a `NullDiagnostics`, panic on error.
fn run(plan: AxisPlan, records: Vec<Record>) -> Vec<Cluster> {
    let mut diag = NullDiagnostics;
    build_tree(&plan, records, &mut diag).expect("Prefix 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)
}

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

mod explicit_depth {
    //! Acceptance: §5.3 — `prefix` with explicit depth uses exactly `d`
    //! segments split on `/`.

    use super::*;

    fn dataset() -> Vec<Record> {
        let items = vec![
            json!({"path": "src/cli.rs"}),
            json!({"path": "src/core/mod.rs"}),
            json!({"path": "docs/design.md"}),
            json!({"path": "docs/plans/x.md"}),
            json!({"path": "src/cli.rs"}),
            json!({"path": "src/parser.rs"}),
        ];
        records_from(items, None)
    }

    #[test]
    fn depth_one_groups_top_dirs() {
        // 4 src/* + 2 docs/* → src (4), docs (2). Count desc → src first.
        let plan = AxisPlan::leaf(prefix("path", Some(1)));
        let clusters = run(plan, dataset());

        assert_eq!(clusters.len(), 2, "expected exactly 2 top-dir clusters");
        assert_eq!(clusters[0].label, "src");
        assert_eq!(clusters[0].total, 4);
        assert_eq!(clusters[1].label, "docs");
        assert_eq!(clusters[1].total, 2);
    }

    #[test]
    fn depth_two_groups_subdirs() {
        // depth=2 over the dataset:
        //   src/cli.rs       (2)
        //   src/core         (1)  ← from "src/core/mod.rs"
        //   src/parser.rs    (1)
        //   docs/design.md   (1)
        //   docs/plans       (1)  ← from "docs/plans/x.md"
        let plan = AxisPlan::leaf(prefix("path", Some(2)));
        let clusters = run(plan, dataset());

        // Soft-match the exact set: at least the labels we care about
        // exist with the right counts. The biggest cluster is the one
        // we pin sharply.
        let src_cli = find(&clusters, "src/cli.rs").expect("src/cli.rs cluster present");
        assert_eq!(
            src_cli.total, 2,
            "src/cli.rs at depth=2 should have 2 records (count was the only repeat)",
        );

        // Total record count across all clusters should equal 6 (no
        // records dropped since every path has ≥ 1 segment).
        let total: u64 = clusters.iter().map(|c| c.total).sum();
        assert_eq!(total, 6, "all 6 records should be clustered at depth=2");
    }

    #[test]
    fn path_shorter_than_depth_groups_whole() {
        // The path "a" has no `/` at all. Asking for depth=2 should
        // group it at its actual depth (one segment) — label "a".
        let items = vec![json!({"path": "a"}), json!({"path": "a"})];
        let plan = AxisPlan::leaf(prefix("path", Some(2)));
        let clusters = run(plan, records_from(items, None));

        assert_eq!(
            clusters.len(),
            1,
            "single-segment path should produce one cluster, got {}: {:?}",
            clusters.len(),
            clusters.iter().map(|c| c.label.clone()).collect::<Vec<_>>(),
        );
        assert_eq!(clusters[0].label, "a");
        assert_eq!(clusters[0].total, 2);
    }

    #[test]
    fn cluster_id_uses_label() {
        // The id for the `src` cluster (depth=1) parses canonically as
        // `path:src`.
        let plan = AxisPlan::leaf(prefix("path", Some(1)));
        let clusters = run(plan, dataset());

        let src = find(&clusters, "src").expect("src cluster present");
        let expected = ClusterId::parse_canonical("path:src").expect("canonical id parses");
        assert_eq!(src.id, expected);
    }

    #[test]
    fn cluster_label_is_joined_prefix() {
        // For "src/cli.rs/foo" with depth=2, the label is the join of
        // the first two segments — exactly one `/` regardless of
        // whether the implementation treats `cli.rs` as a directory or
        // a file (the brief leaves this soft).
        let items = vec![json!({"path": "src/cli.rs/foo"})];
        let plan = AxisPlan::leaf(prefix("path", Some(2)));
        let clusters = run(plan, records_from(items, None));

        assert_eq!(clusters.len(), 1);
        let label = &clusters[0].label;
        let slash_count = label.matches('/').count();
        assert_eq!(
            slash_count, 1,
            "depth=2 label should have exactly one `/`, got {label:?}",
        );
        // Sanity: the prefix is some leading run of the path.
        assert!(
            "src/cli.rs/foo".starts_with(label.as_str()),
            "label {label:?} must be a prefix of the input path",
        );
    }
}

mod auto_depth {
    //! Acceptance: §5.3 — `prefix` with `depth: None` auto-picks the
    //! shallowest depth whose biggest-cluster count ≤ 60% of records;
    //! falls back to depth=1 when none qualifies.

    use super::*;

    #[test]
    fn auto_picks_depth_two_when_one_too_coarse() {
        // 100 records all under crates/. Depth=1 yields a single cluster
        // covering 100% of records — fails the ≤ 60% bar. Depth=2 splits
        // into ~5 sub-dirs at ~20 each — well under 60%.
        let mut items = Vec::with_capacity(100);
        for i in 0..100u32 {
            // 5 evenly-distributed sub-crates: face-core, face-cli,
            // face-detect, face-input, face-render. 20 each.
            let sub = match i % 5 {
                0 => "face-core",
                1 => "face-cli",
                2 => "face-detect",
                3 => "face-input",
                _ => "face-render",
            };
            items.push(json!({"path": format!("crates/{sub}/src/lib.rs")}));
        }
        let plan = AxisPlan::leaf(prefix("path", None));
        let clusters = run(plan, records_from(items, None));

        // Depth=1 would emit a single `crates` cluster carrying all 100.
        // Auto must NOT produce that.
        assert!(
            !(clusters.len() == 1 && clusters[0].label == "crates" && clusters[0].total == 100),
            "auto-depth must avoid the 100%-one-cluster outcome, got {:?}",
            clusters
                .iter()
                .map(|c| (c.label.clone(), c.total))
                .collect::<Vec<_>>(),
        );

        // Positive check: more than one cluster, biggest ≤ 60%.
        assert!(
            clusters.len() > 1,
            "auto-depth must produce more than one cluster on this dataset",
        );
        let biggest = clusters.iter().map(|c| c.total).max().unwrap_or(0);
        assert!(
            biggest as f64 / 100.0 <= 0.6 + f64::EPSILON,
            "auto-depth biggest cluster should cover ≤ 60% of records, was {biggest}/100",
        );
    }

    #[test]
    fn auto_falls_back_to_depth_one_when_no_depth_qualifies() {
        // Pathological: every path is unique at every depth. Each path
        // has 3 segments. At any depth d, there are 5 distinct prefixes
        // — biggest cluster = 1/5 = 20% — which actually qualifies (≤ 60).
        // The point of this test is just to make sure auto-pick does
        // SOMETHING reasonable without erroring on a fully-unique input.
        let items = vec![
            json!({"path": "a/b/c"}),
            json!({"path": "d/e/f"}),
            json!({"path": "g/h/i"}),
            json!({"path": "j/k/l"}),
            json!({"path": "m/n/o"}),
        ];
        let plan = AxisPlan::leaf(prefix("path", None));
        let clusters = run(plan, records_from(items, None));

        // Soft-match: assert no error, at least one cluster produced,
        // and total record count preserved.
        assert!(
            !clusters.is_empty(),
            "auto-pick must produce at least one cluster on a non-empty input",
        );
        let total: u64 = clusters.iter().map(|c| c.total).sum();
        assert_eq!(
            total, 5,
            "auto-pick must preserve record count even on unique-path input",
        );
    }
}

mod scores {
    //! Acceptance: §5.3 — score min/max aggregate across each cluster's
    //! records, mirroring the Exact contract.

    use super::*;

    #[test]
    fn score_min_max_propagates() {
        // Two src/* records (scores 0.4, 0.9) and one docs/* (score 0.6).
        let items = vec![
            json!({"path": "src/a.rs", "score": 0.4}),
            json!({"path": "src/b.rs", "score": 0.9}),
            json!({"path": "docs/x.md", "score": 0.6}),
        ];
        let plan = AxisPlan::leaf(prefix("path", Some(1)));
        let clusters = run(plan, records_from(items, Some(".score")));

        let src = find(&clusters, "src").expect("src cluster present");
        assert_eq!(src.score_min, Some(0.4));
        assert_eq!(src.score_max, Some(0.9));

        let docs = find(&clusters, "docs").expect("docs cluster present");
        assert_eq!(docs.score_min, Some(0.6));
        assert_eq!(docs.score_max, Some(0.6));
    }
}

mod recursion {
    //! Acceptance: §5.5 — `--within` recurses per cluster's record
    //! subset; child cluster ids extend the parent's id.

    use super::*;

    #[test]
    fn prefix_within_exact() {
        // Outer: prefix depth=1 on path. Inner: exact on kind.
        let items = vec![
            json!({"path": "src/a.rs", "kind": "bug"}),
            json!({"path": "src/b.rs", "kind": "bug"}),
            json!({"path": "src/c.rs", "kind": "feat"}),
            json!({"path": "docs/d.md", "kind": "docs"}),
        ];
        let plan = AxisPlan::with(prefix("path", Some(1)), AxisPlan::leaf(exact("kind")));
        let clusters = run(plan, records_from(items, None));

        // Outer: src (3), docs (1).
        assert_eq!(clusters.len(), 2);
        let src = find(&clusters, "src").expect("src cluster present");
        assert_eq!(src.total, 3);

        // Inner under src: bug (2), feat (1) — count desc, label asc.
        assert_eq!(src.clusters.len(), 2, "src has two kind sub-clusters");
        assert_eq!(src.clusters[0].label, "bug");
        assert_eq!(src.clusters[0].total, 2);
        assert_eq!(src.clusters[1].label, "feat");
        assert_eq!(src.clusters[1].total, 1);

        // Inner cluster id extends outer id: `path:src,kind:bug`.
        let bug_id = &src.clusters[0].id;
        let expected = ClusterId::parse_canonical("path:src,kind:bug").expect("two-axis id parses");
        assert_eq!(bug_id, &expected);
        assert_eq!(bug_id.depth(), 2);
    }
}

mod ignored_records {
    //! Per §5.3 categorical handling: records whose `path` resolves to
    //! a non-string value are not represented in any cluster. The
    //! Diagnostics surface mirrors Exact: a present-but-uncoercible
    //! value emits a `SkipReport`; a missing path is a silent drop.

    use super::*;

    #[test]
    fn non_string_path_dropped() {
        // First record is a number at `path` — uncoercible to a string
        // path. The other two are string paths.
        let items = vec![
            json!({"path": 42}),
            json!({"path": "src/a.rs"}),
            json!({"path": "src/b.rs"}),
        ];
        let plan = AxisPlan::leaf(prefix("path", Some(1)));
        // Use VecDiagnostics so we can soft-match: either the skip is
        // recorded (Exact's pattern for present-uncoercible) OR the
        // record is silently dropped. Either is acceptable per the
        // brief; what is NOT acceptable is the bad record showing up
        // as a cluster.
        let mut diag = face_core::VecDiagnostics::default();
        let clusters = build_tree(&plan, records_from(items, None), &mut diag)
            .expect("Prefix build_tree should succeed");

        // No cluster should be produced for the non-string record.
        let total: u64 = clusters.iter().map(|c| c.total).sum();
        assert_eq!(
            total, 2,
            "only the two string-path records should be clustered, got total={total}",
        );

        // Whatever cluster(s) we got, none should carry the numeric value.
        for c in &clusters {
            assert_ne!(
                c.value,
                json!("42"),
                "non-string path must not become a cluster",
            );
            assert_ne!(c.label, "42");
        }
    }
}