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.
14fn 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    /// Project root containing the git repository.
38    pub root: &'a std::path::Path,
39    /// Normalized project-relative path of the file to fetch churn for.
40    pub target: &'a std::path::Path,
41    /// Directory holding the shared hotspot churn cache.
42    pub cache_dir: std::path::PathBuf,
43    /// Bypass the churn cache and re-run `git log`.
44    pub no_cache: bool,
45    /// Churn lookback window (`90d`, `6m`, `1y`, or an ISO date); `None` uses
46    /// the default window.
47    pub since: Option<&'a str>,
48    /// Minimum commit count for the target to qualify as churn evidence.
49    pub min_commits: Option<u32>,
50}
51
52/// Qualifying target-level churn returned by the focused health API.
53#[derive(Debug)]
54pub struct TargetChurnEvidence {
55    /// Per-file churn metrics from git history.
56    pub file: crate::churn::FileChurn,
57    /// The lookback window the churn was measured over.
58    pub since: crate::churn::SinceDuration,
59    /// The commit-count threshold the target met to qualify.
60    pub min_commits: u32,
61    /// True when the repository is a shallow clone, so churn counts may
62    /// undercount actual history.
63    pub shallow_clone: bool,
64}
65
66/// Result states that do not represent a churn-analysis failure.
67#[derive(Debug)]
68pub enum TargetChurnOutcome {
69    /// The target met the churn threshold; evidence is attached.
70    Found(TargetChurnEvidence),
71    /// The target was analyzed but did not meet the commit threshold.
72    NoQualifyingChurn {
73        /// Commits observed for the target, when it appeared in history.
74        observed_commits: Option<u32>,
75        /// The lookback window the churn was measured over.
76        since: crate::churn::SinceDuration,
77        /// The commit-count threshold that was not met.
78        min_commits: u32,
79        /// True when the repository is a shallow clone, so churn counts may
80        /// undercount actual history.
81        shallow_clone: bool,
82    },
83    /// Churn could not be measured at all (for example: not a git repository).
84    Unavailable {
85        /// Human-readable reason churn analysis was unavailable.
86        message: String,
87    },
88}
89
90/// Analyze git churn for one normalized project-relative target.
91///
92/// The call is intentionally independent of the full health pipeline. Missing
93/// git is an explicit unavailable outcome, while a failed git analysis remains
94/// an error so callers can preserve partial-evidence warnings.
95pub 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
156/// Validate git prerequisites and return churn data for hotspot analysis.
157///
158/// Uses disk cache when available. Returns `None` if the repo is missing,
159/// `--since` is malformed, or git analysis fails. A missing git repo is treated
160/// as unavailable data rather than a hard error so combined-mode `--format
161/// json` never emits a second JSON document alongside the combined report
162/// (#294); a non-fatal note goes to stderr unless `--quiet` is set.
163pub(super) fn fetch_churn_data(
164    opts: &HealthOptions<'_>,
165    cache_dir: &std::path::Path,
166) -> Option<ChurnFetchResult> {
167    // `--churn-file` imports change history from a normalized JSON file and
168    // bypasses git entirely, so projects on a non-git VCS (Yandex Arc,
169    // Mercurial, Perforce) still get hotspots / ownership. The file is
170    // authoritative for the analysis window, so `--since` is NOT applied to
171    // imported events; it would only mislabel the header, hence `imported_since`.
172    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                // The up-front `health::validate_churn_file` gate already
180                // emitted this error and aborted with exit 2 for a malformed
181                // file, so reaching here means the file changed between the
182                // gate and this re-read (a TOCTOU race). Skip silently rather
183                // than emit a SECOND error document, which would break the
184                // single-document `--format json` contract (#294).
185                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        // A malformed `--since` degrades to "no churn, continue" like the
207        // missing-git-repo branch above: route the diagnostic to `tracing` and
208        // emit NO second JSON document, preserving the single-document
209        // `--format json` contract (#294).
210        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
234/// Header label for imported churn (`--churn-file`). The imported window is
235/// whatever the wrapper exported, so reusing the `--since` duration ("since 6
236/// months") would misdescribe it. `git_after` is unused on the import path.
237fn imported_since() -> crate::churn::SinceDuration {
238    crate::churn::SinceDuration {
239        git_after: String::new(),
240        display: "imported churn".to_string(),
241    }
242}
243
244/// Find the maximum weighted-commits and complexity-density across eligible files.
245///
246/// Used to normalize hotspot scores into the 0-100 range.
247fn 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
265/// Check whether a file should be excluded from hotspot results
266/// based on workspace filter and ignore patterns.
267fn 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
287/// Compute a normalized hotspot score from churn and complexity data.
288///
289/// Both inputs are normalized against their respective maxima so the result
290/// falls in the 0-100 range (rounded to one decimal).
291fn 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
319/// Compute hotspot entries by combining pre-fetched churn data with file health scores.
320pub(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
390/// Emit shallow-clone warnings (and the ownership-skew note) when relevant.
391fn 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
406/// Compile the bot-author glob set for ownership analysis, warning on a bad pattern.
407fn 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
421/// Load CODEOWNERS for ownership analysis, warning on a real parse error only.
422fn 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
439/// Read-only inputs for the per-file hotspot-entry loop.
440struct 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
452/// Build hotspot entries for eligible files; returns the entries plus the count
453/// of files excluded for not meeting the minimum-commits threshold.
454fn 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, // below min_commits=3
762                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, // highest density
864                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, // highest weighted
907                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, // higher but excluded
954                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, // below min_commits=5
982                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, // exactly at min_commits=3
1078                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}