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,
};
const AUTO_DEPTH_CANDIDATES: &[u8] = &[1, 2, 3];
const AUTO_DEPTH_MAX_DOMINANCE: f64 = 0.60;
const SEPARATOR_DETECTION_RATIO: f64 = 0.50;
const DEFAULT_SEPARATOR: &str = "/";
pub(super) fn cluster<D: Diagnostics + ?Sized>(
plan: &AxisPlan,
items: Vec<Record>,
parent: &ClusterId,
diag: &mut D,
) -> Result<Vec<Cluster>, FaceError> {
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 {
continue;
};
let Some(path) = value.as_str() else {
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));
}
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);
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"),
};
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)
}
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)
}
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();
assert_eq!(clusters.len(), 3);
assert_eq!(clusters[0].label, "src/cli");
assert_eq!(clusters[0].total, 2);
assert_eq!(clusters[1].label, "src/core");
assert_eq!(clusters[2].label, "tests/it.rs");
}
#[test]
fn auto_picks_depth_for_well_split_paths() {
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);
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();
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"}), json!({"path": 42}), 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() {
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() {
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() {
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);
}
}