1#![allow(
2 clippy::print_stderr,
3 reason = "human stderr notes (no-git, bot patterns, CODEOWNERS) preserved verbatim from the CLI health path"
4)]
5
6use fallow_output::{FileHealthScore, HotspotEntry, HotspotSummary};
7
8use super::HealthOptions;
9use super::ownership::{OwnershipContext, compile_bot_globs, compute_ownership};
10
11fn is_test_path(relative: &std::path::Path) -> bool {
15 let s = relative.to_string_lossy().replace('\\', "/");
16 s.contains("/__tests__/")
17 || s.contains("/__mocks__/")
18 || s.contains("/test/")
19 || s.contains("/tests/")
20 || s.contains(".test.")
21 || s.contains(".spec.")
22}
23
24pub struct ChurnFetchResult {
26 pub result: crate::churn::ChurnResult,
27 pub since: crate::churn::SinceDuration,
28 pub cache_hit: bool,
29 pub git_log_ms: f64,
30}
31
32pub struct TargetChurnOptions<'a> {
37 pub root: &'a std::path::Path,
39 pub target: &'a std::path::Path,
41 pub cache_dir: std::path::PathBuf,
43 pub no_cache: bool,
45 pub since: Option<&'a str>,
48 pub min_commits: Option<u32>,
50}
51
52#[derive(Debug)]
54pub struct TargetChurnEvidence {
55 pub file: crate::churn::FileChurn,
57 pub since: crate::churn::SinceDuration,
59 pub min_commits: u32,
61 pub shallow_clone: bool,
64}
65
66#[derive(Debug)]
68pub enum TargetChurnOutcome {
69 Found(TargetChurnEvidence),
71 NoQualifyingChurn {
73 observed_commits: Option<u32>,
75 since: crate::churn::SinceDuration,
77 min_commits: u32,
79 shallow_clone: bool,
82 },
83 Unavailable {
85 message: String,
87 },
88}
89
90pub fn analyze_target_churn(
96 options: &TargetChurnOptions<'_>,
97) -> Result<TargetChurnOutcome, String> {
98 analyze_target_churn_with(
99 options,
100 crate::churn::is_git_repo,
101 crate::churn::analyze_churn_cached,
102 )
103}
104
105fn analyze_target_churn_with<GitAvailable, Analyze>(
106 options: &TargetChurnOptions<'_>,
107 git_available: GitAvailable,
108 analyze: Analyze,
109) -> Result<TargetChurnOutcome, String>
110where
111 GitAvailable: FnOnce(&std::path::Path) -> bool,
112 Analyze: FnOnce(
113 &std::path::Path,
114 &crate::churn::SinceDuration,
115 &std::path::Path,
116 bool,
117 ) -> Option<(crate::churn::ChurnResult, bool)>,
118{
119 if !git_available(options.root) {
120 return Ok(TargetChurnOutcome::Unavailable {
121 message: "git repository unavailable at project root".to_string(),
122 });
123 }
124
125 let since = crate::churn::parse_since(options.since.unwrap_or("6m"))?;
126 let min_commits = options.min_commits.unwrap_or(3);
127 let Some((result, _cache_hit)) =
128 analyze(options.root, &since, &options.cache_dir, options.no_cache)
129 else {
130 return Err("git churn analysis failed".to_string());
131 };
132 let shallow_clone = result.shallow_clone;
133 let target = options.root.join(options.target);
134 let file = result.files.get(&target).cloned();
135
136 let observed_commits = file.as_ref().map(|file| file.commits);
137 if let Some(file) = file
138 && file.commits >= min_commits
139 {
140 return Ok(TargetChurnOutcome::Found(TargetChurnEvidence {
141 file,
142 since,
143 min_commits,
144 shallow_clone,
145 }));
146 }
147
148 Ok(TargetChurnOutcome::NoQualifyingChurn {
149 observed_commits,
150 since,
151 min_commits,
152 shallow_clone,
153 })
154}
155
156pub(super) fn fetch_churn_data(
164 opts: &HealthOptions<'_>,
165 cache_dir: &std::path::Path,
166) -> Option<ChurnFetchResult> {
167 if let Some(churn_file) = opts.churn_file {
173 let resolved =
174 crate::health::scoring::resolve_relative_to_root(churn_file, Some(opts.root));
175 let t = std::time::Instant::now();
176 let result = match crate::churn::analyze_churn_from_file(&resolved, opts.root) {
177 Ok(r) => r,
178 Err(e) => {
179 tracing::warn!("churn file became unreadable after validation: {e}");
186 return None;
187 }
188 };
189 return Some(ChurnFetchResult {
190 result,
191 since: imported_since(),
192 cache_hit: false,
193 git_log_ms: t.elapsed().as_secs_f64() * 1000.0,
194 });
195 }
196
197 if !crate::churn::is_git_repo(opts.root) {
198 if !opts.quiet {
199 eprintln!("note: hotspot analysis skipped: no git repository found at project root");
200 }
201 return None;
202 }
203
204 let since_input = opts.since.unwrap_or("6m");
205 if let Err(e) = crate::validate::validate_no_control_chars(since_input, "--since") {
206 tracing::warn!("hotspot analysis skipped: {e}");
211 return None;
212 }
213 let since = match crate::churn::parse_since(since_input) {
214 Ok(s) => s,
215 Err(e) => {
216 tracing::warn!("hotspot analysis skipped: invalid --since: {e}");
217 return None;
218 }
219 };
220
221 let t = std::time::Instant::now();
222 let (churn_result, cache_hit) =
223 crate::churn::analyze_churn_cached(opts.root, &since, cache_dir, opts.no_cache)?;
224 let git_log_ms = t.elapsed().as_secs_f64() * 1000.0;
225
226 Some(ChurnFetchResult {
227 result: churn_result,
228 since,
229 cache_hit,
230 git_log_ms,
231 })
232}
233
234fn imported_since() -> crate::churn::SinceDuration {
238 crate::churn::SinceDuration {
239 git_after: String::new(),
240 display: "imported churn".to_string(),
241 }
242}
243
244fn compute_normalization_maxima(
248 file_scores: &[FileHealthScore],
249 churn_files: &rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn>,
250 min_commits: u32,
251) -> (f64, f64) {
252 let mut max_weighted: f64 = 0.0;
253 let mut max_density: f64 = 0.0;
254 for score in file_scores {
255 if let Some(churn) = churn_files.get(&score.path)
256 && churn.commits >= min_commits
257 {
258 max_weighted = max_weighted.max(churn.weighted_commits);
259 max_density = max_density.max(score.complexity_density);
260 }
261 }
262 (max_weighted, max_density)
263}
264
265fn is_excluded_from_hotspots(
268 path: &std::path::Path,
269 root: &std::path::Path,
270 ignore_set: &globset::GlobSet,
271 ws_roots: Option<&[std::path::PathBuf]>,
272) -> bool {
273 if let Some(ws) = ws_roots
274 && !ws.iter().any(|r| path.starts_with(r))
275 {
276 return true;
277 }
278 if !ignore_set.is_empty() {
279 let relative = path.strip_prefix(root).unwrap_or(path);
280 if ignore_set.is_match(relative) {
281 return true;
282 }
283 }
284 false
285}
286
287fn compute_hotspot_score(
292 weighted_commits: f64,
293 max_weighted: f64,
294 complexity_density: f64,
295 max_density: f64,
296) -> f64 {
297 let norm_churn = if max_weighted > 0.0 {
298 weighted_commits / max_weighted
299 } else {
300 0.0
301 };
302 let norm_complexity = if max_density > 0.0 {
303 complexity_density / max_density
304 } else {
305 0.0
306 };
307 (norm_churn * norm_complexity * 100.0 * 10.0).round() / 10.0
308}
309
310pub(super) struct HotspotComputationInput<'a> {
311 pub(super) opts: &'a HealthOptions<'a>,
312 pub(super) config: &'a fallow_config::ResolvedConfig,
313 pub(super) file_scores: &'a [FileHealthScore],
314 pub(super) ignore_set: &'a globset::GlobSet,
315 pub(super) ws_roots: Option<&'a [std::path::PathBuf]>,
316 pub(super) churn_fetch: ChurnFetchResult,
317}
318
319pub(super) fn compute_hotspots(
321 input: HotspotComputationInput<'_>,
322) -> (Vec<HotspotEntry>, Option<HotspotSummary>) {
323 let HotspotComputationInput {
324 opts,
325 config,
326 file_scores,
327 ignore_set,
328 ws_roots,
329 churn_fetch,
330 } = input;
331 let churn_result = churn_fetch.result;
332 let since = churn_fetch.since;
333
334 let shallow_clone = churn_result.shallow_clone;
335 warn_shallow_clone(opts, shallow_clone);
336
337 let min_commits = opts.min_commits.unwrap_or(3);
338 let (max_weighted, max_density) =
339 compute_normalization_maxima(file_scores, &churn_result.files, min_commits);
340
341 let ownership_cfg = &config.health.ownership;
342 let bot_globs_owned = load_ownership_bot_globs(opts, ownership_cfg);
343 let codeowners_owned = load_ownership_codeowners(opts, &config.root);
344 let now_secs = std::time::SystemTime::now()
345 .duration_since(std::time::UNIX_EPOCH)
346 .unwrap_or_default()
347 .as_secs();
348 let ownership_ctx = bot_globs_owned.as_ref().map(|bot_globs| OwnershipContext {
349 author_pool: &churn_result.author_pool,
350 bot_globs,
351 codeowners: codeowners_owned.as_ref(),
352 email_mode: opts.ownership_emails.unwrap_or(ownership_cfg.email_mode),
353 now_secs,
354 });
355
356 let (mut hotspot_entries, files_excluded) = collect_hotspot_entries(&HotspotEntryCtx {
357 file_scores,
358 root: &config.root,
359 ignore_set,
360 ws_roots,
361 churn_files: &churn_result.files,
362 min_commits,
363 max_weighted,
364 max_density,
365 ownership_ctx: ownership_ctx.as_ref(),
366 });
367
368 hotspot_entries.sort_by(|a, b| {
369 b.score
370 .partial_cmp(&a.score)
371 .unwrap_or(std::cmp::Ordering::Equal)
372 });
373
374 let files_analyzed = hotspot_entries.len();
375 let summary = HotspotSummary {
376 since: since.display,
377 min_commits,
378 files_analyzed,
379 files_excluded,
380 shallow_clone,
381 };
382
383 if let Some(top) = opts.top {
384 hotspot_entries.truncate(top);
385 }
386
387 (hotspot_entries, Some(summary))
388}
389
390fn warn_shallow_clone(opts: &HealthOptions<'_>, shallow_clone: bool) {
392 if shallow_clone && !opts.quiet {
393 eprintln!(
394 "Warning: shallow clone detected. Hotspot analysis may be incomplete. \
395 Use `git fetch --unshallow` for full history."
396 );
397 if opts.ownership {
398 eprintln!(
399 "Warning: shallow clones inflate single-author dominance, so \
400 ownership signals will be skewed."
401 );
402 }
403 }
404}
405
406fn load_ownership_bot_globs(
408 opts: &HealthOptions<'_>,
409 ownership_cfg: &fallow_config::OwnershipConfig,
410) -> Option<globset::GlobSet> {
411 opts.ownership.then(|| {
412 compile_bot_globs(&ownership_cfg.bot_patterns).unwrap_or_else(|e| {
413 if !opts.quiet {
414 eprintln!("Warning: invalid bot pattern in health.ownership.botPatterns: {e}");
415 }
416 globset::GlobSet::empty()
417 })
418 })
419}
420
421fn load_ownership_codeowners(
423 opts: &HealthOptions<'_>,
424 root: &std::path::Path,
425) -> Option<crate::codeowners::CodeOwners> {
426 opts.ownership
427 .then(|| match crate::codeowners::CodeOwners::load(root, None) {
428 Ok(co) => Some(co),
429 Err(e) => {
430 if !opts.quiet && !e.contains("no CODEOWNERS file found") {
431 eprintln!("Warning: failed to parse CODEOWNERS: {e}");
432 }
433 None
434 }
435 })
436 .flatten()
437}
438
439struct HotspotEntryCtx<'a> {
441 file_scores: &'a [FileHealthScore],
442 root: &'a std::path::Path,
443 ignore_set: &'a globset::GlobSet,
444 ws_roots: Option<&'a [std::path::PathBuf]>,
445 churn_files: &'a rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn>,
446 min_commits: u32,
447 max_weighted: f64,
448 max_density: f64,
449 ownership_ctx: Option<&'a OwnershipContext<'a>>,
450}
451
452fn collect_hotspot_entries(ctx: &HotspotEntryCtx<'_>) -> (Vec<HotspotEntry>, usize) {
455 let mut hotspot_entries = Vec::new();
456 let mut files_excluded: usize = 0;
457
458 for score in ctx.file_scores {
459 if is_excluded_from_hotspots(&score.path, ctx.root, ctx.ignore_set, ctx.ws_roots) {
460 continue;
461 }
462
463 let Some(churn) = ctx.churn_files.get(&score.path) else {
464 continue;
465 };
466 if churn.commits < ctx.min_commits {
467 files_excluded += 1;
468 continue;
469 }
470
471 let relative = score.path.strip_prefix(ctx.root).unwrap_or(&score.path);
472 let ownership = ctx
473 .ownership_ctx
474 .and_then(|own| compute_ownership(churn, relative, own));
475
476 hotspot_entries.push(HotspotEntry {
477 path: score.path.clone(),
478 score: compute_hotspot_score(
479 churn.weighted_commits,
480 ctx.max_weighted,
481 score.complexity_density,
482 ctx.max_density,
483 ),
484 commits: churn.commits,
485 weighted_commits: churn.weighted_commits,
486 lines_added: churn.lines_added,
487 lines_deleted: churn.lines_deleted,
488 complexity_density: score.complexity_density,
489 fan_in: score.fan_in,
490 trend: churn.trend,
491 ownership,
492 is_test_path: is_test_path(relative),
493 });
494 }
495
496 (hotspot_entries, files_excluded)
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 fn target_churn_options(root: &std::path::Path) -> TargetChurnOptions<'_> {
504 TargetChurnOptions {
505 root,
506 target: std::path::Path::new("src/app.ts"),
507 cache_dir: root.join(".fallow"),
508 no_cache: true,
509 since: None,
510 min_commits: None,
511 }
512 }
513
514 fn churn_result(root: &std::path::Path, commits: u32) -> crate::churn::ChurnResult {
515 let path = root.join("src/app.ts");
516 let mut files = rustc_hash::FxHashMap::default();
517 files.insert(
518 path.clone(),
519 crate::churn::FileChurn {
520 path,
521 commits,
522 weighted_commits: 2.5,
523 lines_added: 20,
524 lines_deleted: 5,
525 trend: crate::churn::ChurnTrend::Accelerating,
526 authors: rustc_hash::FxHashMap::default(),
527 },
528 );
529 crate::churn::ChurnResult {
530 files,
531 shallow_clone: false,
532 author_pool: Vec::new(),
533 }
534 }
535
536 #[test]
537 fn target_churn_returns_only_the_requested_qualifying_file() {
538 let root = std::path::Path::new("/project");
539 let options = target_churn_options(root);
540
541 let outcome = analyze_target_churn_with(
542 &options,
543 |_| true,
544 |_, _, _, _| Some((churn_result(root, 4), false)),
545 )
546 .unwrap();
547
548 let TargetChurnOutcome::Found(evidence) = outcome else {
549 panic!("expected qualifying churn evidence");
550 };
551 assert_eq!(evidence.file.path, root.join("src/app.ts"));
552 assert_eq!(evidence.file.commits, 4);
553 assert_eq!(evidence.min_commits, 3);
554 assert_eq!(evidence.since.display, "6 months");
555 }
556
557 #[test]
558 fn target_churn_distinguishes_no_qualifying_history() {
559 let root = std::path::Path::new("/project");
560 let options = target_churn_options(root);
561
562 let outcome = analyze_target_churn_with(
563 &options,
564 |_| true,
565 |_, _, _, _| Some((churn_result(root, 2), false)),
566 )
567 .unwrap();
568
569 assert!(matches!(
570 outcome,
571 TargetChurnOutcome::NoQualifyingChurn {
572 observed_commits: Some(2),
573 min_commits: 3,
574 ..
575 }
576 ));
577 }
578
579 #[test]
580 fn target_churn_distinguishes_git_unavailable() {
581 let root = std::path::Path::new("/project");
582 let options = target_churn_options(root);
583
584 let outcome = analyze_target_churn_with(
585 &options,
586 |_| false,
587 |_, _, _, _| panic!("churn analysis must not run without git"),
588 )
589 .unwrap();
590
591 assert!(matches!(outcome, TargetChurnOutcome::Unavailable { .. }));
592 }
593
594 #[test]
595 fn target_churn_surfaces_analysis_failure() {
596 let root = std::path::Path::new("/project");
597 let options = target_churn_options(root);
598
599 let error = analyze_target_churn_with(&options, |_| true, |_, _, _, _| None)
600 .expect_err("failed git analysis must remain explicit");
601
602 assert!(error.contains("git churn analysis failed"));
603 }
604
605 #[test]
606 fn hotspot_score_both_maxima_zero() {
607 assert!((compute_hotspot_score(0.0, 0.0, 0.0, 0.0)).abs() < f64::EPSILON);
608 }
609
610 #[test]
611 fn hotspot_score_max_weighted_zero() {
612 assert!((compute_hotspot_score(5.0, 0.0, 0.5, 1.0)).abs() < f64::EPSILON);
613 }
614
615 #[test]
616 fn hotspot_score_max_density_zero() {
617 assert!((compute_hotspot_score(5.0, 10.0, 0.0, 0.0)).abs() < f64::EPSILON);
618 }
619
620 #[test]
621 fn hotspot_score_equal_normalization() {
622 let score = compute_hotspot_score(10.0, 10.0, 2.0, 2.0);
623 assert!((score - 100.0).abs() < f64::EPSILON);
624 }
625
626 #[test]
627 fn hotspot_score_half_values() {
628 let score = compute_hotspot_score(5.0, 10.0, 1.0, 2.0);
629 assert!((score - 25.0).abs() < f64::EPSILON);
630 }
631
632 #[test]
633 fn excluded_no_filters() {
634 let path = std::path::Path::new("/project/src/foo.ts");
635 let root = std::path::Path::new("/project");
636 let ignore_set = globset::GlobSet::empty();
637
638 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
639 }
640
641 #[test]
642 fn excluded_workspace_filter_mismatch() {
643 let path = std::path::Path::new("/project/packages/b/src/foo.ts");
644 let root = std::path::Path::new("/project");
645 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
646 let ignore_set = globset::GlobSet::empty();
647
648 assert!(is_excluded_from_hotspots(
649 path,
650 root,
651 &ignore_set,
652 Some(&ws_roots)
653 ));
654 }
655
656 #[test]
657 fn excluded_workspace_filter_match() {
658 let path = std::path::Path::new("/project/packages/a/src/foo.ts");
659 let root = std::path::Path::new("/project");
660 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
661 let ignore_set = globset::GlobSet::empty();
662
663 assert!(!is_excluded_from_hotspots(
664 path,
665 root,
666 &ignore_set,
667 Some(&ws_roots)
668 ));
669 }
670
671 #[test]
672 fn excluded_matching_glob() {
673 let path = std::path::Path::new("/project/src/generated/types.ts");
674 let root = std::path::Path::new("/project");
675 let mut builder = globset::GlobSetBuilder::new();
676 builder.add(globset::Glob::new("src/generated/**").unwrap());
677 let ignore_set = builder.build().unwrap();
678
679 assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
680 }
681
682 #[test]
683 fn excluded_non_matching_glob() {
684 let path = std::path::Path::new("/project/src/components/Button.tsx");
685 let root = std::path::Path::new("/project");
686 let mut builder = globset::GlobSetBuilder::new();
687 builder.add(globset::Glob::new("src/generated/**").unwrap());
688 let ignore_set = builder.build().unwrap();
689
690 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
691 }
692
693 #[test]
694 fn normalization_maxima_empty_input() {
695 let scores: Vec<FileHealthScore> = vec![];
696 let churn_files = rustc_hash::FxHashMap::default();
697
698 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
699 assert!((max_w).abs() < f64::EPSILON);
700 assert!((max_d).abs() < f64::EPSILON);
701 }
702
703 #[test]
704 fn normalization_maxima_single_file() {
705 let scores = vec![FileHealthScore {
706 path: std::path::PathBuf::from("/src/foo.ts"),
707 fan_in: 0,
708 fan_out: 0,
709 dead_code_ratio: 0.0,
710 complexity_density: 0.75,
711 maintainability_index: 80.0,
712 total_cyclomatic: 15,
713 total_cognitive: 10,
714 function_count: 3,
715 lines: 20,
716 crap_max: 0.0,
717 crap_above_threshold: 0,
718 }];
719 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
720 rustc_hash::FxHashMap::default();
721 churn_files.insert(
722 std::path::PathBuf::from("/src/foo.ts"),
723 crate::churn::FileChurn {
724 path: std::path::PathBuf::from("/src/foo.ts"),
725 commits: 5,
726 weighted_commits: 4.2,
727 lines_added: 100,
728 lines_deleted: 20,
729 trend: crate::churn::ChurnTrend::Stable,
730 authors: rustc_hash::FxHashMap::default(),
731 },
732 );
733
734 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
735 assert!((max_w - 4.2).abs() < f64::EPSILON);
736 assert!((max_d - 0.75).abs() < f64::EPSILON);
737 }
738
739 #[test]
740 fn normalization_maxima_below_min_commits() {
741 let scores = vec![FileHealthScore {
742 path: std::path::PathBuf::from("/src/foo.ts"),
743 fan_in: 0,
744 fan_out: 0,
745 dead_code_ratio: 0.0,
746 complexity_density: 0.75,
747 maintainability_index: 80.0,
748 total_cyclomatic: 15,
749 total_cognitive: 10,
750 function_count: 3,
751 lines: 20,
752 crap_max: 0.0,
753 crap_above_threshold: 0,
754 }];
755 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
756 rustc_hash::FxHashMap::default();
757 churn_files.insert(
758 std::path::PathBuf::from("/src/foo.ts"),
759 crate::churn::FileChurn {
760 path: std::path::PathBuf::from("/src/foo.ts"),
761 commits: 2, weighted_commits: 4.2,
763 lines_added: 100,
764 lines_deleted: 20,
765 trend: crate::churn::ChurnTrend::Stable,
766 authors: rustc_hash::FxHashMap::default(),
767 },
768 );
769
770 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
771 assert!((max_w).abs() < f64::EPSILON);
772 assert!((max_d).abs() < f64::EPSILON);
773 }
774
775 #[test]
776 fn normalization_maxima_all_zeros() {
777 let scores = vec![FileHealthScore {
778 path: std::path::PathBuf::from("/src/foo.ts"),
779 fan_in: 0,
780 fan_out: 0,
781 dead_code_ratio: 0.0,
782 complexity_density: 0.0,
783 maintainability_index: 100.0,
784 total_cyclomatic: 0,
785 total_cognitive: 0,
786 function_count: 1,
787 lines: 10,
788 crap_max: 0.0,
789 crap_above_threshold: 0,
790 }];
791 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
792 rustc_hash::FxHashMap::default();
793 churn_files.insert(
794 std::path::PathBuf::from("/src/foo.ts"),
795 crate::churn::FileChurn {
796 path: std::path::PathBuf::from("/src/foo.ts"),
797 commits: 5,
798 weighted_commits: 0.0,
799 lines_added: 0,
800 lines_deleted: 0,
801 trend: crate::churn::ChurnTrend::Stable,
802 authors: rustc_hash::FxHashMap::default(),
803 },
804 );
805
806 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
807 assert!((max_w).abs() < f64::EPSILON);
808 assert!((max_d).abs() < f64::EPSILON);
809 }
810
811 #[test]
812 fn hotspot_score_high_churn_low_complexity() {
813 let score = compute_hotspot_score(10.0, 10.0, 0.1, 1.0);
814 assert!((score - 10.0).abs() < f64::EPSILON);
815 }
816
817 #[test]
818 fn hotspot_score_low_churn_high_complexity() {
819 let score = compute_hotspot_score(1.0, 10.0, 2.0, 2.0);
820 assert!((score - 10.0).abs() < f64::EPSILON);
821 }
822
823 #[test]
824 fn hotspot_score_rounding() {
825 let score = compute_hotspot_score(1.0, 3.0, 1.0, 3.0);
826 assert!((score - 11.1).abs() < f64::EPSILON);
827 }
828
829 #[test]
830 fn hotspot_score_very_small_values() {
831 let score = compute_hotspot_score(0.01, 100.0, 0.001, 10.0);
832 assert!((score).abs() < 0.1);
833 }
834
835 #[test]
836 fn hotspot_score_weighted_exceeds_max() {
837 let score = compute_hotspot_score(15.0, 10.0, 1.0, 2.0);
838 assert!((score - 75.0).abs() < f64::EPSILON);
839 }
840
841 #[test]
842 fn normalization_maxima_multiple_files_picks_max() {
843 let scores = vec![
844 FileHealthScore {
845 path: std::path::PathBuf::from("/src/a.ts"),
846 fan_in: 0,
847 fan_out: 0,
848 dead_code_ratio: 0.0,
849 complexity_density: 0.5,
850 maintainability_index: 80.0,
851 total_cyclomatic: 10,
852 total_cognitive: 5,
853 function_count: 2,
854 lines: 50,
855 crap_max: 0.0,
856 crap_above_threshold: 0,
857 },
858 FileHealthScore {
859 path: std::path::PathBuf::from("/src/b.ts"),
860 fan_in: 0,
861 fan_out: 0,
862 dead_code_ratio: 0.0,
863 complexity_density: 1.2, maintainability_index: 60.0,
865 total_cyclomatic: 30,
866 total_cognitive: 20,
867 function_count: 5,
868 lines: 100,
869 crap_max: 0.0,
870 crap_above_threshold: 0,
871 },
872 FileHealthScore {
873 path: std::path::PathBuf::from("/src/c.ts"),
874 fan_in: 0,
875 fan_out: 0,
876 dead_code_ratio: 0.0,
877 complexity_density: 0.8,
878 maintainability_index: 70.0,
879 total_cyclomatic: 20,
880 total_cognitive: 15,
881 function_count: 4,
882 lines: 80,
883 crap_max: 0.0,
884 crap_above_threshold: 0,
885 },
886 ];
887 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
888 rustc_hash::FxHashMap::default();
889 churn_files.insert(
890 std::path::PathBuf::from("/src/a.ts"),
891 crate::churn::FileChurn {
892 path: std::path::PathBuf::from("/src/a.ts"),
893 commits: 5,
894 weighted_commits: 3.0,
895 lines_added: 50,
896 lines_deleted: 10,
897 trend: crate::churn::ChurnTrend::Stable,
898 authors: rustc_hash::FxHashMap::default(),
899 },
900 );
901 churn_files.insert(
902 std::path::PathBuf::from("/src/b.ts"),
903 crate::churn::FileChurn {
904 path: std::path::PathBuf::from("/src/b.ts"),
905 commits: 10,
906 weighted_commits: 8.5, lines_added: 200,
908 lines_deleted: 50,
909 trend: crate::churn::ChurnTrend::Accelerating,
910 authors: rustc_hash::FxHashMap::default(),
911 },
912 );
913 churn_files.insert(
914 std::path::PathBuf::from("/src/c.ts"),
915 crate::churn::FileChurn {
916 path: std::path::PathBuf::from("/src/c.ts"),
917 commits: 7,
918 weighted_commits: 5.0,
919 lines_added: 100,
920 lines_deleted: 30,
921 trend: crate::churn::ChurnTrend::Cooling,
922 authors: rustc_hash::FxHashMap::default(),
923 },
924 );
925
926 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
927 assert!((max_w - 8.5).abs() < f64::EPSILON);
928 assert!((max_d - 1.2).abs() < f64::EPSILON);
929 }
930
931 #[test]
932 fn normalization_maxima_mixed_above_and_below_threshold() {
933 let scores = vec![
934 FileHealthScore {
935 path: std::path::PathBuf::from("/src/frequent.ts"),
936 fan_in: 0,
937 fan_out: 0,
938 dead_code_ratio: 0.0,
939 complexity_density: 0.4,
940 maintainability_index: 85.0,
941 total_cyclomatic: 8,
942 total_cognitive: 4,
943 function_count: 2,
944 lines: 40,
945 crap_max: 0.0,
946 crap_above_threshold: 0,
947 },
948 FileHealthScore {
949 path: std::path::PathBuf::from("/src/rare.ts"),
950 fan_in: 0,
951 fan_out: 0,
952 dead_code_ratio: 0.0,
953 complexity_density: 2.0, maintainability_index: 50.0,
955 total_cyclomatic: 40,
956 total_cognitive: 30,
957 function_count: 8,
958 lines: 200,
959 crap_max: 0.0,
960 crap_above_threshold: 0,
961 },
962 ];
963 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
964 rustc_hash::FxHashMap::default();
965 churn_files.insert(
966 std::path::PathBuf::from("/src/frequent.ts"),
967 crate::churn::FileChurn {
968 path: std::path::PathBuf::from("/src/frequent.ts"),
969 commits: 10,
970 weighted_commits: 7.0,
971 lines_added: 150,
972 lines_deleted: 40,
973 trend: crate::churn::ChurnTrend::Stable,
974 authors: rustc_hash::FxHashMap::default(),
975 },
976 );
977 churn_files.insert(
978 std::path::PathBuf::from("/src/rare.ts"),
979 crate::churn::FileChurn {
980 path: std::path::PathBuf::from("/src/rare.ts"),
981 commits: 1, weighted_commits: 0.9,
983 lines_added: 10,
984 lines_deleted: 2,
985 trend: crate::churn::ChurnTrend::Cooling,
986 authors: rustc_hash::FxHashMap::default(),
987 },
988 );
989
990 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 5);
991 assert!((max_w - 7.0).abs() < f64::EPSILON);
992 assert!((max_d - 0.4).abs() < f64::EPSILON);
993 }
994
995 #[test]
996 fn normalization_maxima_file_score_without_churn() {
997 let scores = vec![FileHealthScore {
998 path: std::path::PathBuf::from("/src/no_churn.ts"),
999 fan_in: 0,
1000 fan_out: 0,
1001 dead_code_ratio: 0.0,
1002 complexity_density: 5.0,
1003 maintainability_index: 30.0,
1004 total_cyclomatic: 100,
1005 total_cognitive: 80,
1006 function_count: 20,
1007 lines: 500,
1008 crap_max: 0.0,
1009 crap_above_threshold: 0,
1010 }];
1011 let churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1012 rustc_hash::FxHashMap::default();
1013
1014 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 1);
1015 assert!((max_w).abs() < f64::EPSILON);
1016 assert!((max_d).abs() < f64::EPSILON);
1017 }
1018
1019 #[test]
1020 fn normalization_maxima_min_commits_zero() {
1021 let scores = vec![FileHealthScore {
1022 path: std::path::PathBuf::from("/src/foo.ts"),
1023 fan_in: 0,
1024 fan_out: 0,
1025 dead_code_ratio: 0.0,
1026 complexity_density: 0.3,
1027 maintainability_index: 90.0,
1028 total_cyclomatic: 3,
1029 total_cognitive: 2,
1030 function_count: 1,
1031 lines: 10,
1032 crap_max: 0.0,
1033 crap_above_threshold: 0,
1034 }];
1035 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1036 rustc_hash::FxHashMap::default();
1037 churn_files.insert(
1038 std::path::PathBuf::from("/src/foo.ts"),
1039 crate::churn::FileChurn {
1040 path: std::path::PathBuf::from("/src/foo.ts"),
1041 commits: 0,
1042 weighted_commits: 0.0,
1043 lines_added: 0,
1044 lines_deleted: 0,
1045 trend: crate::churn::ChurnTrend::Stable,
1046 authors: rustc_hash::FxHashMap::default(),
1047 },
1048 );
1049
1050 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 0);
1051 assert!((max_w).abs() < f64::EPSILON);
1052 assert!((max_d - 0.3).abs() < f64::EPSILON);
1053 }
1054
1055 #[test]
1056 fn normalization_maxima_exactly_at_threshold() {
1057 let scores = vec![FileHealthScore {
1058 path: std::path::PathBuf::from("/src/foo.ts"),
1059 fan_in: 0,
1060 fan_out: 0,
1061 dead_code_ratio: 0.0,
1062 complexity_density: 1.5,
1063 maintainability_index: 65.0,
1064 total_cyclomatic: 25,
1065 total_cognitive: 18,
1066 function_count: 5,
1067 lines: 120,
1068 crap_max: 0.0,
1069 crap_above_threshold: 0,
1070 }];
1071 let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1072 rustc_hash::FxHashMap::default();
1073 churn_files.insert(
1074 std::path::PathBuf::from("/src/foo.ts"),
1075 crate::churn::FileChurn {
1076 path: std::path::PathBuf::from("/src/foo.ts"),
1077 commits: 3, weighted_commits: 2.8,
1079 lines_added: 60,
1080 lines_deleted: 15,
1081 trend: crate::churn::ChurnTrend::Stable,
1082 authors: rustc_hash::FxHashMap::default(),
1083 },
1084 );
1085
1086 let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
1087 assert!((max_w - 2.8).abs() < f64::EPSILON);
1088 assert!((max_d - 1.5).abs() < f64::EPSILON);
1089 }
1090
1091 #[test]
1092 fn excluded_workspace_and_glob_combined() {
1093 let path = std::path::Path::new("/project/packages/a/src/generated/types.ts");
1094 let root = std::path::Path::new("/project");
1095 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
1096 let mut builder = globset::GlobSetBuilder::new();
1097 builder.add(globset::Glob::new("**/generated/**").unwrap());
1098 let ignore_set = builder.build().unwrap();
1099
1100 assert!(is_excluded_from_hotspots(
1101 path,
1102 root,
1103 &ignore_set,
1104 Some(&ws_roots)
1105 ));
1106 }
1107
1108 #[test]
1109 fn excluded_workspace_match_but_glob_no_match() {
1110 let path = std::path::Path::new("/project/packages/a/src/index.ts");
1111 let root = std::path::Path::new("/project");
1112 let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
1113 let mut builder = globset::GlobSetBuilder::new();
1114 builder.add(globset::Glob::new("**/generated/**").unwrap());
1115 let ignore_set = builder.build().unwrap();
1116
1117 assert!(!is_excluded_from_hotspots(
1118 path,
1119 root,
1120 &ignore_set,
1121 Some(&ws_roots)
1122 ));
1123 }
1124
1125 #[test]
1126 fn excluded_path_equals_root() {
1127 let path = std::path::Path::new("/project");
1128 let root = std::path::Path::new("/project");
1129 let ignore_set = globset::GlobSet::empty();
1130
1131 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1132 }
1133
1134 #[test]
1135 fn excluded_path_outside_root() {
1136 let path = std::path::Path::new("/other/src/foo.ts");
1137 let root = std::path::Path::new("/project");
1138 let mut builder = globset::GlobSetBuilder::new();
1139 builder.add(globset::Glob::new("src/foo.ts").unwrap());
1140 let ignore_set = builder.build().unwrap();
1141
1142 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1143 }
1144
1145 #[test]
1146 fn excluded_multiple_globs_first_matches() {
1147 let path = std::path::Path::new("/project/dist/bundle.js");
1148 let root = std::path::Path::new("/project");
1149 let mut builder = globset::GlobSetBuilder::new();
1150 builder.add(globset::Glob::new("dist/**").unwrap());
1151 builder.add(globset::Glob::new("node_modules/**").unwrap());
1152 let ignore_set = builder.build().unwrap();
1153
1154 assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
1155 }
1156
1157 #[test]
1158 fn excluded_multiple_globs_second_matches() {
1159 let path = std::path::Path::new("/project/node_modules/lodash/index.js");
1160 let root = std::path::Path::new("/project");
1161 let mut builder = globset::GlobSetBuilder::new();
1162 builder.add(globset::Glob::new("dist/**").unwrap());
1163 builder.add(globset::Glob::new("node_modules/**").unwrap());
1164 let ignore_set = builder.build().unwrap();
1165
1166 assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
1167 }
1168
1169 #[test]
1170 fn excluded_multiple_globs_none_matches() {
1171 let path = std::path::Path::new("/project/src/app.ts");
1172 let root = std::path::Path::new("/project");
1173 let mut builder = globset::GlobSetBuilder::new();
1174 builder.add(globset::Glob::new("dist/**").unwrap());
1175 builder.add(globset::Glob::new("node_modules/**").unwrap());
1176 let ignore_set = builder.build().unwrap();
1177
1178 assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1179 }
1180}