use serde_json::Value;
use crate::{FaceError, Strategy};
const ENUM_MAX_CARDINALITY: usize = 20;
const ENUM_COVERAGE_THRESHOLD: f64 = 0.80;
const PATH_LIKE_SLASH_RATIO: f64 = 0.50;
pub(crate) const PATH_SEPARATORS: &[&str] = &["/", "::", "."];
const ENUM_NUMERIC_MAX_CARDINALITY: usize = 20;
const DEFAULT_TOP_N: u32 = 10;
const DEFAULT_BAND_COUNT: u8 = 5;
const GROUPING_PREFERENCE: &[&str] = &[
"data.path.text",
"path",
"file",
"module",
"kind",
"status",
"type",
];
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct StrategyDetectionOptions {
pub enum_max_cardinality: usize,
pub enum_coverage_threshold: f64,
pub path_like_slash_ratio: f64,
pub group_preference: Vec<String>,
pub default_bands: u8,
}
impl Default for StrategyDetectionOptions {
fn default() -> Self {
Self {
enum_max_cardinality: ENUM_MAX_CARDINALITY,
enum_coverage_threshold: ENUM_COVERAGE_THRESHOLD,
path_like_slash_ratio: PATH_LIKE_SLASH_RATIO,
group_preference: GROUPING_PREFERENCE
.iter()
.map(|field| (*field).to_string())
.collect(),
default_bands: DEFAULT_BAND_COUNT,
}
}
}
pub fn auto_strategy(field: &str, items: &[Value]) -> Result<Strategy, FaceError> {
auto_strategy_with_options(field, items, &StrategyDetectionOptions::default())
}
pub fn auto_strategy_with_options(
field: &str,
items: &[Value],
options: &StrategyDetectionOptions,
) -> Result<Strategy, FaceError> {
let resolved = resolve_field_values(field, items);
if resolved.is_empty() {
return Ok(Strategy::Top { n: DEFAULT_TOP_N });
}
let kind = classify_distribution(&resolved);
let strategy = match kind {
DistributionKind::Numeric => pick_numeric_strategy(&resolved, options),
DistributionKind::String => pick_string_strategy(&resolved, options),
DistributionKind::Bool => Strategy::Exact,
DistributionKind::Mixed => Strategy::Top { n: DEFAULT_TOP_N },
};
Ok(strategy)
}
pub fn pick_grouping_field(items: &[Value]) -> Option<String> {
pick_grouping_field_with_options(items, &StrategyDetectionOptions::default())
}
pub fn pick_grouping_field_with_options(
items: &[Value],
options: &StrategyDetectionOptions,
) -> Option<String> {
if items.is_empty() {
return None;
}
let candidates = collect_string_candidates(items);
let cardinality_ceiling = (items.len() / 3).max(2);
let mut qualified: Vec<(String, usize)> = candidates
.iter()
.filter(|(_, card)| *card >= 2 && *card <= cardinality_ceiling)
.cloned()
.collect();
for (name, card) in &candidates {
if qualified.iter().any(|(n, _)| n == name) {
continue;
}
if is_path_like_field(items, name, options.path_like_slash_ratio) {
qualified.push((name.clone(), *card));
}
}
if qualified.is_empty() {
return None;
}
pick_with_canonical_preference(&qualified, &options.group_preference)
}
fn pick_with_canonical_preference(
candidates: &[(String, usize)],
preference: &[String],
) -> Option<String> {
for canonical in preference {
if let Some((name, _)) = candidates
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(canonical))
{
return Some(name.clone());
}
}
candidates
.iter()
.max_by(|(a_name, a_card), (b_name, b_card)| {
a_card.cmp(b_card).then_with(|| b_name.cmp(a_name))
})
.map(|(name, _)| name.clone())
}
fn is_path_like_field(items: &[Value], field: &str, min_ratio: f64) -> bool {
let resolved = resolve_field_values(field, items);
if resolved.is_empty() {
return false;
}
let Some(sep) = dominant_path_separator(&resolved, min_ratio) else {
return false;
};
shares_two_segment_prefix(&resolved, sep)
}
fn collect_string_candidates(items: &[Value]) -> Vec<(String, usize)> {
use std::collections::BTreeMap;
let mut per_field: BTreeMap<String, FieldStats> = BTreeMap::new();
for item in items {
collect_string_candidate_paths(item, "", &mut per_field);
}
let mut out = Vec::new();
for (name, stats) in per_field {
if stats.disqualified || stats.string_count == 0 {
continue;
}
out.push((name, stats.distinct.len()));
}
out.sort();
out
}
fn collect_string_candidate_paths(
value: &Value,
prefix: &str,
per_field: &mut std::collections::BTreeMap<String, FieldStats>,
) {
match value {
Value::Object(map) => {
for (key, value) in map {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
collect_string_candidate_paths(value, &path, per_field);
}
}
Value::String(s) if !prefix.is_empty() => {
let entry = per_field.entry(prefix.to_string()).or_default();
entry.string_count += 1;
entry.distinct.insert(s.clone());
}
Value::Null => {
}
_ if !prefix.is_empty() => {
per_field
.entry(prefix.to_string())
.or_default()
.disqualified = true;
}
_ => {}
}
}
#[derive(Default)]
struct FieldStats {
string_count: usize,
distinct: std::collections::BTreeSet<String>,
disqualified: bool,
}
fn resolve_field_values(field: &str, items: &[Value]) -> Vec<Value> {
items
.iter()
.filter_map(|item| crate::path::resolve(item, field).ok().cloned())
.collect()
}
enum DistributionKind {
Numeric,
String,
Bool,
Mixed,
}
fn classify_distribution(values: &[Value]) -> DistributionKind {
let mut numeric = 0;
let mut string = 0;
let mut boolean = 0;
let mut other = 0;
for v in values {
match v {
Value::Number(_) => numeric += 1,
Value::String(_) => string += 1,
Value::Bool(_) => boolean += 1,
_ => other += 1,
}
}
let total = values.len();
if numeric == total {
DistributionKind::Numeric
} else if string == total {
DistributionKind::String
} else if boolean == total {
DistributionKind::Bool
} else {
if numeric > string && numeric > boolean && numeric + other >= total / 2 {
DistributionKind::Numeric
} else if string > numeric && string > boolean {
DistributionKind::String
} else {
DistributionKind::Mixed
}
}
}
fn pick_numeric_strategy(values: &[Value], options: &StrategyDetectionOptions) -> Strategy {
let mut all_integer = true;
let mut distinct = std::collections::BTreeSet::new();
for v in values {
let n = match v {
Value::Number(n) => n,
_ => {
all_integer = false;
break;
}
};
let f = match n.as_f64() {
Some(f) => f,
None => {
all_integer = false;
break;
}
};
if !f.is_finite() || f.fract() != 0.0 {
all_integer = false;
break;
}
if let Some(i) = n.as_i64() {
distinct.insert(i.to_string());
} else {
distinct.insert(format!("{f}"));
}
}
if all_integer && distinct.len() <= ENUM_NUMERIC_MAX_CARDINALITY {
return Strategy::Exact;
}
Strategy::Bands {
count: options.default_bands,
}
}
fn pick_string_strategy(values: &[Value], options: &StrategyDetectionOptions) -> Strategy {
use std::collections::BTreeMap;
let mut frequencies: BTreeMap<String, usize> = BTreeMap::new();
for v in values {
if let Value::String(s) = v {
*frequencies.entry(s.clone()).or_insert(0) += 1;
}
}
let total = values.len();
let cardinality = frequencies.len();
if cardinality == 0 {
return Strategy::Top { n: DEFAULT_TOP_N };
}
let sqrt_n = (total as f64).sqrt().ceil() as usize;
let cardinality_ok = (cardinality <= options.enum_max_cardinality || cardinality <= sqrt_n)
&& cardinality < total;
if cardinality_ok {
let mut counts: Vec<usize> = frequencies.values().copied().collect();
counts.sort_by(|a, b| b.cmp(a));
let take = counts.len().min(options.enum_max_cardinality);
let top_sum: usize = counts.iter().take(take).sum();
let coverage = top_sum as f64 / total as f64;
if coverage >= options.enum_coverage_threshold {
return Strategy::Exact;
}
}
if let Some(sep) = dominant_path_separator(values, options.path_like_slash_ratio)
&& shares_two_segment_prefix(values, sep)
{
return Strategy::Prefix { depth: None };
}
Strategy::Top { n: DEFAULT_TOP_N }
}
pub(crate) fn dominant_path_separator(values: &[Value], min_ratio: f64) -> Option<&'static str> {
if values.is_empty() {
return None;
}
let total = values.len() as f64;
let mut best: Option<(&'static str, usize)> = None;
for &sep in PATH_SEPARATORS {
let count = values
.iter()
.filter(|v| matches!(v, Value::String(s) if s.contains(sep)))
.count();
let ratio = count as f64 / total;
if ratio < min_ratio {
continue;
}
match best {
Some((_, current)) if count > current => best = Some((sep, count)),
Some(_) => {}
None => best = Some((sep, count)),
}
}
best.map(|(sep, _)| sep)
}
fn shares_two_segment_prefix(values: &[Value], sep: &str) -> bool {
use std::collections::BTreeMap;
let mut prefix_counts: BTreeMap<String, usize> = BTreeMap::new();
for v in values {
let Value::String(s) = v else {
continue;
};
let mut parts = s.split(sep);
let (Some(a), Some(b)) = (parts.next(), parts.next()) else {
continue;
};
if a.is_empty() && b.is_empty() {
continue;
}
let prefix = format!("{a}{sep}{b}");
*prefix_counts.entry(prefix).or_insert(0) += 1;
}
prefix_counts.values().any(|&c| c >= 2)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn small_cardinality_string_is_exact() {
let items = vec![
json!({"kind": "bug"}),
json!({"kind": "bug"}),
json!({"kind": "feat"}),
json!({"kind": "feat"}),
json!({"kind": "bug"}),
];
assert_eq!(auto_strategy("kind", &items).unwrap(), Strategy::Exact);
}
#[test]
fn path_like_strings_become_prefix() {
let items = vec![
json!({"file": "src/cli/main.rs"}),
json!({"file": "src/cli/lib.rs"}),
json!({"file": "src/core/util.rs"}),
json!({"file": "src/core/api.rs"}),
json!({"file": "src/format/human.rs"}),
json!({"file": "src/format/json.rs"}),
];
assert_eq!(
auto_strategy("file", &items).unwrap(),
Strategy::Prefix { depth: None }
);
}
#[test]
fn free_form_strings_fall_back_to_top() {
let items: Vec<Value> = (0..50)
.map(|i| json!({"msg": format!("totally-unique-message-{i}-with-words")}))
.collect();
match auto_strategy("msg", &items).unwrap() {
Strategy::Top { n } => assert_eq!(n, DEFAULT_TOP_N),
other => panic!("expected Top, got {other:?}"),
}
}
#[test]
fn continuous_numeric_becomes_bands() {
let items: Vec<Value> = (0..50)
.map(|i| json!({"score": (i as f64) * 0.013}))
.collect();
match auto_strategy("score", &items).unwrap() {
Strategy::Bands { count } => assert_eq!(count, DEFAULT_BAND_COUNT),
other => panic!("expected Bands, got {other:?}"),
}
}
#[test]
fn small_integer_numeric_becomes_exact() {
let items: Vec<Value> = [0, 1, 2, 1, 0, 2, 3]
.iter()
.map(|s| json!({"sev": *s}))
.collect();
assert_eq!(auto_strategy("sev", &items).unwrap(), Strategy::Exact);
}
#[test]
fn empty_items_returns_top_fallback() {
assert!(matches!(
auto_strategy("anything", &[]).unwrap(),
Strategy::Top { .. }
));
}
#[test]
fn pick_grouping_field_prefers_canonical_name() {
let items = vec![
json!({"kind": "bug", "id": "x1"}),
json!({"kind": "bug", "id": "x2"}),
json!({"kind": "feat", "id": "x3"}),
];
assert_eq!(pick_grouping_field(&items).as_deref(), Some("kind"));
}
#[test]
fn pick_grouping_field_filters_out_high_cardinality() {
let items: Vec<Value> = (0..30)
.map(|i| json!({"id": format!("x{i}"), "kind": "same"}))
.collect();
assert_eq!(pick_grouping_field(&items), None);
}
#[test]
fn pick_grouping_field_picks_canonical_over_alphabetical() {
let alpha_vals = ["a", "b", "c"];
let kind_vals = ["x", "y", "z"];
let items: Vec<Value> = (0..12)
.map(|i| json!({"alpha": alpha_vals[i % 3], "kind": kind_vals[i % 3]}))
.collect();
assert_eq!(pick_grouping_field(&items).as_deref(), Some("kind"));
}
#[test]
fn pick_grouping_field_falls_back_to_max_cardinality() {
let alpha_vals = ["a", "b", "c", "d"];
let beta_vals = ["x", "y"];
let items: Vec<Value> = (0..12)
.map(|i| json!({"alpha": alpha_vals[i % 4], "beta": beta_vals[i % 2]}))
.collect();
assert_eq!(pick_grouping_field(&items).as_deref(), Some("alpha"));
}
#[test]
fn double_colon_paths_become_prefix() {
let items = vec![
json!({"locator": "Foo::A::x"}),
json!({"locator": "Foo::A::y"}),
json!({"locator": "Foo::B::x"}),
json!({"locator": "Bar::A::x"}),
];
assert_eq!(
auto_strategy("locator", &items).unwrap(),
Strategy::Prefix { depth: None }
);
}
#[test]
fn dotted_namespace_becomes_prefix() {
let items = vec![
json!({"path": "com.example.foo.Bar"}),
json!({"path": "com.example.foo.Baz"}),
json!({"path": "com.example.bar.Qux"}),
json!({"path": "com.example.bar.Quux"}),
];
assert_eq!(
auto_strategy("path", &items).unwrap(),
Strategy::Prefix { depth: None }
);
}
#[test]
fn file_extensions_alone_do_not_trigger_prefix() {
let items: Vec<Value> = (0..30)
.map(|i| json!({"name": format!("Symbol{i}.swift")}))
.collect();
match auto_strategy("name", &items).unwrap() {
Strategy::Top { .. } => {}
other => panic!("expected Top, got {other:?}"),
}
}
#[test]
fn dominant_separator_picks_highest_coverage() {
let values: Vec<Value> = vec![
json!("a/b/c"),
json!("a/b/d"),
json!("x/y/z"),
json!("x/y/w"),
json!("Foo::Bar::baz"),
];
assert_eq!(dominant_path_separator(&values, 0.5), Some("/"));
}
#[test]
fn pick_grouping_field_picks_path_like_at_high_cardinality() {
let mut items: Vec<Value> = Vec::new();
for i in 0..30 {
let module = match i % 3 {
0 => "Share",
1 => "Lib",
_ => "App",
};
items.push(json!({
"locator": format!("{module}::View::sym{i}"),
"kind": "function",
}));
}
assert_eq!(
pick_grouping_field(&items).as_deref(),
Some("locator"),
"high-cardinality path-like field should win over a 1-cardinality enum",
);
}
#[test]
fn pick_grouping_field_discovers_rg_nested_path() {
let items = vec![
json!({"type": "begin", "data": {"path": {"text": "Modules/Share/A.swift"}}}),
json!({"type": "match", "data": {"path": {"text": "Modules/Share/A.swift"}, "lines": {"text": "let viewModel = AViewModel()"}}}),
json!({"type": "end", "data": {"path": {"text": "Modules/Share/A.swift"}}}),
json!({"type": "begin", "data": {"path": {"text": "Modules/Room/B.swift"}}}),
json!({"type": "match", "data": {"path": {"text": "Modules/Room/B.swift"}, "lines": {"text": "let viewModel = BViewModel()"}}}),
json!({"type": "end", "data": {"path": {"text": "Modules/Room/B.swift"}}}),
json!({"type": "summary", "data": {"elapsed_total": {"secs": 0, "nanos": 1}}}),
];
assert_eq!(
pick_grouping_field(&items).as_deref(),
Some("data.path.text"),
"rg JSONL should auto-group by file path, not record `type`",
);
}
#[test]
fn pick_grouping_field_disqualifies_mixed_type_field() {
let items = vec![
json!({"mixed": "string-value"}),
json!({"mixed": 42}),
json!({"mixed": "another"}),
json!({"kind": "ok"}),
json!({"kind": "ok"}),
json!({"kind": "no"}),
];
assert_eq!(pick_grouping_field(&items).as_deref(), Some("kind"));
}
}