face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! §5.3 Prefix clustering: path/namespace grouping by directory depth.
//!
//! - The axis field resolves to a string per-record via
//!   [`crate::path::resolve`]. Missing-path records drop silently
//!   (consistent with Exact); present-but-not-a-string records drop
//!   and emit a [`SkipReport`] via the [`Diagnostics`] sink.
//! - Depth handling for `Strategy::Prefix { depth: Option<u8> }`:
//!   - `Some(d)` — use exactly `d` segments of the path. A path with
//!     fewer than `d` segments is grouped at its actual depth (the
//!     whole string), not an error. `d == 0` is treated as `1`.
//!   - `None` — auto-pick. Heuristic: try depths 1, 2, 3 and pick the
//!     smallest depth where the **biggest cluster covers ≤ 60% of
//!     records** (so we get useful discrimination); if no depth meets
//!     that bar, fall back to depth 1. The 60% threshold is a tunable
//!     starting point — we'll likely refine it once real usage data
//!     comes in.
//! - Splitting: the segment delimiter is auto-detected per-call from
//!   the resolved string values — `/` is the default, but `::` (Rust /
//!   C++ namespaces) and `.` (Java / JS namespaces) are also
//!   recognized via [`crate::detect::strategy::dominant_path_separator`].
//!   The label of each cluster is the joined prefix on the chosen
//!   separator (e.g. `src/cli` for `/` or `Foo::Bar` for `::`).
//! - Sort: count desc, label asc (matches Exact).
//! - Recursion into `plan.within` follows the Exact pattern.

use std::{cmp::Reverse, collections::BTreeMap};

use serde_json::Value;

use crate::{
    Cluster, ClusterId, Diagnostics, FaceError, Record, SkipReason, SkipReport, Strategy,
    cluster_tree::{
        AxisPlan, build_tree_impl, extend_id,
        util::{json_kind, score_range},
    },
    detect::strategy::dominant_path_separator,
};

/// Auto-depth probe range: try 1, 2, 3 in order.
const AUTO_DEPTH_CANDIDATES: &[u8] = &[1, 2, 3];

/// Auto-depth acceptance bar: the biggest cluster at that depth must
/// cover at most this fraction of records for the depth to qualify.
const AUTO_DEPTH_MAX_DOMINANCE: f64 = 0.60;

/// Threshold passed to [`dominant_path_separator`] when auto-detecting
/// the segment delimiter for a Prefix axis. Mirrors the default
/// `path_like_slash_ratio` in [`crate::StrategyDetectionOptions`] so a
/// path-like detection at strategy time also splits cleanly here.
const SEPARATOR_DETECTION_RATIO: f64 = 0.50;

/// Default separator when no recognized delimiter dominates the
/// resolved values. Preserves the legacy "always split on `/`"
/// behavior for plain filesystem paths.
const DEFAULT_SEPARATOR: &str = "/";

/// Cluster `items` by path-prefix at `plan.axis.field`, recursing per
/// cluster into `plan.within`.
pub(super) fn cluster<D: Diagnostics + ?Sized>(
    plan: &AxisPlan,
    items: Vec<Record>,
    parent: &ClusterId,
    diag: &mut D,
) -> Result<Vec<Cluster>, FaceError> {
    // Resolve each record to its path string. Missing paths drop
    // silently; present-but-non-string values drop and emit a skip.
    let field = plan.axis.field.as_str();
    let mut resolved: Vec<(String, Record)> = Vec::with_capacity(items.len());
    for (index, record) in items.into_iter().enumerate() {
        let Ok(value) = crate::path::resolve(&record.raw, field) else {
            // Path absent — silent drop.
            continue;
        };
        let Some(path) = value.as_str() else {
            // Value present but not a string — drop and report with
            // the value's kind.
            diag.record_skip(SkipReport {
                record_index: index,
                reason: SkipReason::WrongType {
                    field: plan.axis.field.clone(),
                    kind: json_kind(value).to_string(),
                },
            });
            continue;
        };
        resolved.push((path.to_string(), record));
    }

    // Auto-detect the segment delimiter from the resolved strings.
    // Falls back to `/` so plain filesystem paths keep working when no
    // separator dominates (e.g. a single record).
    let path_values: Vec<Value> = resolved
        .iter()
        .map(|(p, _)| Value::String(p.clone()))
        .collect();
    let separator = dominant_path_separator(&path_values, SEPARATOR_DETECTION_RATIO)
        .unwrap_or(DEFAULT_SEPARATOR);

    // Pick the depth: explicit when supplied, otherwise auto.
    let depth = match &plan.axis.strategy {
        Strategy::Prefix { depth: Some(d) } => (*d).max(1),
        Strategy::Prefix { depth: None } => auto_pick_depth(&resolved, separator),
        _ => unreachable!("prefix clusterer is only called for Strategy::Prefix"),
    };

    // Group by joined prefix. BTreeMap gives deterministic alphabetical
    // iteration so the count-desc stable sort below preserves label-asc
    // order on ties.
    let mut groups: BTreeMap<String, Vec<Record>> = BTreeMap::new();
    for (path, record) in resolved {
        let label = prefix_label(&path, depth, separator);
        groups.entry(label).or_default().push(record);
    }

    let mut ordered: Vec<(String, Vec<Record>)> = groups.into_iter().collect();
    ordered.sort_by_key(|(_, group)| Reverse(group.len()));

    let mut out = Vec::with_capacity(ordered.len());
    for (label, group) in ordered {
        let id = extend_id(parent, &plan.axis.field, &label);
        let total = group.len() as u64;
        let (score_min, score_max) = score_range(&group);

        let children = if plan.within.is_empty() {
            Vec::new()
        } else {
            let mut acc: Vec<Cluster> = Vec::new();
            for child_plan in &plan.within {
                let child_clusters = build_tree_impl(child_plan, group.clone(), &id, diag)?;
                acc.extend(child_clusters);
            }
            acc
        };

        out.push(Cluster {
            id,
            label: label.clone(),
            axis: plan.axis.field.clone(),
            value: Value::String(label),
            total,
            score_min,
            score_max,
            clusters: children,
        });
    }

    Ok(out)
}

/// Take the first `depth` `sep`-separated segments of `path` and
/// rejoin them. If `path` has fewer than `depth` segments, returns the
/// path unchanged.
fn prefix_label(path: &str, depth: u8, sep: &str) -> String {
    let depth = depth.max(1) as usize;
    let segments: Vec<&str> = path.split(sep).collect();
    if segments.len() <= depth {
        return path.to_string();
    }
    segments[..depth].join(sep)
}

/// Pick a prefix depth that produces useful discrimination on this
/// record set: the smallest depth in `AUTO_DEPTH_CANDIDATES` where the
/// biggest cluster covers at most `AUTO_DEPTH_MAX_DOMINANCE` of records.
/// Falls back to `1` if no candidate qualifies (or the input is empty).
fn auto_pick_depth(resolved: &[(String, Record)], sep: &str) -> u8 {
    if resolved.is_empty() {
        return 1;
    }
    let total = resolved.len() as f64;
    for &candidate in AUTO_DEPTH_CANDIDATES {
        let mut counts: BTreeMap<String, usize> = BTreeMap::new();
        for (path, _) in resolved {
            *counts
                .entry(prefix_label(path, candidate, sep))
                .or_insert(0) += 1;
        }
        let biggest = counts.values().copied().max().unwrap_or(0) as f64;
        if biggest / total <= AUTO_DEPTH_MAX_DOMINANCE {
            return candidate;
        }
    }
    1
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{Axis, NullDiagnostics, Strategy, VecDiagnostics};
    use serde_json::json;

    fn axis(field: &str, depth: Option<u8>) -> Axis {
        Axis {
            field: field.into(),
            strategy: Strategy::Prefix { depth },
            auto: false,
        }
    }

    #[test]
    fn groups_at_explicit_depth() {
        let plan = AxisPlan::leaf(axis("path", Some(2)));
        let items = vec![
            json!({"path": "src/cli/main.rs"}),
            json!({"path": "src/cli/args.rs"}),
            json!({"path": "src/core/lib.rs"}),
            json!({"path": "tests/it.rs"}),
        ];
        let mut diag = NullDiagnostics;
        let clusters = super::cluster(
            &plan,
            Record::from_items(items, None),
            &ClusterId::default(),
            &mut diag,
        )
        .unwrap();
        // src/cli (2), src/core (1), tests/it.rs (1, only 2 segments — whole path).
        assert_eq!(clusters.len(), 3);
        assert_eq!(clusters[0].label, "src/cli");
        assert_eq!(clusters[0].total, 2);
        // ties broken alphabetically.
        assert_eq!(clusters[1].label, "src/core");
        assert_eq!(clusters[2].label, "tests/it.rs");
    }

    #[test]
    fn auto_picks_depth_for_well_split_paths() {
        // Depth 1 → all four under "src" (100% > 60%, rejected).
        // Depth 2 → src/a (2), src/b (1), src/c (1) — biggest is 50% ≤ 60%, accepted.
        let plan = AxisPlan::leaf(axis("path", None));
        let items = vec![
            json!({"path": "src/a/x.rs"}),
            json!({"path": "src/a/y.rs"}),
            json!({"path": "src/b/x.rs"}),
            json!({"path": "src/c/x.rs"}),
        ];
        let mut diag = NullDiagnostics;
        let clusters = super::cluster(
            &plan,
            Record::from_items(items, None),
            &ClusterId::default(),
            &mut diag,
        )
        .unwrap();
        assert_eq!(clusters.len(), 3);
        // The labels confirm depth 2 was chosen.
        assert_eq!(clusters[0].label, "src/a");
        assert_eq!(clusters[0].total, 2);
        assert_eq!(clusters[1].label, "src/b");
        assert_eq!(clusters[2].label, "src/c");
    }

    #[test]
    fn path_shorter_than_depth_groups_whole() {
        let plan = AxisPlan::leaf(axis("path", Some(3)));
        let items = vec![
            json!({"path": "src/cli.rs"}),
            json!({"path": "src/cli.rs"}),
            json!({"path": "lib.rs"}),
        ];
        let mut diag = NullDiagnostics;
        let clusters = super::cluster(
            &plan,
            Record::from_items(items, None),
            &ClusterId::default(),
            &mut diag,
        )
        .unwrap();
        // Both paths have ≤ 3 segments, so each groups at its actual depth.
        assert_eq!(clusters.len(), 2);
        assert_eq!(clusters[0].label, "src/cli.rs");
        assert_eq!(clusters[0].total, 2);
        assert_eq!(clusters[1].label, "lib.rs");
        assert_eq!(clusters[1].total, 1);
    }

    #[test]
    fn missing_path_drops_silently_non_string_emits_skip() {
        let plan = AxisPlan::leaf(axis("path", Some(1)));
        let items = vec![
            json!({"path": "src/a.rs"}),
            json!({"other": "x"}), // missing → silent drop
            json!({"path": 42}),   // present non-string → skip + drop
            json!({"path": "src/b.rs"}),
        ];
        let mut diag = VecDiagnostics::default();
        let clusters = super::cluster(
            &plan,
            Record::from_items(items, None),
            &ClusterId::default(),
            &mut diag,
        )
        .unwrap();
        assert_eq!(clusters.len(), 1);
        assert_eq!(clusters[0].label, "src");
        assert_eq!(clusters[0].total, 2);
        assert_eq!(diag.skips.len(), 1, "only the non-string emits a skip");
        assert_eq!(diag.skips[0].record_index, 2);
    }

    #[test]
    fn double_colon_paths_cluster_at_depth_one() {
        // `Share::A::x` and `Share::B::y` cluster under `Share`.
        let plan = AxisPlan::leaf(axis("locator", Some(1)));
        let items = vec![
            json!({"locator": "Share::A::x"}),
            json!({"locator": "Share::B::y"}),
            json!({"locator": "Lib::C::z"}),
        ];
        let mut diag = NullDiagnostics;
        let clusters = super::cluster(
            &plan,
            Record::from_items(items, None),
            &ClusterId::default(),
            &mut diag,
        )
        .unwrap();
        assert_eq!(clusters.len(), 2);
        assert_eq!(clusters[0].label, "Share");
        assert_eq!(clusters[0].total, 2);
        assert_eq!(clusters[1].label, "Lib");
        assert_eq!(clusters[1].total, 1);
    }

    #[test]
    fn dotted_namespace_clusters_at_explicit_depth_two() {
        // `com.example.A.x` and `com.example.B.y` cluster under
        // `com.example`.
        let plan = AxisPlan::leaf(axis("path", Some(2)));
        let items = vec![
            json!({"path": "com.example.A.x"}),
            json!({"path": "com.example.B.y"}),
            json!({"path": "com.other.C.z"}),
        ];
        let mut diag = NullDiagnostics;
        let clusters = super::cluster(
            &plan,
            Record::from_items(items, None),
            &ClusterId::default(),
            &mut diag,
        )
        .unwrap();
        assert_eq!(clusters.len(), 2);
        assert_eq!(clusters[0].label, "com.example");
        assert_eq!(clusters[0].total, 2);
        assert_eq!(clusters[1].label, "com.other");
        assert_eq!(clusters[1].total, 1);
    }

    #[test]
    fn auto_falls_back_to_depth_one_when_no_split_helps() {
        // A single dominant prefix at every depth — auto picks 1 by fallback.
        let plan = AxisPlan::leaf(axis("path", None));
        let items = vec![
            json!({"path": "src/a/x.rs"}),
            json!({"path": "src/a/x.rs"}),
            json!({"path": "src/a/x.rs"}),
            json!({"path": "src/a/x.rs"}),
        ];
        let mut diag = NullDiagnostics;
        let clusters = super::cluster(
            &plan,
            Record::from_items(items, None),
            &ClusterId::default(),
            &mut diag,
        )
        .unwrap();
        assert_eq!(clusters.len(), 1);
        assert_eq!(clusters[0].label, "src");
        assert_eq!(clusters[0].total, 4);
    }
}