face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for the `to_flat` / `from_flat` round-trip (§7.1).
//!
//! Hand-curated property tests: for each fixture cluster tree T,
//! `from_flat(&to_flat(&T)).unwrap() == T`. Plus shape-level assertions
//! on the flat form, plus a dangling-parent error case.
//!
//! `Cluster` and `FlatCluster` are `#[non_exhaustive]`, so we build
//! values via JSON deserialization rather than struct literals.

use face_core::{Cluster, ClusterId, ClusterIdSegment, FlatCluster, from_flat, to_flat};

/// Test helper: build a cluster id with one or more `(axis, value)` pairs.
fn cid(pairs: &[(&str, &str)]) -> ClusterId {
    ClusterId::new(
        pairs
            .iter()
            .map(|(a, v)| ClusterIdSegment::new(*a, *v))
            .collect(),
    )
}

/// Build a `Cluster` from JSON. The struct is `#[non_exhaustive]`, so
/// integration-level callers cannot use struct-literal syntax.
fn cluster(value: serde_json::Value) -> Cluster {
    serde_json::from_value(value).expect("Cluster JSON")
}

/// Build a `FlatCluster` from JSON.
fn flat_cluster(value: serde_json::Value) -> FlatCluster {
    serde_json::from_value(value).expect("FlatCluster JSON")
}

/// Build a leaf `Cluster` (no children) — convenience wrapper.
fn leaf(id: &str, axis: &str, value: &str, total: u64) -> Cluster {
    cluster(serde_json::json!({
        "id": id,
        "label": value,
        "axis": axis,
        "value": value,
        "total": total,
        "score_min": null,
        "score_max": null,
        "clusters": []
    }))
}

/// Build a `Cluster` with children — convenience wrapper. `children`
/// must be a JSON array of nested cluster objects, or use the supplied
/// `Cluster` values via `serde_json::to_value`.
fn parent_cluster(
    id: &str,
    axis: &str,
    value: &str,
    total: u64,
    children: Vec<Cluster>,
) -> Cluster {
    let kids = serde_json::to_value(&children).expect("serialize children");
    cluster(serde_json::json!({
        "id": id,
        "label": value,
        "axis": axis,
        "value": value,
        "total": total,
        "score_min": null,
        "score_max": null,
        "clusters": kids
    }))
}

/// Round-trip property: `from_flat(&to_flat(&T))? == T`.
fn assert_round_trip(label: &str, tree: Vec<Cluster>) {
    let flat = to_flat(&tree);
    let back = from_flat(&flat).unwrap_or_else(|e| panic!("[{label}] from_flat failed: {e:?}"));
    assert_eq!(back, tree, "[{label}] round-trip mismatch");
}

mod round_trip {
    use super::*;

    #[test]
    fn empty_tree() {
        assert_round_trip("empty", Vec::<Cluster>::new());
    }

    #[test]
    fn single_leaf_one_axis() {
        let tree = vec![leaf("file:src/cli.rs", "file", "src/cli.rs", 38)];
        assert_round_trip("single-leaf", tree);
    }

    #[test]
    fn two_axis_one_root_three_children() {
        let root = parent_cluster(
            "file:src/cli.rs",
            "file",
            "src/cli.rs",
            38,
            vec![
                leaf("file:src/cli.rs,score:excellent", "score", "excellent", 12),
                leaf("file:src/cli.rs,score:strong", "score", "strong", 18),
                leaf("file:src/cli.rs,score:weak", "score", "weak", 8),
            ],
        );
        assert_round_trip("two-axis", vec![root]);
    }

    #[test]
    fn three_axis_tree() {
        let inner = parent_cluster(
            "repo:oops-rs,file:src/cli.rs",
            "file",
            "src/cli.rs",
            38,
            vec![leaf(
                "repo:oops-rs,file:src/cli.rs,score:excellent",
                "score",
                "excellent",
                12,
            )],
        );
        let root = parent_cluster("repo:oops-rs", "repo", "oops-rs", 38, vec![inner]);
        assert_round_trip("three-axis", vec![root]);
    }

    #[test]
    fn sibling_roots() {
        let r1 = leaf("file:a.rs", "file", "a.rs", 10);
        let r2 = leaf("file:b.rs", "file", "b.rs", 20);
        let r3 = leaf("file:c.rs", "file", "c.rs", 30);
        assert_round_trip("sibling-roots", vec![r1, r2, r3]);
    }
}

mod to_flat_shape {
    use super::*;

    #[test]
    fn root_clusters_have_none_parent() {
        let tree = vec![
            leaf("file:a.rs", "file", "a.rs", 10),
            leaf("file:b.rs", "file", "b.rs", 20),
        ];
        let flat = to_flat(&tree);
        for fc in &flat {
            assert!(
                fc.parent_id.is_none(),
                "root cluster {:?} should have parent_id=None",
                fc.id
            );
        }
    }

    #[test]
    fn child_parent_id_matches_parent() {
        let parent_id = cid(&[("file", "src/cli.rs")]);
        let child_id = cid(&[("file", "src/cli.rs"), ("score", "excellent")]);
        let tree = vec![parent_cluster(
            "file:src/cli.rs",
            "file",
            "src/cli.rs",
            38,
            vec![leaf(
                "file:src/cli.rs,score:excellent",
                "score",
                "excellent",
                12,
            )],
        )];
        let flat = to_flat(&tree);

        let parent_fc = flat
            .iter()
            .find(|fc| fc.id == parent_id)
            .expect("parent present in flat list");
        assert!(parent_fc.parent_id.is_none());

        let child_fc = flat
            .iter()
            .find(|fc| fc.id == child_id)
            .expect("child present in flat list");
        assert_eq!(child_fc.parent_id.as_ref(), Some(&parent_id));
    }

    #[test]
    fn flat_length_equals_total_cluster_count() {
        // Tree with 1 root + 3 children + 1 grandchild = 5 clusters.
        let gc = leaf(
            "repo:oops-rs,file:src/cli.rs,score:excellent",
            "score",
            "excellent",
            12,
        );
        let c1 = parent_cluster(
            "repo:oops-rs,file:src/cli.rs",
            "file",
            "src/cli.rs",
            38,
            vec![gc],
        );
        let c2 = leaf("repo:oops-rs,file:src/core.rs", "file", "src/core.rs", 27);
        let c3 = leaf(
            "repo:oops-rs,file:src/parser.rs",
            "file",
            "src/parser.rs",
            19,
        );
        let root = parent_cluster("repo:oops-rs", "repo", "oops-rs", 84, vec![c1, c2, c3]);
        let flat = to_flat(&[root]);
        assert_eq!(flat.len(), 5, "expected 5 flattened clusters");
    }

    #[test]
    fn flat_field_subset_matches_nested() {
        // Each flat record carries the same scalar fields as its
        // nested counterpart (minus `clusters`).
        let tree = vec![leaf("file:src/cli.rs", "file", "src/cli.rs", 38)];
        let flat = to_flat(&tree);
        assert_eq!(flat.len(), 1);
        let fc = &flat[0];
        assert_eq!(fc.label, "src/cli.rs");
        assert_eq!(fc.axis, "file");
        assert_eq!(fc.value, serde_json::Value::String("src/cli.rs".into()));
        assert_eq!(fc.total, 38);
        assert_eq!(fc.score_min, None);
        assert_eq!(fc.score_max, None);
    }
}

mod from_flat_errors {
    use super::*;

    #[test]
    fn dangling_parent_id_is_err() {
        // A flat list where one entry's `parent_id` points to an id that
        // doesn't appear anywhere in the list. We expect an error;
        // the architect's contract leaves the variant choice to the
        // developer (likely `FaceError::InvalidClusterId`), so we
        // assert `is_err()` rather than pinning the variant.
        let flat = vec![flat_cluster(serde_json::json!({
            "id": "file:missing,score:excellent",
            "parent_id": "file:missing",
            "label": "excellent",
            "axis": "score",
            "value": "excellent",
            "total": 12,
            "score_min": null,
            "score_max": null
        }))];
        let result = from_flat(&flat);
        assert!(
            result.is_err(),
            "from_flat must error on dangling parent_id; got {result:?}"
        );
    }
}