Skip to main content

fallow_engine/health/
hotspots.rs

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
11/// Detect test/mock path conventions. Kept as a simple substring scan
12/// against forward-slash normalized paths so it works uniformly on the
13/// relative paths we use for display.
14pub(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
24/// Result of fetching churn data, including cache hit/miss info and timing.
25pub 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
32/// Focused inputs for target-level churn evidence.
33///
34/// This reuses the health hotspot churn cache without running project parsing,
35/// file scoring, or any other health section.
36pub 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/// Qualifying target-level churn returned by the focused health API.
46#[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/// Result states that do not represent a churn-analysis failure.
55#[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
69/// Analyze git churn for one normalized project-relative target.
70///
71/// The call is intentionally independent of the full health pipeline. Missing
72/// git is an explicit unavailable outcome, while a failed git analysis remains
73/// an error so callers can preserve partial-evidence warnings.
74pub 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
135/// Validate git prerequisites and return churn data for hotspot analysis.
136///
137/// Uses disk cache when available. Returns `None` if the repo is missing,
138/// `--since` is malformed, or git analysis fails. A missing git repo is treated
139/// as unavailable data rather than a hard error so combined-mode `--format
140/// json` never emits a second JSON document alongside the combined report
141/// (#294); a non-fatal note goes to stderr unless `--quiet` is set.
142pub(super) fn fetch_churn_data(
143    opts: &HealthOptions<'_>,
144    cache_dir: &std::path::Path,
145) -> Option<ChurnFetchResult> {
146    // `--churn-file` imports change history from a normalized JSON file and
147    // bypasses git entirely, so projects on a non-git VCS (Yandex Arc,
148    // Mercurial, Perforce) still get hotspots / ownership. The file is
149    // authoritative for the analysis window, so `--since` is NOT applied to
150    // imported events; it would only mislabel the header, hence `imported_since`.
151    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                // The up-front `health::validate_churn_file` gate already
159                // emitted this error and aborted with exit 2 for a malformed
160                // file, so reaching here means the file changed between the
161                // gate and this re-read (a TOCTOU race). Skip silently rather
162                // than emit a SECOND error document, which would break the
163                // single-document `--format json` contract (#294).
164                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        // A malformed `--since` degrades to "no churn, continue" like the
186        // missing-git-repo branch above: route the diagnostic to `tracing` and
187        // emit NO second JSON document, preserving the single-document
188        // `--format json` contract (#294).
189        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
213/// Header label for imported churn (`--churn-file`). The imported window is
214/// whatever the wrapper exported, so reusing the `--since` duration ("since 6
215/// months") would misdescribe it. `git_after` is unused on the import path.
216fn imported_since() -> crate::churn::SinceDuration {
217    crate::churn::SinceDuration {
218        git_after: String::new(),
219        display: "imported churn".to_string(),
220    }
221}
222
223/// Find the maximum weighted-commits and complexity-density across eligible files.
224///
225/// Used to normalize hotspot scores into the 0-100 range.
226pub(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
244/// Check whether a file should be excluded from hotspot results
245/// based on workspace filter and ignore patterns.
246pub(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
266/// Compute a normalized hotspot score from churn and complexity data.
267///
268/// Both inputs are normalized against their respective maxima so the result
269/// falls in the 0-100 range (rounded to one decimal).
270pub(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
298/// Compute hotspot entries by combining pre-fetched churn data with file health scores.
299pub(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
369/// Emit shallow-clone warnings (and the ownership-skew note) when relevant.
370fn 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
385/// Compile the bot-author glob set for ownership analysis, warning on a bad pattern.
386fn 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
400/// Load CODEOWNERS for ownership analysis, warning on a real parse error only.
401fn 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
418/// Read-only inputs for the per-file hotspot-entry loop.
419struct 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
431/// Build hotspot entries for eligible files; returns the entries plus the count
432/// of files excluded for not meeting the minimum-commits threshold.
433fn 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, // below min_commits=3
741                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, // highest density
843                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, // highest weighted
886                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, // higher but excluded
933                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, // below min_commits=5
961                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, // exactly at min_commits=3
1057                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}