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::{ClockProvenance, ClockSource, 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, and no cutoff is applied on the import path.
237fn imported_since() -> crate::churn::SinceDuration {
238    crate::churn::SinceDuration {
239        window: crate::churn::ChurnWindow::Imported,
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    warn_unpinned_clock(opts, churn_result.clock);
337
338    let min_commits = opts.min_commits.unwrap_or(3);
339    let (max_weighted, max_density) =
340        compute_normalization_maxima(file_scores, &churn_result.files, min_commits);
341
342    let ownership_cfg = &config.health.ownership;
343    let bot_globs_owned = load_ownership_bot_globs(opts, ownership_cfg);
344    let codeowners_owned = load_ownership_codeowners(opts, &config.root);
345    // Staleness is measured against the run clock, not the system clock, so
346    // `stale_days` and the thresholds it feeds (owner-active, drift minimum
347    // file age) do not flip between two runs over the same commit.
348    let now_secs = churn_result.clock.epoch_secs();
349    let ownership_ctx = bot_globs_owned.as_ref().map(|bot_globs| OwnershipContext {
350        author_pool: &churn_result.author_pool,
351        bot_globs,
352        codeowners: codeowners_owned.as_ref(),
353        email_mode: opts.ownership_emails.unwrap_or(ownership_cfg.email_mode),
354        now_secs,
355    });
356
357    let (mut hotspot_entries, files_excluded) = collect_hotspot_entries(&HotspotEntryCtx {
358        file_scores,
359        root: &config.root,
360        ignore_set,
361        ws_roots,
362        churn_files: &churn_result.files,
363        min_commits,
364        max_weighted,
365        max_density,
366        ownership_ctx: ownership_ctx.as_ref(),
367    });
368
369    hotspot_entries.sort_by(|a, b| {
370        b.score
371            .partial_cmp(&a.score)
372            .unwrap_or(std::cmp::Ordering::Equal)
373    });
374
375    let files_analyzed = hotspot_entries.len();
376    let summary = HotspotSummary {
377        since: since.display,
378        min_commits,
379        files_analyzed,
380        files_excluded,
381        shallow_clone,
382        clock: Some(clock_provenance(churn_result.clock)),
383    };
384
385    if let Some(top) = opts.top {
386        hotspot_entries.truncate(top);
387    }
388
389    (hotspot_entries, Some(summary))
390}
391
392/// Describe the run clock on the wire, so a JSON consumer can tell a
393/// reproducible churn number from a drifting one.
394///
395/// [`warn_unpinned_clock`] says the same thing on stderr, where `--quiet`
396/// removes it and a machine consumer never sees it at all.
397fn clock_provenance(clock: crate::clock::AnalysisClock) -> ClockProvenance {
398    ClockProvenance {
399        source: match clock.source() {
400            crate::clock::AnalysisClockSource::Environment => ClockSource::Environment,
401            crate::clock::AnalysisClockSource::HeadCommit => ClockSource::HeadCommit,
402            crate::clock::AnalysisClockSource::WallClock => ClockSource::WallClock,
403        },
404        epoch_secs: clock.epoch_secs(),
405        reproducible: clock.is_reproducible(),
406    }
407}
408
409/// Warn when churn numbers were measured against the wall clock.
410///
411/// The run clock normally comes from HEAD's committer timestamp, which makes
412/// recency weighting and `stale_days` reproducible for one commit. `git log`
413/// churn cannot reach this warning: if HEAD has no readable timestamp there is
414/// no git history to analyze either. Imported churn (`--churn-file`) can, and
415/// is the point of the warning, since it exists for projects whose history
416/// lives in a non-git VCS.
417fn warn_unpinned_clock(opts: &HealthOptions<'_>, clock: crate::clock::AnalysisClock) {
418    if !clock.is_reproducible() && !opts.quiet {
419        eprintln!(
420            "Warning: no commit timestamp available, so churn recency and \
421             ownership staleness were measured against the wall clock and will \
422             drift between runs. Set FALLOW_CLOCK_EPOCH to pin them."
423        );
424    }
425}
426
427/// Emit shallow-clone warnings (and the ownership-skew note) when relevant.
428fn warn_shallow_clone(opts: &HealthOptions<'_>, shallow_clone: bool) {
429    if shallow_clone && !opts.quiet {
430        eprintln!(
431            "Warning: shallow clone detected. Hotspot analysis may be incomplete. \
432             Use `git fetch --unshallow` for full history."
433        );
434        if opts.ownership {
435            eprintln!(
436                "Warning: shallow clones inflate single-author dominance, so \
437                 ownership signals will be skewed."
438            );
439        }
440    }
441}
442
443/// Compile the bot-author glob set for ownership analysis, warning on a bad pattern.
444fn load_ownership_bot_globs(
445    opts: &HealthOptions<'_>,
446    ownership_cfg: &fallow_config::OwnershipConfig,
447) -> Option<globset::GlobSet> {
448    opts.ownership.then(|| {
449        compile_bot_globs(&ownership_cfg.bot_patterns).unwrap_or_else(|e| {
450            if !opts.quiet {
451                eprintln!("Warning: invalid bot pattern in health.ownership.botPatterns: {e}");
452            }
453            globset::GlobSet::empty()
454        })
455    })
456}
457
458/// Load CODEOWNERS for ownership analysis, warning on a real parse error only.
459fn load_ownership_codeowners(
460    opts: &HealthOptions<'_>,
461    root: &std::path::Path,
462) -> Option<crate::codeowners::CodeOwners> {
463    opts.ownership
464        .then(|| match crate::codeowners::CodeOwners::load(root, None) {
465            Ok(co) => Some(co),
466            Err(e) => {
467                if !opts.quiet && !e.contains("no CODEOWNERS file found") {
468                    eprintln!("Warning: failed to parse CODEOWNERS: {e}");
469                }
470                None
471            }
472        })
473        .flatten()
474}
475
476/// Read-only inputs for the per-file hotspot-entry loop.
477struct HotspotEntryCtx<'a> {
478    file_scores: &'a [FileHealthScore],
479    root: &'a std::path::Path,
480    ignore_set: &'a globset::GlobSet,
481    ws_roots: Option<&'a [std::path::PathBuf]>,
482    churn_files: &'a rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn>,
483    min_commits: u32,
484    max_weighted: f64,
485    max_density: f64,
486    ownership_ctx: Option<&'a OwnershipContext<'a>>,
487}
488
489/// Build hotspot entries for eligible files; returns the entries plus the count
490/// of files excluded for not meeting the minimum-commits threshold.
491fn collect_hotspot_entries(ctx: &HotspotEntryCtx<'_>) -> (Vec<HotspotEntry>, usize) {
492    let mut hotspot_entries = Vec::new();
493    let mut files_excluded: usize = 0;
494
495    for score in ctx.file_scores {
496        if is_excluded_from_hotspots(&score.path, ctx.root, ctx.ignore_set, ctx.ws_roots) {
497            continue;
498        }
499
500        let Some(churn) = ctx.churn_files.get(&score.path) else {
501            continue;
502        };
503        if churn.commits < ctx.min_commits {
504            files_excluded += 1;
505            continue;
506        }
507
508        let relative = score.path.strip_prefix(ctx.root).unwrap_or(&score.path);
509        let ownership = ctx
510            .ownership_ctx
511            .and_then(|own| compute_ownership(churn, relative, own));
512
513        hotspot_entries.push(HotspotEntry {
514            path: score.path.clone(),
515            score: compute_hotspot_score(
516                churn.weighted_commits,
517                ctx.max_weighted,
518                score.complexity_density,
519                ctx.max_density,
520            ),
521            commits: churn.commits,
522            weighted_commits: churn.weighted_commits,
523            lines_added: churn.lines_added,
524            lines_deleted: churn.lines_deleted,
525            complexity_density: score.complexity_density,
526            fan_in: score.fan_in,
527            trend: churn.trend,
528            ownership,
529            is_test_path: is_test_path(relative),
530        });
531    }
532
533    (hotspot_entries, files_excluded)
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    fn target_churn_options(root: &std::path::Path) -> TargetChurnOptions<'_> {
541        TargetChurnOptions {
542            root,
543            target: std::path::Path::new("src/app.ts"),
544            cache_dir: root.join(".fallow"),
545            no_cache: true,
546            since: None,
547            min_commits: None,
548        }
549    }
550
551    fn churn_result(root: &std::path::Path, commits: u32) -> crate::churn::ChurnResult {
552        let path = root.join("src/app.ts");
553        let mut files = rustc_hash::FxHashMap::default();
554        files.insert(
555            path.clone(),
556            crate::churn::FileChurn {
557                path,
558                commits,
559                weighted_commits: 2.5,
560                lines_added: 20,
561                lines_deleted: 5,
562                trend: crate::churn::ChurnTrend::Accelerating,
563                authors: rustc_hash::FxHashMap::default(),
564            },
565        );
566        crate::churn::ChurnResult {
567            files,
568            shallow_clone: false,
569            author_pool: Vec::new(),
570            clock: crate::clock::AnalysisClock::pinned(1_788_782_400),
571        }
572    }
573
574    #[test]
575    fn target_churn_returns_only_the_requested_qualifying_file() {
576        let root = std::path::Path::new("/project");
577        let options = target_churn_options(root);
578
579        let outcome = analyze_target_churn_with(
580            &options,
581            |_| true,
582            |_, _, _, _| Some((churn_result(root, 4), false)),
583        )
584        .unwrap();
585
586        let TargetChurnOutcome::Found(evidence) = outcome else {
587            panic!("expected qualifying churn evidence");
588        };
589        assert_eq!(evidence.file.path, root.join("src/app.ts"));
590        assert_eq!(evidence.file.commits, 4);
591        assert_eq!(evidence.min_commits, 3);
592        assert_eq!(evidence.since.display, "6 months");
593    }
594
595    #[test]
596    fn target_churn_distinguishes_no_qualifying_history() {
597        let root = std::path::Path::new("/project");
598        let options = target_churn_options(root);
599
600        let outcome = analyze_target_churn_with(
601            &options,
602            |_| true,
603            |_, _, _, _| Some((churn_result(root, 2), false)),
604        )
605        .unwrap();
606
607        assert!(matches!(
608            outcome,
609            TargetChurnOutcome::NoQualifyingChurn {
610                observed_commits: Some(2),
611                min_commits: 3,
612                ..
613            }
614        ));
615    }
616
617    #[test]
618    fn target_churn_distinguishes_git_unavailable() {
619        let root = std::path::Path::new("/project");
620        let options = target_churn_options(root);
621
622        let outcome = analyze_target_churn_with(
623            &options,
624            |_| false,
625            |_, _, _, _| panic!("churn analysis must not run without git"),
626        )
627        .unwrap();
628
629        assert!(matches!(outcome, TargetChurnOutcome::Unavailable { .. }));
630    }
631
632    #[test]
633    fn target_churn_surfaces_analysis_failure() {
634        let root = std::path::Path::new("/project");
635        let options = target_churn_options(root);
636
637        let error = analyze_target_churn_with(&options, |_| true, |_, _, _, _| None)
638            .expect_err("failed git analysis must remain explicit");
639
640        assert!(error.contains("git churn analysis failed"));
641    }
642
643    #[test]
644    fn hotspot_score_both_maxima_zero() {
645        assert!((compute_hotspot_score(0.0, 0.0, 0.0, 0.0)).abs() < f64::EPSILON);
646    }
647
648    #[test]
649    fn hotspot_score_max_weighted_zero() {
650        assert!((compute_hotspot_score(5.0, 0.0, 0.5, 1.0)).abs() < f64::EPSILON);
651    }
652
653    #[test]
654    fn hotspot_score_max_density_zero() {
655        assert!((compute_hotspot_score(5.0, 10.0, 0.0, 0.0)).abs() < f64::EPSILON);
656    }
657
658    #[test]
659    fn hotspot_score_equal_normalization() {
660        let score = compute_hotspot_score(10.0, 10.0, 2.0, 2.0);
661        assert!((score - 100.0).abs() < f64::EPSILON);
662    }
663
664    #[test]
665    fn hotspot_score_half_values() {
666        let score = compute_hotspot_score(5.0, 10.0, 1.0, 2.0);
667        assert!((score - 25.0).abs() < f64::EPSILON);
668    }
669
670    #[test]
671    fn excluded_no_filters() {
672        let path = std::path::Path::new("/project/src/foo.ts");
673        let root = std::path::Path::new("/project");
674        let ignore_set = globset::GlobSet::empty();
675
676        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
677    }
678
679    #[test]
680    fn excluded_workspace_filter_mismatch() {
681        let path = std::path::Path::new("/project/packages/b/src/foo.ts");
682        let root = std::path::Path::new("/project");
683        let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
684        let ignore_set = globset::GlobSet::empty();
685
686        assert!(is_excluded_from_hotspots(
687            path,
688            root,
689            &ignore_set,
690            Some(&ws_roots)
691        ));
692    }
693
694    #[test]
695    fn excluded_workspace_filter_match() {
696        let path = std::path::Path::new("/project/packages/a/src/foo.ts");
697        let root = std::path::Path::new("/project");
698        let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
699        let ignore_set = globset::GlobSet::empty();
700
701        assert!(!is_excluded_from_hotspots(
702            path,
703            root,
704            &ignore_set,
705            Some(&ws_roots)
706        ));
707    }
708
709    #[test]
710    fn excluded_matching_glob() {
711        let path = std::path::Path::new("/project/src/generated/types.ts");
712        let root = std::path::Path::new("/project");
713        let mut builder = globset::GlobSetBuilder::new();
714        builder.add(globset::Glob::new("src/generated/**").unwrap());
715        let ignore_set = builder.build().unwrap();
716
717        assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
718    }
719
720    #[test]
721    fn excluded_non_matching_glob() {
722        let path = std::path::Path::new("/project/src/components/Button.tsx");
723        let root = std::path::Path::new("/project");
724        let mut builder = globset::GlobSetBuilder::new();
725        builder.add(globset::Glob::new("src/generated/**").unwrap());
726        let ignore_set = builder.build().unwrap();
727
728        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
729    }
730
731    #[test]
732    fn normalization_maxima_empty_input() {
733        let scores: Vec<FileHealthScore> = vec![];
734        let churn_files = rustc_hash::FxHashMap::default();
735
736        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
737        assert!((max_w).abs() < f64::EPSILON);
738        assert!((max_d).abs() < f64::EPSILON);
739    }
740
741    #[test]
742    fn normalization_maxima_single_file() {
743        let scores = vec![FileHealthScore {
744            path: std::path::PathBuf::from("/src/foo.ts"),
745            fan_in: 0,
746            fan_out: 0,
747            dead_code_ratio: 0.0,
748            complexity_density: 0.75,
749            maintainability_index: 80.0,
750            total_cyclomatic: 15,
751            total_cognitive: 10,
752            function_count: 3,
753            lines: 20,
754            crap_max: 0.0,
755            crap_above_threshold: 0,
756            crap_exempted: 0,
757            crap_effective_threshold: None,
758        }];
759        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
760            rustc_hash::FxHashMap::default();
761        churn_files.insert(
762            std::path::PathBuf::from("/src/foo.ts"),
763            crate::churn::FileChurn {
764                path: std::path::PathBuf::from("/src/foo.ts"),
765                commits: 5,
766                weighted_commits: 4.2,
767                lines_added: 100,
768                lines_deleted: 20,
769                trend: crate::churn::ChurnTrend::Stable,
770                authors: rustc_hash::FxHashMap::default(),
771            },
772        );
773
774        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
775        assert!((max_w - 4.2).abs() < f64::EPSILON);
776        assert!((max_d - 0.75).abs() < f64::EPSILON);
777    }
778
779    /// Hotspot ranking is churn times complexity density and consults no CRAP
780    /// thresholds, so an exemption that clears the risk tag and the coverage
781    /// target must leave hotspot inputs untouched (issue #2228).
782    #[test]
783    fn normalization_maxima_ignore_crap_exemption_fields() {
784        let base = FileHealthScore {
785            path: std::path::PathBuf::from("/src/foo.ts"),
786            fan_in: 0,
787            fan_out: 0,
788            dead_code_ratio: 0.0,
789            complexity_density: 0.75,
790            maintainability_index: 80.0,
791            total_cyclomatic: 15,
792            total_cognitive: 10,
793            function_count: 3,
794            lines: 20,
795            crap_max: 110.0,
796            crap_above_threshold: 2,
797            crap_exempted: 0,
798            crap_effective_threshold: None,
799        };
800        let exempt = FileHealthScore {
801            crap_above_threshold: 0,
802            crap_exempted: 2,
803            crap_effective_threshold: Some(500.0),
804            ..base.clone()
805        };
806        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
807            rustc_hash::FxHashMap::default();
808        churn_files.insert(
809            std::path::PathBuf::from("/src/foo.ts"),
810            crate::churn::FileChurn {
811                path: std::path::PathBuf::from("/src/foo.ts"),
812                commits: 5,
813                weighted_commits: 4.2,
814                lines_added: 100,
815                lines_deleted: 20,
816                trend: crate::churn::ChurnTrend::Stable,
817                authors: rustc_hash::FxHashMap::default(),
818            },
819        );
820
821        let flagged = compute_normalization_maxima(&[base], &churn_files, 3);
822        let exempted = compute_normalization_maxima(&[exempt], &churn_files, 3);
823        assert_eq!(flagged, exempted);
824    }
825
826    #[test]
827    fn normalization_maxima_below_min_commits() {
828        let scores = vec![FileHealthScore {
829            path: std::path::PathBuf::from("/src/foo.ts"),
830            fan_in: 0,
831            fan_out: 0,
832            dead_code_ratio: 0.0,
833            complexity_density: 0.75,
834            maintainability_index: 80.0,
835            total_cyclomatic: 15,
836            total_cognitive: 10,
837            function_count: 3,
838            lines: 20,
839            crap_max: 0.0,
840            crap_above_threshold: 0,
841            crap_exempted: 0,
842            crap_effective_threshold: None,
843        }];
844        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
845            rustc_hash::FxHashMap::default();
846        churn_files.insert(
847            std::path::PathBuf::from("/src/foo.ts"),
848            crate::churn::FileChurn {
849                path: std::path::PathBuf::from("/src/foo.ts"),
850                commits: 2, // below min_commits=3
851                weighted_commits: 4.2,
852                lines_added: 100,
853                lines_deleted: 20,
854                trend: crate::churn::ChurnTrend::Stable,
855                authors: rustc_hash::FxHashMap::default(),
856            },
857        );
858
859        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
860        assert!((max_w).abs() < f64::EPSILON);
861        assert!((max_d).abs() < f64::EPSILON);
862    }
863
864    #[test]
865    fn normalization_maxima_all_zeros() {
866        let scores = vec![FileHealthScore {
867            path: std::path::PathBuf::from("/src/foo.ts"),
868            fan_in: 0,
869            fan_out: 0,
870            dead_code_ratio: 0.0,
871            complexity_density: 0.0,
872            maintainability_index: 100.0,
873            total_cyclomatic: 0,
874            total_cognitive: 0,
875            function_count: 1,
876            lines: 10,
877            crap_max: 0.0,
878            crap_above_threshold: 0,
879            crap_exempted: 0,
880            crap_effective_threshold: None,
881        }];
882        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
883            rustc_hash::FxHashMap::default();
884        churn_files.insert(
885            std::path::PathBuf::from("/src/foo.ts"),
886            crate::churn::FileChurn {
887                path: std::path::PathBuf::from("/src/foo.ts"),
888                commits: 5,
889                weighted_commits: 0.0,
890                lines_added: 0,
891                lines_deleted: 0,
892                trend: crate::churn::ChurnTrend::Stable,
893                authors: rustc_hash::FxHashMap::default(),
894            },
895        );
896
897        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
898        assert!((max_w).abs() < f64::EPSILON);
899        assert!((max_d).abs() < f64::EPSILON);
900    }
901
902    #[test]
903    fn hotspot_score_high_churn_low_complexity() {
904        let score = compute_hotspot_score(10.0, 10.0, 0.1, 1.0);
905        assert!((score - 10.0).abs() < f64::EPSILON);
906    }
907
908    #[test]
909    fn hotspot_score_low_churn_high_complexity() {
910        let score = compute_hotspot_score(1.0, 10.0, 2.0, 2.0);
911        assert!((score - 10.0).abs() < f64::EPSILON);
912    }
913
914    #[test]
915    fn hotspot_score_rounding() {
916        let score = compute_hotspot_score(1.0, 3.0, 1.0, 3.0);
917        assert!((score - 11.1).abs() < f64::EPSILON);
918    }
919
920    #[test]
921    fn hotspot_score_very_small_values() {
922        let score = compute_hotspot_score(0.01, 100.0, 0.001, 10.0);
923        assert!((score).abs() < 0.1);
924    }
925
926    #[test]
927    fn hotspot_score_weighted_exceeds_max() {
928        let score = compute_hotspot_score(15.0, 10.0, 1.0, 2.0);
929        assert!((score - 75.0).abs() < f64::EPSILON);
930    }
931
932    #[test]
933    fn normalization_maxima_multiple_files_picks_max() {
934        let scores = vec![
935            FileHealthScore {
936                path: std::path::PathBuf::from("/src/a.ts"),
937                fan_in: 0,
938                fan_out: 0,
939                dead_code_ratio: 0.0,
940                complexity_density: 0.5,
941                maintainability_index: 80.0,
942                total_cyclomatic: 10,
943                total_cognitive: 5,
944                function_count: 2,
945                lines: 50,
946                crap_max: 0.0,
947                crap_above_threshold: 0,
948                crap_exempted: 0,
949                crap_effective_threshold: None,
950            },
951            FileHealthScore {
952                path: std::path::PathBuf::from("/src/b.ts"),
953                fan_in: 0,
954                fan_out: 0,
955                dead_code_ratio: 0.0,
956                complexity_density: 1.2, // highest density
957                maintainability_index: 60.0,
958                total_cyclomatic: 30,
959                total_cognitive: 20,
960                function_count: 5,
961                lines: 100,
962                crap_max: 0.0,
963                crap_above_threshold: 0,
964                crap_exempted: 0,
965                crap_effective_threshold: None,
966            },
967            FileHealthScore {
968                path: std::path::PathBuf::from("/src/c.ts"),
969                fan_in: 0,
970                fan_out: 0,
971                dead_code_ratio: 0.0,
972                complexity_density: 0.8,
973                maintainability_index: 70.0,
974                total_cyclomatic: 20,
975                total_cognitive: 15,
976                function_count: 4,
977                lines: 80,
978                crap_max: 0.0,
979                crap_above_threshold: 0,
980                crap_exempted: 0,
981                crap_effective_threshold: None,
982            },
983        ];
984        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
985            rustc_hash::FxHashMap::default();
986        churn_files.insert(
987            std::path::PathBuf::from("/src/a.ts"),
988            crate::churn::FileChurn {
989                path: std::path::PathBuf::from("/src/a.ts"),
990                commits: 5,
991                weighted_commits: 3.0,
992                lines_added: 50,
993                lines_deleted: 10,
994                trend: crate::churn::ChurnTrend::Stable,
995                authors: rustc_hash::FxHashMap::default(),
996            },
997        );
998        churn_files.insert(
999            std::path::PathBuf::from("/src/b.ts"),
1000            crate::churn::FileChurn {
1001                path: std::path::PathBuf::from("/src/b.ts"),
1002                commits: 10,
1003                weighted_commits: 8.5, // highest weighted
1004                lines_added: 200,
1005                lines_deleted: 50,
1006                trend: crate::churn::ChurnTrend::Accelerating,
1007                authors: rustc_hash::FxHashMap::default(),
1008            },
1009        );
1010        churn_files.insert(
1011            std::path::PathBuf::from("/src/c.ts"),
1012            crate::churn::FileChurn {
1013                path: std::path::PathBuf::from("/src/c.ts"),
1014                commits: 7,
1015                weighted_commits: 5.0,
1016                lines_added: 100,
1017                lines_deleted: 30,
1018                trend: crate::churn::ChurnTrend::Cooling,
1019                authors: rustc_hash::FxHashMap::default(),
1020            },
1021        );
1022
1023        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
1024        assert!((max_w - 8.5).abs() < f64::EPSILON);
1025        assert!((max_d - 1.2).abs() < f64::EPSILON);
1026    }
1027
1028    #[test]
1029    fn normalization_maxima_mixed_above_and_below_threshold() {
1030        let scores = vec![
1031            FileHealthScore {
1032                path: std::path::PathBuf::from("/src/frequent.ts"),
1033                fan_in: 0,
1034                fan_out: 0,
1035                dead_code_ratio: 0.0,
1036                complexity_density: 0.4,
1037                maintainability_index: 85.0,
1038                total_cyclomatic: 8,
1039                total_cognitive: 4,
1040                function_count: 2,
1041                lines: 40,
1042                crap_max: 0.0,
1043                crap_above_threshold: 0,
1044                crap_exempted: 0,
1045                crap_effective_threshold: None,
1046            },
1047            FileHealthScore {
1048                path: std::path::PathBuf::from("/src/rare.ts"),
1049                fan_in: 0,
1050                fan_out: 0,
1051                dead_code_ratio: 0.0,
1052                complexity_density: 2.0, // higher but excluded
1053                maintainability_index: 50.0,
1054                total_cyclomatic: 40,
1055                total_cognitive: 30,
1056                function_count: 8,
1057                lines: 200,
1058                crap_max: 0.0,
1059                crap_above_threshold: 0,
1060                crap_exempted: 0,
1061                crap_effective_threshold: None,
1062            },
1063        ];
1064        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1065            rustc_hash::FxHashMap::default();
1066        churn_files.insert(
1067            std::path::PathBuf::from("/src/frequent.ts"),
1068            crate::churn::FileChurn {
1069                path: std::path::PathBuf::from("/src/frequent.ts"),
1070                commits: 10,
1071                weighted_commits: 7.0,
1072                lines_added: 150,
1073                lines_deleted: 40,
1074                trend: crate::churn::ChurnTrend::Stable,
1075                authors: rustc_hash::FxHashMap::default(),
1076            },
1077        );
1078        churn_files.insert(
1079            std::path::PathBuf::from("/src/rare.ts"),
1080            crate::churn::FileChurn {
1081                path: std::path::PathBuf::from("/src/rare.ts"),
1082                commits: 1, // below min_commits=5
1083                weighted_commits: 0.9,
1084                lines_added: 10,
1085                lines_deleted: 2,
1086                trend: crate::churn::ChurnTrend::Cooling,
1087                authors: rustc_hash::FxHashMap::default(),
1088            },
1089        );
1090
1091        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 5);
1092        assert!((max_w - 7.0).abs() < f64::EPSILON);
1093        assert!((max_d - 0.4).abs() < f64::EPSILON);
1094    }
1095
1096    #[test]
1097    fn normalization_maxima_file_score_without_churn() {
1098        let scores = vec![FileHealthScore {
1099            path: std::path::PathBuf::from("/src/no_churn.ts"),
1100            fan_in: 0,
1101            fan_out: 0,
1102            dead_code_ratio: 0.0,
1103            complexity_density: 5.0,
1104            maintainability_index: 30.0,
1105            total_cyclomatic: 100,
1106            total_cognitive: 80,
1107            function_count: 20,
1108            lines: 500,
1109            crap_max: 0.0,
1110            crap_above_threshold: 0,
1111            crap_exempted: 0,
1112            crap_effective_threshold: None,
1113        }];
1114        let churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1115            rustc_hash::FxHashMap::default();
1116
1117        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 1);
1118        assert!((max_w).abs() < f64::EPSILON);
1119        assert!((max_d).abs() < f64::EPSILON);
1120    }
1121
1122    #[test]
1123    fn normalization_maxima_min_commits_zero() {
1124        let scores = vec![FileHealthScore {
1125            path: std::path::PathBuf::from("/src/foo.ts"),
1126            fan_in: 0,
1127            fan_out: 0,
1128            dead_code_ratio: 0.0,
1129            complexity_density: 0.3,
1130            maintainability_index: 90.0,
1131            total_cyclomatic: 3,
1132            total_cognitive: 2,
1133            function_count: 1,
1134            lines: 10,
1135            crap_max: 0.0,
1136            crap_above_threshold: 0,
1137            crap_exempted: 0,
1138            crap_effective_threshold: None,
1139        }];
1140        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1141            rustc_hash::FxHashMap::default();
1142        churn_files.insert(
1143            std::path::PathBuf::from("/src/foo.ts"),
1144            crate::churn::FileChurn {
1145                path: std::path::PathBuf::from("/src/foo.ts"),
1146                commits: 0,
1147                weighted_commits: 0.0,
1148                lines_added: 0,
1149                lines_deleted: 0,
1150                trend: crate::churn::ChurnTrend::Stable,
1151                authors: rustc_hash::FxHashMap::default(),
1152            },
1153        );
1154
1155        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 0);
1156        assert!((max_w).abs() < f64::EPSILON);
1157        assert!((max_d - 0.3).abs() < f64::EPSILON);
1158    }
1159
1160    #[test]
1161    fn normalization_maxima_exactly_at_threshold() {
1162        let scores = vec![FileHealthScore {
1163            path: std::path::PathBuf::from("/src/foo.ts"),
1164            fan_in: 0,
1165            fan_out: 0,
1166            dead_code_ratio: 0.0,
1167            complexity_density: 1.5,
1168            maintainability_index: 65.0,
1169            total_cyclomatic: 25,
1170            total_cognitive: 18,
1171            function_count: 5,
1172            lines: 120,
1173            crap_max: 0.0,
1174            crap_above_threshold: 0,
1175            crap_exempted: 0,
1176            crap_effective_threshold: None,
1177        }];
1178        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1179            rustc_hash::FxHashMap::default();
1180        churn_files.insert(
1181            std::path::PathBuf::from("/src/foo.ts"),
1182            crate::churn::FileChurn {
1183                path: std::path::PathBuf::from("/src/foo.ts"),
1184                commits: 3, // exactly at min_commits=3
1185                weighted_commits: 2.8,
1186                lines_added: 60,
1187                lines_deleted: 15,
1188                trend: crate::churn::ChurnTrend::Stable,
1189                authors: rustc_hash::FxHashMap::default(),
1190            },
1191        );
1192
1193        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
1194        assert!((max_w - 2.8).abs() < f64::EPSILON);
1195        assert!((max_d - 1.5).abs() < f64::EPSILON);
1196    }
1197
1198    #[test]
1199    fn excluded_workspace_and_glob_combined() {
1200        let path = std::path::Path::new("/project/packages/a/src/generated/types.ts");
1201        let root = std::path::Path::new("/project");
1202        let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
1203        let mut builder = globset::GlobSetBuilder::new();
1204        builder.add(globset::Glob::new("**/generated/**").unwrap());
1205        let ignore_set = builder.build().unwrap();
1206
1207        assert!(is_excluded_from_hotspots(
1208            path,
1209            root,
1210            &ignore_set,
1211            Some(&ws_roots)
1212        ));
1213    }
1214
1215    #[test]
1216    fn excluded_workspace_match_but_glob_no_match() {
1217        let path = std::path::Path::new("/project/packages/a/src/index.ts");
1218        let root = std::path::Path::new("/project");
1219        let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
1220        let mut builder = globset::GlobSetBuilder::new();
1221        builder.add(globset::Glob::new("**/generated/**").unwrap());
1222        let ignore_set = builder.build().unwrap();
1223
1224        assert!(!is_excluded_from_hotspots(
1225            path,
1226            root,
1227            &ignore_set,
1228            Some(&ws_roots)
1229        ));
1230    }
1231
1232    #[test]
1233    fn excluded_path_equals_root() {
1234        let path = std::path::Path::new("/project");
1235        let root = std::path::Path::new("/project");
1236        let ignore_set = globset::GlobSet::empty();
1237
1238        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1239    }
1240
1241    #[test]
1242    fn excluded_path_outside_root() {
1243        let path = std::path::Path::new("/other/src/foo.ts");
1244        let root = std::path::Path::new("/project");
1245        let mut builder = globset::GlobSetBuilder::new();
1246        builder.add(globset::Glob::new("src/foo.ts").unwrap());
1247        let ignore_set = builder.build().unwrap();
1248
1249        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1250    }
1251
1252    #[test]
1253    fn excluded_multiple_globs_first_matches() {
1254        let path = std::path::Path::new("/project/dist/bundle.js");
1255        let root = std::path::Path::new("/project");
1256        let mut builder = globset::GlobSetBuilder::new();
1257        builder.add(globset::Glob::new("dist/**").unwrap());
1258        builder.add(globset::Glob::new("node_modules/**").unwrap());
1259        let ignore_set = builder.build().unwrap();
1260
1261        assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
1262    }
1263
1264    #[test]
1265    fn excluded_multiple_globs_second_matches() {
1266        let path = std::path::Path::new("/project/node_modules/lodash/index.js");
1267        let root = std::path::Path::new("/project");
1268        let mut builder = globset::GlobSetBuilder::new();
1269        builder.add(globset::Glob::new("dist/**").unwrap());
1270        builder.add(globset::Glob::new("node_modules/**").unwrap());
1271        let ignore_set = builder.build().unwrap();
1272
1273        assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
1274    }
1275
1276    #[test]
1277    fn excluded_multiple_globs_none_matches() {
1278        let path = std::path::Path::new("/project/src/app.ts");
1279        let root = std::path::Path::new("/project");
1280        let mut builder = globset::GlobSetBuilder::new();
1281        builder.add(globset::Glob::new("dist/**").unwrap());
1282        builder.add(globset::Glob::new("node_modules/**").unwrap());
1283        let ignore_set = builder.build().unwrap();
1284
1285        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1286    }
1287}