1use serde_json::Value;
20
21use crate::{FaceError, Strategy};
22
23const ENUM_MAX_CARDINALITY: usize = 20;
25
26const ENUM_COVERAGE_THRESHOLD: f64 = 0.80;
28
29const PATH_LIKE_SLASH_RATIO: f64 = 0.50;
32
33pub(crate) const PATH_SEPARATORS: &[&str] = &["/", "::", "."];
41
42const ENUM_NUMERIC_MAX_CARDINALITY: usize = 20;
45
46const DEFAULT_TOP_N: u32 = 10;
48
49const DEFAULT_BAND_COUNT: u8 = 5;
51
52const GROUPING_PREFERENCE: &[&str] = &[
55 "data.path.text",
56 "path",
57 "file",
58 "module",
59 "kind",
60 "status",
61 "type",
62];
63
64#[derive(Debug, Clone, PartialEq)]
66#[non_exhaustive]
67pub struct StrategyDetectionOptions {
68 pub enum_max_cardinality: usize,
70 pub enum_coverage_threshold: f64,
72 pub path_like_slash_ratio: f64,
76 pub group_preference: Vec<String>,
78 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
97pub fn auto_strategy(field: &str, items: &[Value]) -> Result<Strategy, FaceError> {
135 auto_strategy_with_options(field, items, &StrategyDetectionOptions::default())
136}
137
138pub 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 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
163pub fn pick_grouping_field(items: &[Value]) -> Option<String> {
192 pick_grouping_field_with_options(items, &StrategyDetectionOptions::default())
193}
194
195pub 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 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
243fn pick_with_canonical_preference(
246 candidates: &[(String, usize)],
247 preference: &[String],
248) -> Option<String> {
249 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 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
269fn 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
283fn collect_string_candidates(items: &[Value]) -> Vec<(String, usize)> {
289 use std::collections::BTreeMap;
290
291 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 }
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
350fn 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
359enum 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 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
400fn pick_numeric_strategy(values: &[Value], options: &StrategyDetectionOptions) -> Strategy {
403 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 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
444fn 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 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 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
493pub(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 Some((_, current)) if count > current => best = Some((sep, count)),
523 Some(_) => {}
525 None => best = Some((sep, count)),
526 }
527 }
528 best.map(|(sep, _)| sep)
529}
530
531fn 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 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 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 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 assert_eq!(pick_grouping_field(&items), None);
649 }
650
651 #[test]
652 fn pick_grouping_field_picks_canonical_over_alphabetical() {
653 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 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 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 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 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 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 let mut items: Vec<Value> = Vec::new();
742 for i in 0..30 {
743 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 assert_eq!(pick_grouping_field(&items).as_deref(), Some("kind"));
794 }
795}