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