Skip to main content

face_core/detect/
strategy.rs

1//! §4.5 auto-strategy selection and §4.5 fallback grouping-field pick.
2//!
3//! [`auto_strategy`] inspects an axis field's value distribution across
4//! the items list and picks one of the §5.2 / §5.3 strategies:
5//!
6//! - [`Strategy::Exact`] for enum-detected fields (cardinality ≤ 20 OR
7//!   ≤ √N, top values cover ≥ 80%) and small-cardinality integer enums.
8//! - [`Strategy::Prefix`] for path-like strings (≥ 50% contain a path
9//!   separator (`/`, `::`, or `.`) AND share at least one 2-segment
10//!   prefix on that separator).
11//! - [`Strategy::Top`] for free-form strings as the catchall.
12//! - [`Strategy::Bands`] for continuous numeric fields.
13//!
14//! [`pick_grouping_field`] is used when the user supplied no `--by` and
15//! no score was detected: scan all top-level string fields for a
16//! "discriminating" choice, with a small canonical preference list
17//! (`data.path.text`, `path`, `file`, `module`, `kind`, `status`, `type`).
18
19use serde_json::Value;
20
21use crate::{FaceError, Strategy};
22
23/// Auto-strategy: enum cardinality ceiling per §4.5.
24const ENUM_MAX_CARDINALITY: usize = 20;
25
26/// Auto-strategy: top-values coverage threshold for the enum heuristic.
27const ENUM_COVERAGE_THRESHOLD: f64 = 0.80;
28
29/// Auto-strategy: path-like detection threshold (≥ 50% contain a path
30/// separator (`/`, `::`, or `.`)).
31const PATH_LIKE_SLASH_RATIO: f64 = 0.50;
32
33/// Recognized path separators, in tie-break priority order.
34///
35/// When multiple separators tie at the same value-coverage ratio, the
36/// earlier entry wins: `/` beats `::` beats `.`. Filesystem paths are
37/// most common, namespace-style `::` is less ambiguous than `.` (which
38/// can also appear in file extensions and decimal numbers), so `.` is
39/// the lowest-priority candidate.
40pub(crate) const PATH_SEPARATORS: &[&str] = &["/", "::", "."];
41
42/// Auto-strategy: enum-numeric ceiling (small integer enums treated as
43/// categorical per §5.1).
44const ENUM_NUMERIC_MAX_CARDINALITY: usize = 20;
45
46/// Auto-strategy: default `Top { n }` value when picking the catchall.
47const DEFAULT_TOP_N: u32 = 10;
48
49/// Auto-strategy: default band count for continuous numeric fields.
50const DEFAULT_BAND_COUNT: u8 = 5;
51
52/// Canonical names preferred when picking a grouping field with no
53/// explicit `--by`. Earlier entries win; matched case-insensitively.
54const GROUPING_PREFERENCE: &[&str] = &[
55    "data.path.text",
56    "path",
57    "file",
58    "module",
59    "kind",
60    "status",
61    "type",
62];
63
64/// Tunable inputs for §4.5 strategy and grouping-field detection.
65#[derive(Debug, Clone, PartialEq)]
66#[non_exhaustive]
67pub struct StrategyDetectionOptions {
68    /// Maximum distinct values that may still be treated as enum-like.
69    pub enum_max_cardinality: usize,
70    /// Top-value coverage threshold for enum-like string detection.
71    pub enum_coverage_threshold: f64,
72    /// Minimum ratio of values containing a path separator (`/`, `::`,
73    /// or `.`) before a string field is considered path-like. The name
74    /// is preserved for backwards compatibility.
75    pub path_like_slash_ratio: f64,
76    /// Canonical field names preferred when auto-picking a grouping field.
77    pub group_preference: Vec<String>,
78    /// Default band count for continuous numeric fields.
79    pub default_bands: u8,
80}
81
82impl Default for StrategyDetectionOptions {
83    fn default() -> Self {
84        Self {
85            enum_max_cardinality: ENUM_MAX_CARDINALITY,
86            enum_coverage_threshold: ENUM_COVERAGE_THRESHOLD,
87            path_like_slash_ratio: PATH_LIKE_SLASH_RATIO,
88            group_preference: GROUPING_PREFERENCE
89                .iter()
90                .map(|field| (*field).to_string())
91                .collect(),
92            default_bands: DEFAULT_BAND_COUNT,
93        }
94    }
95}
96
97/// Auto-pick a strategy for a given axis field, given the items list.
98///
99/// Implements §4.5 of `docs/design.md`. The function inspects the
100/// distribution of values at `field` across `items` and routes:
101///
102/// - `Strategy::Exact` for enum-detected fields (cardinality ≤ 20 OR
103///   ≤ √N, top values cover ≥ 80%) and small-cardinality integer enums.
104/// - `Strategy::Prefix { depth: None }` for path-like strings (≥ 50%
105///   contain a recognized path separator (`/`, `::`, or `.`) AND share
106///   at least one 2-segment prefix on that separator).
107/// - `Strategy::Top { n: 10 }` for free-form strings as the catchall.
108/// - `Strategy::Bands { count: 5 }` for continuous numeric.
109///
110/// The caller wraps the returned [`Strategy`] in an [`crate::Axis`] and
111/// sets `auto: true`.
112///
113/// # Errors
114///
115/// Currently never returns an error — auto-strategy always falls back
116/// to `Top { n: 10 }` for ambiguous string distributions. The `Result`
117/// shape is reserved for future structural validation (e.g. surfacing a
118/// path-resolution failure on a non-existent field).
119///
120/// # Examples
121///
122/// ```
123/// use face_core::detect::auto_strategy;
124/// use face_core::Strategy;
125/// use serde_json::json;
126///
127/// let items = vec![
128///     json!({"kind": "bug"}),
129///     json!({"kind": "bug"}),
130///     json!({"kind": "feat"}),
131/// ];
132/// assert_eq!(auto_strategy("kind", &items).unwrap(), Strategy::Exact);
133/// ```
134pub fn auto_strategy(field: &str, items: &[Value]) -> Result<Strategy, FaceError> {
135    auto_strategy_with_options(field, items, &StrategyDetectionOptions::default())
136}
137
138/// Auto-pick a strategy with caller-supplied heuristic thresholds.
139pub fn auto_strategy_with_options(
140    field: &str,
141    items: &[Value],
142    options: &StrategyDetectionOptions,
143) -> Result<Strategy, FaceError> {
144    let resolved = resolve_field_values(field, items);
145
146    if resolved.is_empty() {
147        // No values resolved — fall back to the catchall. Strategies
148        // downstream will produce empty clusters; that's the right
149        // signal for "field doesn't exist anywhere".
150        return Ok(Strategy::Top { n: DEFAULT_TOP_N });
151    }
152
153    let kind = classify_distribution(&resolved);
154    let strategy = match kind {
155        DistributionKind::Numeric => pick_numeric_strategy(&resolved, options),
156        DistributionKind::String => pick_string_strategy(&resolved, options),
157        DistributionKind::Bool => Strategy::Exact,
158        DistributionKind::Mixed => Strategy::Top { n: DEFAULT_TOP_N },
159    };
160    Ok(strategy)
161}
162
163/// Pick the most-discriminating string field when no explicit `--by`
164/// was given AND no score was detected (§4.5 fallback).
165///
166/// Heuristic: collect every string leaf field, then build a
167/// candidate pool from two qualifying paths. (1) the cardinality
168/// window `[2, records/3]`, the legacy enum-like check; and (2) the
169/// path-like fallback — high-cardinality fields whose values share a
170/// recognized separator (`/`, `::`, or `.`) and at least one 2-segment
171/// prefix on that separator. A field passing either qualifies. Across
172/// the combined pool, canonical names (`path`, `file`, `module`,
173/// `kind`, `status`, `type`) win in declared order; with no canonical
174/// match, the highest-cardinality candidate wins (alphabetical
175/// tiebreak). Returns `None` if neither path qualifies any field — the
176/// caller errors with [`FaceError::AmbiguousDetection`].
177///
178/// # Examples
179///
180/// ```
181/// use face_core::detect::pick_grouping_field;
182/// use serde_json::json;
183///
184/// let items = vec![
185///     json!({"file": "a.rs", "kind": "bug"}),
186///     json!({"file": "b.rs", "kind": "bug"}),
187///     json!({"file": "a.rs", "kind": "feat"}),
188/// ];
189/// assert_eq!(pick_grouping_field(&items).as_deref(), Some("file"));
190/// ```
191pub fn pick_grouping_field(items: &[Value]) -> Option<String> {
192    pick_grouping_field_with_options(items, &StrategyDetectionOptions::default())
193}
194
195/// Pick the fallback grouping field with caller-supplied preferences.
196///
197/// See [`pick_grouping_field`] for the heuristic; this sibling only
198/// exposes the tunable `options` knobs (cardinality window, path-like
199/// ratio, canonical preference list).
200pub fn pick_grouping_field_with_options(
201    items: &[Value],
202    options: &StrategyDetectionOptions,
203) -> Option<String> {
204    if items.is_empty() {
205        return None;
206    }
207
208    let candidates = collect_string_candidates(items);
209    let cardinality_ceiling = (items.len() / 3).max(2);
210
211    // Two qualifying paths into the candidate pool:
212    //   1. Cardinality window [2, records/3] — the legacy enum-like
213    //      check.
214    //   2. Path-like — high-cardinality strings whose prefix split
215    //      collapses cardinality back into a useful range.
216    // A field passing either qualifies. Canonical-name preference and
217    // the highest-cardinality tiebreak are then applied across the
218    // combined pool, so a canonical-named cardinality-window field
219    // (e.g. `kind`) still wins over a non-canonical path-like field
220    // (e.g. `locator`) when both are present.
221    let mut qualified: Vec<(String, usize)> = candidates
222        .iter()
223        .filter(|(_, card)| *card >= 2 && *card <= cardinality_ceiling)
224        .cloned()
225        .collect();
226
227    for (name, card) in &candidates {
228        if qualified.iter().any(|(n, _)| n == name) {
229            continue;
230        }
231        if is_path_like_field(items, name, options.path_like_slash_ratio) {
232            qualified.push((name.clone(), *card));
233        }
234    }
235
236    if qualified.is_empty() {
237        return None;
238    }
239
240    pick_with_canonical_preference(&qualified, &options.group_preference)
241}
242
243/// Apply the canonical-preference + max-cardinality tiebreak rule to a
244/// pre-filtered candidate list. Returns the chosen field name.
245fn pick_with_canonical_preference(
246    candidates: &[(String, usize)],
247    preference: &[String],
248) -> Option<String> {
249    // Prefer canonical names in declared order.
250    for canonical in preference {
251        if let Some((name, _)) = candidates
252            .iter()
253            .find(|(name, _)| name.eq_ignore_ascii_case(canonical))
254        {
255            return Some(name.clone());
256        }
257    }
258
259    // No canonical match — pick the highest-cardinality candidate.
260    // Ties broken by field name (alphabetical) for determinism.
261    candidates
262        .iter()
263        .max_by(|(a_name, a_card), (b_name, b_card)| {
264            a_card.cmp(b_card).then_with(|| b_name.cmp(a_name))
265        })
266        .map(|(name, _)| name.clone())
267}
268
269/// Returns `true` when `field` resolves to enough path-like string
270/// values to qualify for [`Strategy::Prefix`] under the same threshold
271/// the auto-strategy uses.
272fn is_path_like_field(items: &[Value], field: &str, min_ratio: f64) -> bool {
273    let resolved = resolve_field_values(field, items);
274    if resolved.is_empty() {
275        return false;
276    }
277    let Some(sep) = dominant_path_separator(&resolved, min_ratio) else {
278        return false;
279    };
280    shares_two_segment_prefix(&resolved, sep)
281}
282
283/// Collect every string-valued leaf field path with its cardinality.
284///
285/// A field is considered a candidate only when **every** observed value
286/// at that key is either a string or absent (a single integer at that
287/// key disqualifies it — we want pure strings to keep the pick clean).
288fn collect_string_candidates(items: &[Value]) -> Vec<(String, usize)> {
289    use std::collections::BTreeMap;
290
291    // BTreeMap so the iteration order is deterministic.
292    let mut per_field: BTreeMap<String, FieldStats> = BTreeMap::new();
293
294    for item in items {
295        collect_string_candidate_paths(item, "", &mut per_field);
296    }
297
298    let mut out = Vec::new();
299    for (name, stats) in per_field {
300        if stats.disqualified || stats.string_count == 0 {
301            continue;
302        }
303        out.push((name, stats.distinct.len()));
304    }
305    out.sort();
306    out
307}
308
309fn collect_string_candidate_paths(
310    value: &Value,
311    prefix: &str,
312    per_field: &mut std::collections::BTreeMap<String, FieldStats>,
313) {
314    match value {
315        Value::Object(map) => {
316            for (key, value) in map {
317                let path = if prefix.is_empty() {
318                    key.clone()
319                } else {
320                    format!("{prefix}.{key}")
321                };
322                collect_string_candidate_paths(value, &path, per_field);
323            }
324        }
325        Value::String(s) if !prefix.is_empty() => {
326            let entry = per_field.entry(prefix.to_string()).or_default();
327            entry.string_count += 1;
328            entry.distinct.insert(s.clone());
329        }
330        Value::Null => {
331            // Absent / null — do not disqualify; just ignore.
332        }
333        _ if !prefix.is_empty() => {
334            per_field
335                .entry(prefix.to_string())
336                .or_default()
337                .disqualified = true;
338        }
339        _ => {}
340    }
341}
342
343#[derive(Default)]
344struct FieldStats {
345    string_count: usize,
346    distinct: std::collections::BTreeSet<String>,
347    disqualified: bool,
348}
349
350/// Resolved values at the requested path, in input order. Records
351/// whose path doesn't resolve are omitted.
352fn resolve_field_values(field: &str, items: &[Value]) -> Vec<Value> {
353    items
354        .iter()
355        .filter_map(|item| crate::path::resolve(item, field).ok().cloned())
356        .collect()
357}
358
359/// Top-level distribution classification.
360enum DistributionKind {
361    Numeric,
362    String,
363    Bool,
364    Mixed,
365}
366
367fn classify_distribution(values: &[Value]) -> DistributionKind {
368    let mut numeric = 0;
369    let mut string = 0;
370    let mut boolean = 0;
371    let mut other = 0;
372    for v in values {
373        match v {
374            Value::Number(_) => numeric += 1,
375            Value::String(_) => string += 1,
376            Value::Bool(_) => boolean += 1,
377            _ => other += 1,
378        }
379    }
380    let total = values.len();
381    if numeric == total {
382        DistributionKind::Numeric
383    } else if string == total {
384        DistributionKind::String
385    } else if boolean == total {
386        DistributionKind::Bool
387    } else {
388        // Allow a small minority of nulls / misses but still classify
389        // as the dominant kind.
390        if numeric > string && numeric > boolean && numeric + other >= total / 2 {
391            DistributionKind::Numeric
392        } else if string > numeric && string > boolean {
393            DistributionKind::String
394        } else {
395            DistributionKind::Mixed
396        }
397    }
398}
399
400/// Numeric path: small-cardinality integer enums become Exact;
401/// continuous numerics get Bands (§5.2 default).
402fn pick_numeric_strategy(values: &[Value], options: &StrategyDetectionOptions) -> Strategy {
403    // Pull integer-valued numbers out for the enum-numeric heuristic.
404    let mut all_integer = true;
405    let mut distinct = std::collections::BTreeSet::new();
406    for v in values {
407        let n = match v {
408            Value::Number(n) => n,
409            _ => {
410                all_integer = false;
411                break;
412            }
413        };
414        let f = match n.as_f64() {
415            Some(f) => f,
416            None => {
417                all_integer = false;
418                break;
419            }
420        };
421        if !f.is_finite() || f.fract() != 0.0 {
422            all_integer = false;
423            break;
424        }
425        // Use the i128-or-fallback string form for distinct counting.
426        // `n.as_i64()` covers most integer JSON numbers; otherwise fall
427        // back to the f64 bit pattern.
428        if let Some(i) = n.as_i64() {
429            distinct.insert(i.to_string());
430        } else {
431            distinct.insert(format!("{f}"));
432        }
433    }
434
435    if all_integer && distinct.len() <= ENUM_NUMERIC_MAX_CARDINALITY {
436        return Strategy::Exact;
437    }
438
439    Strategy::Bands {
440        count: options.default_bands,
441    }
442}
443
444/// String path: enum → exact, path-like → prefix, else top.
445fn pick_string_strategy(values: &[Value], options: &StrategyDetectionOptions) -> Strategy {
446    use std::collections::BTreeMap;
447
448    let mut frequencies: BTreeMap<String, usize> = BTreeMap::new();
449    for v in values {
450        if let Value::String(s) = v {
451            *frequencies.entry(s.clone()).or_insert(0) += 1;
452        }
453    }
454
455    let total = values.len();
456    let cardinality = frequencies.len();
457    if cardinality == 0 {
458        return Strategy::Top { n: DEFAULT_TOP_N };
459    }
460
461    // Enum heuristic: cardinality ≤ configured ceiling OR ≤ √N, top
462    // values cover the configured threshold.
463    // We additionally require cardinality < total — when every value is
464    // unique the field isn't enum-like even if the small-N case lets
465    // both sides of the OR pass. The §5.3 path-like check below should
466    // get a chance on degenerate "all unique" string fields.
467    let sqrt_n = (total as f64).sqrt().ceil() as usize;
468    let cardinality_ok = (cardinality <= options.enum_max_cardinality || cardinality <= sqrt_n)
469        && cardinality < total;
470
471    if cardinality_ok {
472        let mut counts: Vec<usize> = frequencies.values().copied().collect();
473        counts.sort_by(|a, b| b.cmp(a));
474        let take = counts.len().min(options.enum_max_cardinality);
475        let top_sum: usize = counts.iter().take(take).sum();
476        let coverage = top_sum as f64 / total as f64;
477        if coverage >= options.enum_coverage_threshold {
478            return Strategy::Exact;
479        }
480    }
481
482    // Path-like heuristic: ≥ 50% contain a recognized separator AND
483    // share at least one 2-segment prefix on that separator.
484    if let Some(sep) = dominant_path_separator(values, options.path_like_slash_ratio)
485        && shares_two_segment_prefix(values, sep)
486    {
487        return Strategy::Prefix { depth: None };
488    }
489
490    Strategy::Top { n: DEFAULT_TOP_N }
491}
492
493/// Returns the dominant path separator across the string values in
494/// `values`, or `None` if no recognized separator covers at least
495/// `min_ratio` of them.
496///
497/// Recognized separators are `/`, `::`, and `.`. The separator that
498/// appears in the highest fraction of string values wins; ties resolve
499/// in [`PATH_SEPARATORS`] declaration order (`/` > `::` > `.`).
500///
501/// Non-string values are ignored for the count (matches the legacy
502/// slash-only check). The denominator is the total length of `values`,
503/// which keeps the threshold meaningful when strings are mixed with
504/// nulls or other shapes.
505pub(crate) fn dominant_path_separator(values: &[Value], min_ratio: f64) -> Option<&'static str> {
506    if values.is_empty() {
507        return None;
508    }
509    let total = values.len() as f64;
510    let mut best: Option<(&'static str, usize)> = None;
511    for &sep in PATH_SEPARATORS {
512        let count = values
513            .iter()
514            .filter(|v| matches!(v, Value::String(s) if s.contains(sep)))
515            .count();
516        let ratio = count as f64 / total;
517        if ratio < min_ratio {
518            continue;
519        }
520        match best {
521            // Strictly greater coverage — replace.
522            Some((_, current)) if count > current => best = Some((sep, count)),
523            // Equal coverage — earlier separator (already stored) wins.
524            Some(_) => {}
525            None => best = Some((sep, count)),
526        }
527    }
528    best.map(|(sep, _)| sep)
529}
530
531/// Returns `true` when at least two of the string values share the same
532/// first two `sep`-separated segments (e.g. `src/cli/main.rs` and
533/// `src/cli/lib.rs` share `src/cli` for `/`; `Foo::Bar::baz` and
534/// `Foo::Bar::qux` share `Foo::Bar` for `::`).
535fn shares_two_segment_prefix(values: &[Value], sep: &str) -> bool {
536    use std::collections::BTreeMap;
537    let mut prefix_counts: BTreeMap<String, usize> = BTreeMap::new();
538    for v in values {
539        let Value::String(s) = v else {
540            continue;
541        };
542        let mut parts = s.split(sep);
543        let (Some(a), Some(b)) = (parts.next(), parts.next()) else {
544            continue;
545        };
546        if a.is_empty() && b.is_empty() {
547            continue;
548        }
549        let prefix = format!("{a}{sep}{b}");
550        *prefix_counts.entry(prefix).or_insert(0) += 1;
551    }
552    prefix_counts.values().any(|&c| c >= 2)
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use serde_json::json;
559
560    #[test]
561    fn small_cardinality_string_is_exact() {
562        let items = vec![
563            json!({"kind": "bug"}),
564            json!({"kind": "bug"}),
565            json!({"kind": "feat"}),
566            json!({"kind": "feat"}),
567            json!({"kind": "bug"}),
568        ];
569        assert_eq!(auto_strategy("kind", &items).unwrap(), Strategy::Exact);
570    }
571
572    #[test]
573    fn path_like_strings_become_prefix() {
574        let items = vec![
575            json!({"file": "src/cli/main.rs"}),
576            json!({"file": "src/cli/lib.rs"}),
577            json!({"file": "src/core/util.rs"}),
578            json!({"file": "src/core/api.rs"}),
579            json!({"file": "src/format/human.rs"}),
580            json!({"file": "src/format/json.rs"}),
581        ];
582        assert_eq!(
583            auto_strategy("file", &items).unwrap(),
584            Strategy::Prefix { depth: None }
585        );
586    }
587
588    #[test]
589    fn free_form_strings_fall_back_to_top() {
590        // Many distinct values with no shared prefixes → Top.
591        let items: Vec<Value> = (0..50)
592            .map(|i| json!({"msg": format!("totally-unique-message-{i}-with-words")}))
593            .collect();
594        match auto_strategy("msg", &items).unwrap() {
595            Strategy::Top { n } => assert_eq!(n, DEFAULT_TOP_N),
596            other => panic!("expected Top, got {other:?}"),
597        }
598    }
599
600    #[test]
601    fn continuous_numeric_becomes_bands() {
602        let items: Vec<Value> = (0..50)
603            .map(|i| json!({"score": (i as f64) * 0.013}))
604            .collect();
605        match auto_strategy("score", &items).unwrap() {
606            Strategy::Bands { count } => assert_eq!(count, DEFAULT_BAND_COUNT),
607            other => panic!("expected Bands, got {other:?}"),
608        }
609    }
610
611    #[test]
612    fn small_integer_numeric_becomes_exact() {
613        // Severity-style: small integer enum.
614        let items: Vec<Value> = [0, 1, 2, 1, 0, 2, 3]
615            .iter()
616            .map(|s| json!({"sev": *s}))
617            .collect();
618        assert_eq!(auto_strategy("sev", &items).unwrap(), Strategy::Exact);
619    }
620
621    #[test]
622    fn empty_items_returns_top_fallback() {
623        assert!(matches!(
624            auto_strategy("anything", &[]).unwrap(),
625            Strategy::Top { .. }
626        ));
627    }
628
629    #[test]
630    fn pick_grouping_field_prefers_canonical_name() {
631        let items = vec![
632            json!({"kind": "bug",  "id": "x1"}),
633            json!({"kind": "bug",  "id": "x2"}),
634            json!({"kind": "feat", "id": "x3"}),
635        ];
636        // `kind` is canonical, `id` is high-cardinality (above
637        // records/3=1). Pick `kind`.
638        assert_eq!(pick_grouping_field(&items).as_deref(), Some("kind"));
639    }
640
641    #[test]
642    fn pick_grouping_field_filters_out_high_cardinality() {
643        let items: Vec<Value> = (0..30)
644            .map(|i| json!({"id": format!("x{i}"), "kind": "same"}))
645            .collect();
646        // `id` is too high-cardinality (30 > 30/3 = 10), `kind` has
647        // cardinality 1 (< 2). Neither qualifies → None.
648        assert_eq!(pick_grouping_field(&items), None);
649    }
650
651    #[test]
652    fn pick_grouping_field_picks_canonical_over_alphabetical() {
653        // 12 records: alpha cardinality 3, kind cardinality 3.
654        // records/3 = 4 → both qualify. `kind` is canonical so it
655        // beats alpha despite the alphabetical order.
656        let alpha_vals = ["a", "b", "c"];
657        let kind_vals = ["x", "y", "z"];
658        let items: Vec<Value> = (0..12)
659            .map(|i| json!({"alpha": alpha_vals[i % 3], "kind": kind_vals[i % 3]}))
660            .collect();
661        assert_eq!(pick_grouping_field(&items).as_deref(), Some("kind"));
662    }
663
664    #[test]
665    fn pick_grouping_field_falls_back_to_max_cardinality() {
666        // 12 records, no canonical-named fields. alpha cardinality 4,
667        // beta cardinality 2. records/3 = 4 → both qualify. alpha wins
668        // on cardinality.
669        let alpha_vals = ["a", "b", "c", "d"];
670        let beta_vals = ["x", "y"];
671        let items: Vec<Value> = (0..12)
672            .map(|i| json!({"alpha": alpha_vals[i % 4], "beta": beta_vals[i % 2]}))
673            .collect();
674        assert_eq!(pick_grouping_field(&items).as_deref(), Some("alpha"));
675    }
676
677    #[test]
678    fn double_colon_paths_become_prefix() {
679        // Rust/C++ namespace style: `Module::Submodule::Symbol`.
680        let items = vec![
681            json!({"locator": "Foo::A::x"}),
682            json!({"locator": "Foo::A::y"}),
683            json!({"locator": "Foo::B::x"}),
684            json!({"locator": "Bar::A::x"}),
685        ];
686        assert_eq!(
687            auto_strategy("locator", &items).unwrap(),
688            Strategy::Prefix { depth: None }
689        );
690    }
691
692    #[test]
693    fn dotted_namespace_becomes_prefix() {
694        // Java/JS-style: `com.example.foo.Bar`.
695        let items = vec![
696            json!({"path": "com.example.foo.Bar"}),
697            json!({"path": "com.example.foo.Baz"}),
698            json!({"path": "com.example.bar.Qux"}),
699            json!({"path": "com.example.bar.Quux"}),
700        ];
701        assert_eq!(
702            auto_strategy("path", &items).unwrap(),
703            Strategy::Prefix { depth: None }
704        );
705    }
706
707    #[test]
708    fn file_extensions_alone_do_not_trigger_prefix() {
709        // Bare `Foo.swift` / `Bar.kt` / `Qux.rs` — every value contains
710        // `.` but none share a 2-segment prefix on `.`. Should fall
711        // through to Top, not be misclassified as Prefix.
712        let items: Vec<Value> = (0..30)
713            .map(|i| json!({"name": format!("Symbol{i}.swift")}))
714            .collect();
715        match auto_strategy("name", &items).unwrap() {
716            Strategy::Top { .. } => {}
717            other => panic!("expected Top, got {other:?}"),
718        }
719    }
720
721    #[test]
722    fn dominant_separator_picks_highest_coverage() {
723        // Mixed input: `/` covers 4/5, `::` covers 1/5.
724        let values: Vec<Value> = vec![
725            json!("a/b/c"),
726            json!("a/b/d"),
727            json!("x/y/z"),
728            json!("x/y/w"),
729            json!("Foo::Bar::baz"),
730        ];
731        assert_eq!(dominant_path_separator(&values, 0.5), Some("/"));
732    }
733
734    #[test]
735    fn pick_grouping_field_picks_path_like_at_high_cardinality() {
736        // 30 records with a high-cardinality `locator` field that is
737        // path-like (uses `::`), plus a tiny `kind` enum. Cardinality
738        // window would normally exclude `locator` (29 > 30/3=10), but
739        // the path-like scan should still pick it because the
740        // prefix-split makes it a useful grouping.
741        let mut items: Vec<Value> = Vec::new();
742        for i in 0..30 {
743            // Three modules: Share (10), Lib (10), App (10).
744            // Each has many distinct symbols → high cardinality
745            // overall, low cardinality after prefix split.
746            let module = match i % 3 {
747                0 => "Share",
748                1 => "Lib",
749                _ => "App",
750            };
751            items.push(json!({
752                "locator": format!("{module}::View::sym{i}"),
753                "kind": "function",
754            }));
755        }
756        assert_eq!(
757            pick_grouping_field(&items).as_deref(),
758            Some("locator"),
759            "high-cardinality path-like field should win over a 1-cardinality enum",
760        );
761    }
762
763    #[test]
764    fn pick_grouping_field_discovers_rg_nested_path() {
765        let items = vec![
766            json!({"type": "begin", "data": {"path": {"text": "Modules/Share/A.swift"}}}),
767            json!({"type": "match", "data": {"path": {"text": "Modules/Share/A.swift"}, "lines": {"text": "let viewModel = AViewModel()"}}}),
768            json!({"type": "end", "data": {"path": {"text": "Modules/Share/A.swift"}}}),
769            json!({"type": "begin", "data": {"path": {"text": "Modules/Room/B.swift"}}}),
770            json!({"type": "match", "data": {"path": {"text": "Modules/Room/B.swift"}, "lines": {"text": "let viewModel = BViewModel()"}}}),
771            json!({"type": "end", "data": {"path": {"text": "Modules/Room/B.swift"}}}),
772            json!({"type": "summary", "data": {"elapsed_total": {"secs": 0, "nanos": 1}}}),
773        ];
774        assert_eq!(
775            pick_grouping_field(&items).as_deref(),
776            Some("data.path.text"),
777            "rg JSONL should auto-group by file path, not record `type`",
778        );
779    }
780
781    #[test]
782    fn pick_grouping_field_disqualifies_mixed_type_field() {
783        let items = vec![
784            json!({"mixed": "string-value"}),
785            json!({"mixed": 42}),
786            json!({"mixed": "another"}),
787            json!({"kind": "ok"}),
788            json!({"kind": "ok"}),
789            json!({"kind": "no"}),
790        ];
791        // `mixed` has a non-string value → disqualified.
792        // `kind` is canonical and qualifies.
793        assert_eq!(pick_grouping_field(&items).as_deref(), Some("kind"));
794    }
795}