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};
10use crate::test_paths::is_test_path;
11
12/// Result of fetching churn data, including cache hit/miss info and timing.
13pub struct ChurnFetchResult {
14    pub result: crate::churn::ChurnResult,
15    pub since: crate::churn::SinceDuration,
16    /// Window of [`crate::churn::ChurnResult::commits`], which change
17    /// coupling and change dispersion read. Without `--since` it is wider
18    /// than `since`.
19    pub history: crate::churn::SinceDuration,
20    pub cache_hit: bool,
21    pub git_log_ms: f64,
22}
23
24/// Focused inputs for target-level churn evidence.
25///
26/// This reuses the health hotspot churn cache without running project parsing,
27/// file scoring, or any other health section.
28pub struct TargetChurnOptions<'a> {
29    /// Project root containing the git repository.
30    pub root: &'a std::path::Path,
31    /// Normalized project-relative path of the file to fetch churn for.
32    pub target: &'a std::path::Path,
33    /// Directory holding the shared hotspot churn cache.
34    pub cache_dir: std::path::PathBuf,
35    /// Bypass the churn cache and re-run `git log`.
36    pub no_cache: bool,
37    /// Churn lookback window (`90d`, `6m`, `1y`, or an ISO date); `None` uses
38    /// the default window.
39    pub since: Option<&'a str>,
40    /// Minimum commit count for the target to qualify as churn evidence.
41    pub min_commits: Option<u32>,
42}
43
44/// Qualifying target-level churn returned by the focused health API.
45#[derive(Debug)]
46pub struct TargetChurnEvidence {
47    /// Per-file churn metrics from git history.
48    pub file: crate::churn::FileChurn,
49    /// The lookback window the churn was measured over.
50    pub since: crate::churn::SinceDuration,
51    /// The commit-count threshold the target met to qualify.
52    pub min_commits: u32,
53    /// True when the repository is a shallow clone, so churn counts may
54    /// undercount actual history.
55    pub shallow_clone: bool,
56}
57
58/// Result states that do not represent a churn-analysis failure.
59#[derive(Debug)]
60pub enum TargetChurnOutcome {
61    /// The target met the churn threshold; evidence is attached.
62    Found(TargetChurnEvidence),
63    /// The target was analyzed but did not meet the commit threshold.
64    NoQualifyingChurn {
65        /// Commits observed for the target, when it appeared in history.
66        observed_commits: Option<u32>,
67        /// The lookback window the churn was measured over.
68        since: crate::churn::SinceDuration,
69        /// The commit-count threshold that was not met.
70        min_commits: u32,
71        /// True when the repository is a shallow clone, so churn counts may
72        /// undercount actual history.
73        shallow_clone: bool,
74    },
75    /// Churn could not be measured at all (for example: not a git repository).
76    Unavailable {
77        /// Human-readable reason churn analysis was unavailable.
78        message: String,
79    },
80}
81
82/// Analyze git churn for one normalized project-relative target.
83///
84/// The call is intentionally independent of the full health pipeline. Missing
85/// git is an explicit unavailable outcome, while a failed git analysis remains
86/// an error so callers can preserve partial-evidence warnings.
87pub fn analyze_target_churn(
88    options: &TargetChurnOptions<'_>,
89) -> Result<TargetChurnOutcome, String> {
90    analyze_target_churn_with(
91        options,
92        crate::churn::is_git_repo,
93        crate::churn::analyze_churn_cached,
94    )
95}
96
97fn analyze_target_churn_with<GitAvailable, Analyze>(
98    options: &TargetChurnOptions<'_>,
99    git_available: GitAvailable,
100    analyze: Analyze,
101) -> Result<TargetChurnOutcome, String>
102where
103    GitAvailable: FnOnce(&std::path::Path) -> bool,
104    Analyze: FnOnce(
105        &std::path::Path,
106        &crate::churn::SinceDuration,
107        &std::path::Path,
108        bool,
109    ) -> Option<(crate::churn::ChurnResult, bool)>,
110{
111    if !git_available(options.root) {
112        return Ok(TargetChurnOutcome::Unavailable {
113            message: "git repository unavailable at project root".to_string(),
114        });
115    }
116
117    let since = crate::churn::parse_since(options.since.unwrap_or("6m"))?;
118    let min_commits = options.min_commits.unwrap_or(3);
119    let Some((result, _cache_hit)) =
120        analyze(options.root, &since, &options.cache_dir, options.no_cache)
121    else {
122        return Err("git churn analysis failed".to_string());
123    };
124    let shallow_clone = result.shallow_clone;
125    let target = options.root.join(options.target);
126    let file = result.files.get(&target).cloned();
127
128    let observed_commits = file.as_ref().map(|file| file.commits);
129    if let Some(file) = file
130        && file.commits >= min_commits
131    {
132        return Ok(TargetChurnOutcome::Found(TargetChurnEvidence {
133            file,
134            since,
135            min_commits,
136            shallow_clone,
137        }));
138    }
139
140    Ok(TargetChurnOutcome::NoQualifyingChurn {
141        observed_commits,
142        since,
143        min_commits,
144        shallow_clone,
145    })
146}
147
148/// Validate git prerequisites and return churn data for hotspot analysis.
149///
150/// Uses disk cache when available. Returns `None` if the repo is missing,
151/// `--since` is malformed, or git analysis fails. A missing git repo is treated
152/// as unavailable data rather than a hard error so combined-mode `--format
153/// json` never emits a second JSON document alongside the combined report
154/// (#294); a non-fatal note goes to stderr unless `--quiet` is set.
155pub(super) fn fetch_churn_data(
156    opts: &HealthOptions<'_>,
157    cache_dir: &std::path::Path,
158) -> Option<ChurnFetchResult> {
159    // `--churn-file` imports change history from a normalized JSON file and
160    // bypasses git entirely, so projects on a non-git VCS (Yandex Arc,
161    // Mercurial, Perforce) still get hotspots / ownership. The file is
162    // authoritative for the analysis window, so `--since` is NOT applied to
163    // imported events; it would only mislabel the header, hence `imported_since`.
164    if let Some(churn_file) = opts.churn_file {
165        let resolved =
166            crate::health::scoring::resolve_relative_to_root(churn_file, Some(opts.root));
167        let t = std::time::Instant::now();
168        let result = match crate::churn::analyze_churn_from_file(&resolved, opts.root) {
169            Ok(r) => r,
170            Err(e) => {
171                // The up-front `health::validate_churn_file` gate already
172                // emitted this error and aborted with exit 2 for a malformed
173                // file, so reaching here means the file changed between the
174                // gate and this re-read (a TOCTOU race). Skip silently rather
175                // than emit a SECOND error document, which would break the
176                // single-document `--format json` contract (#294).
177                tracing::warn!("churn file became unreadable after validation: {e}");
178                // The diagnostic is the channel a `--quiet --format json`
179                // consumer has, and a silent skip there reports the hotspot
180                // sections as zero rather than as unmeasured (issue #2734).
181                super::diagnostics::record_health_diagnostic(
182                    opts.root,
183                    Some(&resolved),
184                    fallow_types::workspace::WorkspaceDiagnosticKind::HotspotsSkipped {
185                        cause: "churn-file-unreadable".to_owned(),
186                    },
187                );
188                return None;
189            }
190        };
191        return Some(ChurnFetchResult {
192            result,
193            since: imported_since(),
194            history: imported_since(),
195            cache_hit: false,
196            git_log_ms: t.elapsed().as_secs_f64() * 1000.0,
197        });
198    }
199
200    if !crate::churn::is_git_repo(opts.root) {
201        if !opts.quiet {
202            eprintln!("note: hotspot analysis skipped: no git repository found at project root");
203        }
204        // Outside the quiet guard: the note is for a human, the diagnostic is
205        // for the consumer that runs fallow with `--quiet` (issue #2689).
206        super::diagnostics::record_health_diagnostic(
207            opts.root,
208            None,
209            fallow_types::workspace::WorkspaceDiagnosticKind::HotspotsSkipped {
210                cause: "not-a-repository".to_owned(),
211            },
212        );
213        return None;
214    }
215
216    let since_input = opts.since.unwrap_or(DEFAULT_HOTSPOT_WINDOW);
217    if let Err(e) = crate::validate::validate_no_control_chars(since_input, "--since") {
218        // A malformed `--since` degrades to "no churn, continue" like the
219        // missing-git-repo branch above: route the error text to `tracing` and
220        // emit NO second JSON document, preserving the single-document
221        // `--format json` contract (#294). The diagnostic carries the cause, so
222        // a consumer that never sees stderr still reads the hotspot sections as
223        // unmeasured rather than as zero (issue #2734).
224        tracing::warn!("hotspot analysis skipped: {e}");
225        record_invalid_since(opts.root);
226        return None;
227    }
228    let since = match crate::churn::parse_since(since_input) {
229        Ok(s) => s,
230        Err(e) => {
231            tracing::warn!("hotspot analysis skipped: invalid --since: {e}");
232            record_invalid_since(opts.root);
233            return None;
234        }
235    };
236
237    // Change coupling and change dispersion need more commits than hotspots,
238    // so without `--since` they read a wider window. The same `git log` pass
239    // feeds both windows.
240    let history = if opts.since.is_some() {
241        since.clone()
242    } else {
243        crate::churn::SinceDuration::relative(
244            DEFAULT_HISTORY_MONTHS,
245            crate::churn::ChurnWindowUnit::Months,
246            format!("{DEFAULT_HISTORY_MONTHS} months"),
247        )
248    };
249
250    let t = std::time::Instant::now();
251    let Some((churn_result, cache_hit)) = crate::churn::analyze_churn_cached_with_history(
252        opts.root,
253        &since,
254        &history,
255        cache_dir,
256        opts.no_cache,
257    ) else {
258        record_unborn_head(opts);
259        return None;
260    };
261    let git_log_ms = t.elapsed().as_secs_f64() * 1000.0;
262
263    Some(ChurnFetchResult {
264        result: churn_result,
265        since,
266        history,
267        cache_hit,
268        git_log_ms,
269    })
270}
271
272/// Hotspot window when `--since` is absent.
273const DEFAULT_HOTSPOT_WINDOW: &str = "6m";
274
275/// Change coupling and change dispersion window, in months, when `--since` is
276/// absent.
277const DEFAULT_HISTORY_MONTHS: u64 = 12;
278
279/// Record a branch without a commit, where churn has no history to read.
280///
281/// A fresh `git init` or an orphan branch passes the repository check, so without this entry the
282/// hotspot sections read as empty rather than unmeasured (issue #2803). Other
283/// churn failures with a resolvable HEAD keep their `tracing` line only.
284fn record_unborn_head(opts: &HealthOptions<'_>) {
285    if !matches!(crate::repo_refs::head_sha(opts.root), Ok(None)) {
286        return;
287    }
288    if !opts.quiet {
289        eprintln!("note: hotspot analysis skipped: the current branch has no commits yet");
290    }
291    super::diagnostics::record_health_diagnostic(
292        opts.root,
293        None,
294        fallow_types::workspace::WorkspaceDiagnosticKind::HotspotsSkipped {
295            cause: "no-commits".to_owned(),
296        },
297    );
298}
299
300/// Record the two `--since` failures as one diagnostic.
301///
302/// Rejected-as-input and failed-to-parse are one fact to the reader of a report:
303/// the window they asked for was not read, so the hotspot sections measured
304/// nothing. The error text that distinguishes them stays on the `tracing` line
305/// where it was already written.
306fn record_invalid_since(root: &std::path::Path) {
307    super::diagnostics::record_health_diagnostic(
308        root,
309        None,
310        fallow_types::workspace::WorkspaceDiagnosticKind::HotspotsSkipped {
311            cause: "invalid-since".to_owned(),
312        },
313    );
314}
315
316/// Header label for imported churn (`--churn-file`). The imported window is
317/// whatever the wrapper exported, so reusing the `--since` duration ("since 6
318/// months") would misdescribe it, and no cutoff is applied on the import path.
319fn imported_since() -> crate::churn::SinceDuration {
320    crate::churn::SinceDuration {
321        window: crate::churn::ChurnWindow::Imported,
322        display: "imported churn".to_string(),
323    }
324}
325
326/// Find the maximum weighted-commits and complexity-density across eligible files.
327///
328/// Used to normalize hotspot scores into the 0-100 range.
329fn compute_normalization_maxima(
330    file_scores: &[FileHealthScore],
331    churn_files: &rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn>,
332    min_commits: u32,
333) -> (f64, f64) {
334    let mut max_weighted: f64 = 0.0;
335    let mut max_density: f64 = 0.0;
336    for score in file_scores {
337        if let Some(churn) = churn_files.get(&score.path)
338            && churn.commits >= min_commits
339        {
340            max_weighted = max_weighted.max(churn.weighted_commits);
341            max_density = max_density.max(score.complexity_density);
342        }
343    }
344    (max_weighted, max_density)
345}
346
347/// Check whether a file should be excluded from hotspot results
348/// based on workspace filter and ignore patterns.
349pub(super) fn is_excluded_from_hotspots(
350    path: &std::path::Path,
351    root: &std::path::Path,
352    ignore_set: &globset::GlobSet,
353    ws_roots: Option<&[std::path::PathBuf]>,
354) -> bool {
355    if let Some(ws) = ws_roots
356        && !ws.iter().any(|r| path.starts_with(r))
357    {
358        return true;
359    }
360    if !ignore_set.is_empty() {
361        let relative = path.strip_prefix(root).unwrap_or(path);
362        if ignore_set.is_match(relative) {
363            return true;
364        }
365    }
366    false
367}
368
369/// Compute a normalized hotspot score from churn and complexity data.
370///
371/// Both inputs are normalized against their respective maxima so the result
372/// falls in the 0-100 range (rounded to one decimal).
373fn compute_hotspot_score(
374    weighted_commits: f64,
375    max_weighted: f64,
376    complexity_density: f64,
377    max_density: f64,
378) -> f64 {
379    let norm_churn = if max_weighted > 0.0 {
380        weighted_commits / max_weighted
381    } else {
382        0.0
383    };
384    let norm_complexity = if max_density > 0.0 {
385        complexity_density / max_density
386    } else {
387        0.0
388    };
389    (norm_churn * norm_complexity * 100.0 * 10.0).round() / 10.0
390}
391
392pub(super) struct HotspotComputationInput<'a> {
393    pub(super) opts: &'a HealthOptions<'a>,
394    pub(super) config: &'a fallow_config::ResolvedConfig,
395    pub(super) file_scores: &'a [FileHealthScore],
396    pub(super) ignore_set: &'a globset::GlobSet,
397    pub(super) ws_roots: Option<&'a [std::path::PathBuf]>,
398    pub(super) churn_fetch: ChurnFetchResult,
399    /// Change dispersion per file, when change coupling ran.
400    pub(super) dispersion: Option<
401        &'a rustc_hash::FxHashMap<std::path::PathBuf, crate::change_coupling::FileDispersion>,
402    >,
403}
404
405/// Compute hotspot entries by combining pre-fetched churn data with file health scores.
406///
407/// The list is ranked and complete. The caller applies `--top`, so a grouped
408/// run can keep the complete list for its groups.
409pub(super) fn compute_hotspots(
410    input: HotspotComputationInput<'_>,
411) -> (Vec<HotspotEntry>, Option<HotspotSummary>) {
412    let HotspotComputationInput {
413        opts,
414        config,
415        file_scores,
416        ignore_set,
417        ws_roots,
418        churn_fetch,
419        dispersion,
420    } = input;
421    let churn_result = churn_fetch.result;
422    let since = churn_fetch.since;
423
424    let shallow_clone = churn_result.shallow_clone;
425    warn_shallow_clone(opts, shallow_clone);
426    warn_unpinned_clock(opts, churn_result.clock);
427
428    let min_commits = opts.min_commits.unwrap_or(3);
429    let (max_weighted, max_density) =
430        compute_normalization_maxima(file_scores, &churn_result.files, min_commits);
431
432    let ownership_cfg = &config.health.ownership;
433    let bot_globs_owned = load_ownership_bot_globs(opts, ownership_cfg);
434    let codeowners_owned = load_ownership_codeowners(opts, &config.root);
435    // Staleness is measured against the run clock, not the system clock, so
436    // `stale_days` and the thresholds it feeds (owner-active, drift minimum
437    // file age) do not flip between two runs over the same commit.
438    let now_secs = churn_result.clock.epoch_secs();
439    let ownership_ctx = bot_globs_owned.as_ref().map(|bot_globs| OwnershipContext {
440        author_pool: &churn_result.author_pool,
441        bot_globs,
442        codeowners: codeowners_owned.as_ref(),
443        email_mode: opts.ownership_emails.unwrap_or(ownership_cfg.email_mode),
444        now_secs,
445    });
446
447    let (mut hotspot_entries, files_excluded) = collect_hotspot_entries(&HotspotEntryCtx {
448        file_scores,
449        root: &config.root,
450        ignore_set,
451        ws_roots,
452        churn_files: &churn_result.files,
453        min_commits,
454        max_weighted,
455        max_density,
456        ownership_ctx: ownership_ctx.as_ref(),
457        dispersion,
458    });
459
460    hotspot_entries.sort_by(|a, b| {
461        b.score
462            .partial_cmp(&a.score)
463            .unwrap_or(std::cmp::Ordering::Equal)
464    });
465
466    let files_analyzed = hotspot_entries.len();
467    let summary = HotspotSummary {
468        since: since.display,
469        min_commits,
470        files_analyzed,
471        files_excluded,
472        shallow_clone,
473        clock: Some(clock_provenance(churn_result.clock)),
474    };
475
476    (hotspot_entries, Some(summary))
477}
478
479/// Describe the run clock on the wire, so a JSON consumer can tell a
480/// reproducible churn number from a drifting one.
481///
482/// [`warn_unpinned_clock`] says the same thing on stderr, where `--quiet`
483/// removes it and a machine consumer never sees it at all.
484fn clock_provenance(clock: crate::clock::AnalysisClock) -> ClockProvenance {
485    ClockProvenance {
486        source: match clock.source() {
487            crate::clock::AnalysisClockSource::Environment => ClockSource::Environment,
488            crate::clock::AnalysisClockSource::HeadCommit => ClockSource::HeadCommit,
489            crate::clock::AnalysisClockSource::WallClock => ClockSource::WallClock,
490        },
491        epoch_secs: clock.epoch_secs(),
492        reproducible: clock.is_reproducible(),
493    }
494}
495
496/// Warn when churn numbers were measured against the wall clock.
497///
498/// The run clock normally comes from HEAD's committer timestamp, which makes
499/// recency weighting and `stale_days` reproducible for one commit. `git log`
500/// churn cannot reach this warning: if HEAD has no readable timestamp there is
501/// no git history to analyze either. Imported churn (`--churn-file`) can, and
502/// is the point of the warning, since it exists for projects whose history
503/// lives in a non-git VCS.
504fn warn_unpinned_clock(opts: &HealthOptions<'_>, clock: crate::clock::AnalysisClock) {
505    if clock.is_reproducible() {
506        return;
507    }
508    if !opts.quiet {
509        eprintln!(
510            "Warning: no commit timestamp available, so churn recency and \
511             ownership staleness were measured against the wall clock and will \
512             drift between runs. Set FALLOW_CLOCK_EPOCH to pin them."
513        );
514    }
515    super::diagnostics::record_health_diagnostic(
516        opts.root,
517        None,
518        fallow_types::workspace::WorkspaceDiagnosticKind::UnpinnedClock,
519    );
520}
521
522/// Emit shallow-clone warnings (and the ownership-skew note) when relevant.
523fn warn_shallow_clone(opts: &HealthOptions<'_>, shallow_clone: bool) {
524    if !shallow_clone {
525        return;
526    }
527    if !opts.quiet {
528        eprintln!(
529            "Warning: shallow clone detected. Hotspot analysis may be incomplete. \
530             Use `git fetch --unshallow` for full history."
531        );
532        if opts.ownership {
533            eprintln!(
534                "Warning: shallow clones inflate single-author dominance, so \
535                 ownership signals will be skewed."
536            );
537        }
538    }
539    super::diagnostics::record_health_diagnostic(
540        opts.root,
541        None,
542        fallow_types::workspace::WorkspaceDiagnosticKind::ShallowClone {
543            ownership_requested: opts.ownership,
544        },
545    );
546}
547
548/// Compile the bot-author glob set for ownership analysis, warning on a bad pattern.
549fn load_ownership_bot_globs(
550    opts: &HealthOptions<'_>,
551    ownership_cfg: &fallow_config::OwnershipConfig,
552) -> Option<globset::GlobSet> {
553    opts.ownership.then(|| {
554        compile_bot_globs(&ownership_cfg.bot_patterns).unwrap_or_else(|e| {
555            if !opts.quiet {
556                eprintln!("Warning: invalid bot pattern in health.ownership.botPatterns: {e}");
557            }
558            super::diagnostics::record_health_diagnostic(
559                opts.root,
560                None,
561                fallow_types::workspace::WorkspaceDiagnosticKind::OwnershipUnavailable {
562                    cause: "invalid-bot-pattern".to_owned(),
563                    error: e.to_string(),
564                },
565            );
566            globset::GlobSet::empty()
567        })
568    })
569}
570
571/// Load CODEOWNERS for ownership analysis, warning on a real parse error only.
572fn load_ownership_codeowners(
573    opts: &HealthOptions<'_>,
574    root: &std::path::Path,
575) -> Option<crate::codeowners::CodeOwners> {
576    opts.ownership
577        .then(|| match crate::codeowners::CodeOwners::load(root, None) {
578            Ok(co) => Some(co),
579            Err(e) => {
580                // A project with no CODEOWNERS at all never asked for declared
581                // owners, so it is not a degraded input and gets neither the
582                // note nor the diagnostic.
583                if !e.contains("no CODEOWNERS file found") {
584                    if !opts.quiet {
585                        eprintln!("Warning: failed to parse CODEOWNERS: {e}");
586                    }
587                    super::diagnostics::record_health_diagnostic(
588                        root,
589                        None,
590                        fallow_types::workspace::WorkspaceDiagnosticKind::OwnershipUnavailable {
591                            cause: "codeowners-parse-failed".to_owned(),
592                            error: e,
593                        },
594                    );
595                }
596                None
597            }
598        })
599        .flatten()
600}
601
602/// Read-only inputs for the per-file hotspot-entry loop.
603struct HotspotEntryCtx<'a> {
604    file_scores: &'a [FileHealthScore],
605    root: &'a std::path::Path,
606    ignore_set: &'a globset::GlobSet,
607    ws_roots: Option<&'a [std::path::PathBuf]>,
608    churn_files: &'a rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn>,
609    min_commits: u32,
610    max_weighted: f64,
611    max_density: f64,
612    ownership_ctx: Option<&'a OwnershipContext<'a>>,
613    dispersion: Option<
614        &'a rustc_hash::FxHashMap<std::path::PathBuf, crate::change_coupling::FileDispersion>,
615    >,
616}
617
618/// Build hotspot entries for eligible files; returns the entries plus the count
619/// of files excluded for not meeting the minimum-commits threshold.
620fn collect_hotspot_entries(ctx: &HotspotEntryCtx<'_>) -> (Vec<HotspotEntry>, usize) {
621    let mut hotspot_entries = Vec::new();
622    let mut files_excluded: usize = 0;
623
624    for score in ctx.file_scores {
625        if is_excluded_from_hotspots(&score.path, ctx.root, ctx.ignore_set, ctx.ws_roots) {
626            continue;
627        }
628
629        let Some(churn) = ctx.churn_files.get(&score.path) else {
630            continue;
631        };
632        if churn.commits < ctx.min_commits {
633            files_excluded += 1;
634            continue;
635        }
636
637        let relative = score.path.strip_prefix(ctx.root).unwrap_or(&score.path);
638        let ownership = ctx
639            .ownership_ctx
640            .and_then(|own| compute_ownership(churn, relative, own));
641        let dispersion = ctx.dispersion.and_then(|map| map.get(&score.path));
642
643        hotspot_entries.push(HotspotEntry {
644            path: score.path.clone(),
645            score: compute_hotspot_score(
646                churn.weighted_commits,
647                ctx.max_weighted,
648                score.complexity_density,
649                ctx.max_density,
650            ),
651            commits: churn.commits,
652            weighted_commits: churn.weighted_commits,
653            lines_added: churn.lines_added,
654            lines_deleted: churn.lines_deleted,
655            complexity_density: score.complexity_density,
656            fan_in: score.fan_in,
657            trend: churn.trend,
658            median_files_per_commit: dispersion
659                .map(|d| round_one_decimal(d.median_files_per_commit)),
660            median_lines_per_commit: dispersion
661                .map(|d| round_one_decimal(d.median_lines_per_commit)),
662            ownership,
663            is_test_path: is_test_path(ctx.root, relative),
664        });
665    }
666
667    (hotspot_entries, files_excluded)
668}
669
670fn round_one_decimal(value: f64) -> f64 {
671    (value * 10.0).round() / 10.0
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    fn target_churn_options(root: &std::path::Path) -> TargetChurnOptions<'_> {
679        TargetChurnOptions {
680            root,
681            target: std::path::Path::new("src/app.ts"),
682            cache_dir: root.join(".fallow"),
683            no_cache: true,
684            since: None,
685            min_commits: None,
686        }
687    }
688
689    fn churn_result(root: &std::path::Path, commits: u32) -> crate::churn::ChurnResult {
690        let path = root.join("src/app.ts");
691        let mut files = rustc_hash::FxHashMap::default();
692        files.insert(
693            path.clone(),
694            crate::churn::FileChurn {
695                path,
696                commits,
697                weighted_commits: 2.5,
698                lines_added: 20,
699                lines_deleted: 5,
700                trend: crate::churn::ChurnTrend::Accelerating,
701                authors: rustc_hash::FxHashMap::default(),
702            },
703        );
704        crate::churn::ChurnResult {
705            files,
706            shallow_clone: false,
707            author_pool: Vec::new(),
708            clock: crate::clock::AnalysisClock::pinned(1_788_782_400),
709            git_log_bytes: 0,
710            commits: Vec::new(),
711        }
712    }
713
714    #[test]
715    fn target_churn_returns_only_the_requested_qualifying_file() {
716        let root = std::path::Path::new("/project");
717        let options = target_churn_options(root);
718
719        let outcome = analyze_target_churn_with(
720            &options,
721            |_| true,
722            |_, _, _, _| Some((churn_result(root, 4), false)),
723        )
724        .unwrap();
725
726        let TargetChurnOutcome::Found(evidence) = outcome else {
727            panic!("expected qualifying churn evidence");
728        };
729        assert_eq!(evidence.file.path, root.join("src/app.ts"));
730        assert_eq!(evidence.file.commits, 4);
731        assert_eq!(evidence.min_commits, 3);
732        assert_eq!(evidence.since.display, "6 months");
733    }
734
735    #[test]
736    fn target_churn_distinguishes_no_qualifying_history() {
737        let root = std::path::Path::new("/project");
738        let options = target_churn_options(root);
739
740        let outcome = analyze_target_churn_with(
741            &options,
742            |_| true,
743            |_, _, _, _| Some((churn_result(root, 2), false)),
744        )
745        .unwrap();
746
747        assert!(matches!(
748            outcome,
749            TargetChurnOutcome::NoQualifyingChurn {
750                observed_commits: Some(2),
751                min_commits: 3,
752                ..
753            }
754        ));
755    }
756
757    #[test]
758    fn target_churn_distinguishes_git_unavailable() {
759        let root = std::path::Path::new("/project");
760        let options = target_churn_options(root);
761
762        let outcome = analyze_target_churn_with(
763            &options,
764            |_| false,
765            |_, _, _, _| panic!("churn analysis must not run without git"),
766        )
767        .unwrap();
768
769        assert!(matches!(outcome, TargetChurnOutcome::Unavailable { .. }));
770    }
771
772    #[test]
773    fn target_churn_surfaces_analysis_failure() {
774        let root = std::path::Path::new("/project");
775        let options = target_churn_options(root);
776
777        let error = analyze_target_churn_with(&options, |_| true, |_, _, _, _| None)
778            .expect_err("failed git analysis must remain explicit");
779
780        assert!(error.contains("git churn analysis failed"));
781    }
782
783    #[test]
784    fn hotspot_score_both_maxima_zero() {
785        assert!((compute_hotspot_score(0.0, 0.0, 0.0, 0.0)).abs() < f64::EPSILON);
786    }
787
788    #[test]
789    fn hotspot_score_max_weighted_zero() {
790        assert!((compute_hotspot_score(5.0, 0.0, 0.5, 1.0)).abs() < f64::EPSILON);
791    }
792
793    #[test]
794    fn hotspot_score_max_density_zero() {
795        assert!((compute_hotspot_score(5.0, 10.0, 0.0, 0.0)).abs() < f64::EPSILON);
796    }
797
798    #[test]
799    fn hotspot_score_equal_normalization() {
800        let score = compute_hotspot_score(10.0, 10.0, 2.0, 2.0);
801        assert!((score - 100.0).abs() < f64::EPSILON);
802    }
803
804    #[test]
805    fn hotspot_score_half_values() {
806        let score = compute_hotspot_score(5.0, 10.0, 1.0, 2.0);
807        assert!((score - 25.0).abs() < f64::EPSILON);
808    }
809
810    #[test]
811    fn excluded_no_filters() {
812        let path = std::path::Path::new("/project/src/foo.ts");
813        let root = std::path::Path::new("/project");
814        let ignore_set = globset::GlobSet::empty();
815
816        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
817    }
818
819    #[test]
820    fn excluded_workspace_filter_mismatch() {
821        let path = std::path::Path::new("/project/packages/b/src/foo.ts");
822        let root = std::path::Path::new("/project");
823        let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
824        let ignore_set = globset::GlobSet::empty();
825
826        assert!(is_excluded_from_hotspots(
827            path,
828            root,
829            &ignore_set,
830            Some(&ws_roots)
831        ));
832    }
833
834    #[test]
835    fn excluded_workspace_filter_match() {
836        let path = std::path::Path::new("/project/packages/a/src/foo.ts");
837        let root = std::path::Path::new("/project");
838        let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
839        let ignore_set = globset::GlobSet::empty();
840
841        assert!(!is_excluded_from_hotspots(
842            path,
843            root,
844            &ignore_set,
845            Some(&ws_roots)
846        ));
847    }
848
849    #[test]
850    fn excluded_matching_glob() {
851        let path = std::path::Path::new("/project/src/generated/types.ts");
852        let root = std::path::Path::new("/project");
853        let mut builder = globset::GlobSetBuilder::new();
854        builder.add(globset::Glob::new("src/generated/**").unwrap());
855        let ignore_set = builder.build().unwrap();
856
857        assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
858    }
859
860    #[test]
861    fn excluded_non_matching_glob() {
862        let path = std::path::Path::new("/project/src/components/Button.tsx");
863        let root = std::path::Path::new("/project");
864        let mut builder = globset::GlobSetBuilder::new();
865        builder.add(globset::Glob::new("src/generated/**").unwrap());
866        let ignore_set = builder.build().unwrap();
867
868        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
869    }
870
871    #[test]
872    fn normalization_maxima_empty_input() {
873        let scores: Vec<FileHealthScore> = vec![];
874        let churn_files = rustc_hash::FxHashMap::default();
875
876        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
877        assert!((max_w).abs() < f64::EPSILON);
878        assert!((max_d).abs() < f64::EPSILON);
879    }
880
881    #[test]
882    fn normalization_maxima_single_file() {
883        let scores = vec![FileHealthScore {
884            path: std::path::PathBuf::from("/src/foo.ts"),
885            fan_in: 0,
886            fan_out: 0,
887            dead_code_ratio: 0.0,
888            complexity_density: 0.75,
889            maintainability_index: 80.0,
890            total_cyclomatic: 15,
891            total_cognitive: 10,
892            function_count: 3,
893            lines: 20,
894            crap_max: 0.0,
895            crap_above_threshold: 0,
896            crap_exempted: 0,
897            crap_suppressed: 0,
898            crap_unsuppressed_max: None,
899            crap_unsuppressed_effective_threshold: None,
900            crap_effective_threshold: None,
901        }];
902        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
903            rustc_hash::FxHashMap::default();
904        churn_files.insert(
905            std::path::PathBuf::from("/src/foo.ts"),
906            crate::churn::FileChurn {
907                path: std::path::PathBuf::from("/src/foo.ts"),
908                commits: 5,
909                weighted_commits: 4.2,
910                lines_added: 100,
911                lines_deleted: 20,
912                trend: crate::churn::ChurnTrend::Stable,
913                authors: rustc_hash::FxHashMap::default(),
914            },
915        );
916
917        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
918        assert!((max_w - 4.2).abs() < f64::EPSILON);
919        assert!((max_d - 0.75).abs() < f64::EPSILON);
920    }
921
922    /// Hotspot ranking is churn times complexity density and consults no CRAP
923    /// thresholds, so an exemption that clears the risk tag and the coverage
924    /// target must leave hotspot inputs untouched (issue #2228).
925    #[test]
926    fn normalization_maxima_ignore_crap_exemption_fields() {
927        let base = FileHealthScore {
928            path: std::path::PathBuf::from("/src/foo.ts"),
929            fan_in: 0,
930            fan_out: 0,
931            dead_code_ratio: 0.0,
932            complexity_density: 0.75,
933            maintainability_index: 80.0,
934            total_cyclomatic: 15,
935            total_cognitive: 10,
936            function_count: 3,
937            lines: 20,
938            crap_max: 110.0,
939            crap_above_threshold: 2,
940            crap_exempted: 0,
941            crap_suppressed: 0,
942            crap_unsuppressed_max: None,
943            crap_unsuppressed_effective_threshold: None,
944            crap_effective_threshold: None,
945        };
946        let exempt = FileHealthScore {
947            crap_above_threshold: 0,
948            crap_exempted: 2,
949            crap_effective_threshold: Some(500.0),
950            ..base.clone()
951        };
952        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
953            rustc_hash::FxHashMap::default();
954        churn_files.insert(
955            std::path::PathBuf::from("/src/foo.ts"),
956            crate::churn::FileChurn {
957                path: std::path::PathBuf::from("/src/foo.ts"),
958                commits: 5,
959                weighted_commits: 4.2,
960                lines_added: 100,
961                lines_deleted: 20,
962                trend: crate::churn::ChurnTrend::Stable,
963                authors: rustc_hash::FxHashMap::default(),
964            },
965        );
966
967        let flagged = compute_normalization_maxima(&[base], &churn_files, 3);
968        let exempted = compute_normalization_maxima(&[exempt], &churn_files, 3);
969        assert_eq!(flagged, exempted);
970    }
971
972    #[test]
973    fn normalization_maxima_below_min_commits() {
974        let scores = vec![FileHealthScore {
975            path: std::path::PathBuf::from("/src/foo.ts"),
976            fan_in: 0,
977            fan_out: 0,
978            dead_code_ratio: 0.0,
979            complexity_density: 0.75,
980            maintainability_index: 80.0,
981            total_cyclomatic: 15,
982            total_cognitive: 10,
983            function_count: 3,
984            lines: 20,
985            crap_max: 0.0,
986            crap_above_threshold: 0,
987            crap_exempted: 0,
988            crap_suppressed: 0,
989            crap_unsuppressed_max: None,
990            crap_unsuppressed_effective_threshold: None,
991            crap_effective_threshold: None,
992        }];
993        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
994            rustc_hash::FxHashMap::default();
995        churn_files.insert(
996            std::path::PathBuf::from("/src/foo.ts"),
997            crate::churn::FileChurn {
998                path: std::path::PathBuf::from("/src/foo.ts"),
999                commits: 2, // below min_commits=3
1000                weighted_commits: 4.2,
1001                lines_added: 100,
1002                lines_deleted: 20,
1003                trend: crate::churn::ChurnTrend::Stable,
1004                authors: rustc_hash::FxHashMap::default(),
1005            },
1006        );
1007
1008        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
1009        assert!((max_w).abs() < f64::EPSILON);
1010        assert!((max_d).abs() < f64::EPSILON);
1011    }
1012
1013    #[test]
1014    fn normalization_maxima_all_zeros() {
1015        let scores = vec![FileHealthScore {
1016            path: std::path::PathBuf::from("/src/foo.ts"),
1017            fan_in: 0,
1018            fan_out: 0,
1019            dead_code_ratio: 0.0,
1020            complexity_density: 0.0,
1021            maintainability_index: 100.0,
1022            total_cyclomatic: 0,
1023            total_cognitive: 0,
1024            function_count: 1,
1025            lines: 10,
1026            crap_max: 0.0,
1027            crap_above_threshold: 0,
1028            crap_exempted: 0,
1029            crap_suppressed: 0,
1030            crap_unsuppressed_max: None,
1031            crap_unsuppressed_effective_threshold: None,
1032            crap_effective_threshold: None,
1033        }];
1034        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1035            rustc_hash::FxHashMap::default();
1036        churn_files.insert(
1037            std::path::PathBuf::from("/src/foo.ts"),
1038            crate::churn::FileChurn {
1039                path: std::path::PathBuf::from("/src/foo.ts"),
1040                commits: 5,
1041                weighted_commits: 0.0,
1042                lines_added: 0,
1043                lines_deleted: 0,
1044                trend: crate::churn::ChurnTrend::Stable,
1045                authors: rustc_hash::FxHashMap::default(),
1046            },
1047        );
1048
1049        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
1050        assert!((max_w).abs() < f64::EPSILON);
1051        assert!((max_d).abs() < f64::EPSILON);
1052    }
1053
1054    #[test]
1055    fn hotspot_score_high_churn_low_complexity() {
1056        let score = compute_hotspot_score(10.0, 10.0, 0.1, 1.0);
1057        assert!((score - 10.0).abs() < f64::EPSILON);
1058    }
1059
1060    #[test]
1061    fn hotspot_score_low_churn_high_complexity() {
1062        let score = compute_hotspot_score(1.0, 10.0, 2.0, 2.0);
1063        assert!((score - 10.0).abs() < f64::EPSILON);
1064    }
1065
1066    #[test]
1067    fn hotspot_score_rounding() {
1068        let score = compute_hotspot_score(1.0, 3.0, 1.0, 3.0);
1069        assert!((score - 11.1).abs() < f64::EPSILON);
1070    }
1071
1072    #[test]
1073    fn hotspot_score_very_small_values() {
1074        let score = compute_hotspot_score(0.01, 100.0, 0.001, 10.0);
1075        assert!((score).abs() < 0.1);
1076    }
1077
1078    #[test]
1079    fn hotspot_score_weighted_exceeds_max() {
1080        let score = compute_hotspot_score(15.0, 10.0, 1.0, 2.0);
1081        assert!((score - 75.0).abs() < f64::EPSILON);
1082    }
1083
1084    #[test]
1085    fn normalization_maxima_multiple_files_picks_max() {
1086        let scores = vec![
1087            FileHealthScore {
1088                path: std::path::PathBuf::from("/src/a.ts"),
1089                fan_in: 0,
1090                fan_out: 0,
1091                dead_code_ratio: 0.0,
1092                complexity_density: 0.5,
1093                maintainability_index: 80.0,
1094                total_cyclomatic: 10,
1095                total_cognitive: 5,
1096                function_count: 2,
1097                lines: 50,
1098                crap_max: 0.0,
1099                crap_above_threshold: 0,
1100                crap_exempted: 0,
1101                crap_suppressed: 0,
1102                crap_unsuppressed_max: None,
1103                crap_unsuppressed_effective_threshold: None,
1104                crap_effective_threshold: None,
1105            },
1106            FileHealthScore {
1107                path: std::path::PathBuf::from("/src/b.ts"),
1108                fan_in: 0,
1109                fan_out: 0,
1110                dead_code_ratio: 0.0,
1111                complexity_density: 1.2, // highest density
1112                maintainability_index: 60.0,
1113                total_cyclomatic: 30,
1114                total_cognitive: 20,
1115                function_count: 5,
1116                lines: 100,
1117                crap_max: 0.0,
1118                crap_above_threshold: 0,
1119                crap_exempted: 0,
1120                crap_suppressed: 0,
1121                crap_unsuppressed_max: None,
1122                crap_unsuppressed_effective_threshold: None,
1123                crap_effective_threshold: None,
1124            },
1125            FileHealthScore {
1126                path: std::path::PathBuf::from("/src/c.ts"),
1127                fan_in: 0,
1128                fan_out: 0,
1129                dead_code_ratio: 0.0,
1130                complexity_density: 0.8,
1131                maintainability_index: 70.0,
1132                total_cyclomatic: 20,
1133                total_cognitive: 15,
1134                function_count: 4,
1135                lines: 80,
1136                crap_max: 0.0,
1137                crap_above_threshold: 0,
1138                crap_exempted: 0,
1139                crap_suppressed: 0,
1140                crap_unsuppressed_max: None,
1141                crap_unsuppressed_effective_threshold: None,
1142                crap_effective_threshold: None,
1143            },
1144        ];
1145        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1146            rustc_hash::FxHashMap::default();
1147        churn_files.insert(
1148            std::path::PathBuf::from("/src/a.ts"),
1149            crate::churn::FileChurn {
1150                path: std::path::PathBuf::from("/src/a.ts"),
1151                commits: 5,
1152                weighted_commits: 3.0,
1153                lines_added: 50,
1154                lines_deleted: 10,
1155                trend: crate::churn::ChurnTrend::Stable,
1156                authors: rustc_hash::FxHashMap::default(),
1157            },
1158        );
1159        churn_files.insert(
1160            std::path::PathBuf::from("/src/b.ts"),
1161            crate::churn::FileChurn {
1162                path: std::path::PathBuf::from("/src/b.ts"),
1163                commits: 10,
1164                weighted_commits: 8.5, // highest weighted
1165                lines_added: 200,
1166                lines_deleted: 50,
1167                trend: crate::churn::ChurnTrend::Accelerating,
1168                authors: rustc_hash::FxHashMap::default(),
1169            },
1170        );
1171        churn_files.insert(
1172            std::path::PathBuf::from("/src/c.ts"),
1173            crate::churn::FileChurn {
1174                path: std::path::PathBuf::from("/src/c.ts"),
1175                commits: 7,
1176                weighted_commits: 5.0,
1177                lines_added: 100,
1178                lines_deleted: 30,
1179                trend: crate::churn::ChurnTrend::Cooling,
1180                authors: rustc_hash::FxHashMap::default(),
1181            },
1182        );
1183
1184        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
1185        assert!((max_w - 8.5).abs() < f64::EPSILON);
1186        assert!((max_d - 1.2).abs() < f64::EPSILON);
1187    }
1188
1189    #[test]
1190    fn normalization_maxima_mixed_above_and_below_threshold() {
1191        let scores = vec![
1192            FileHealthScore {
1193                path: std::path::PathBuf::from("/src/frequent.ts"),
1194                fan_in: 0,
1195                fan_out: 0,
1196                dead_code_ratio: 0.0,
1197                complexity_density: 0.4,
1198                maintainability_index: 85.0,
1199                total_cyclomatic: 8,
1200                total_cognitive: 4,
1201                function_count: 2,
1202                lines: 40,
1203                crap_max: 0.0,
1204                crap_above_threshold: 0,
1205                crap_exempted: 0,
1206                crap_suppressed: 0,
1207                crap_unsuppressed_max: None,
1208                crap_unsuppressed_effective_threshold: None,
1209                crap_effective_threshold: None,
1210            },
1211            FileHealthScore {
1212                path: std::path::PathBuf::from("/src/rare.ts"),
1213                fan_in: 0,
1214                fan_out: 0,
1215                dead_code_ratio: 0.0,
1216                complexity_density: 2.0, // higher but excluded
1217                maintainability_index: 50.0,
1218                total_cyclomatic: 40,
1219                total_cognitive: 30,
1220                function_count: 8,
1221                lines: 200,
1222                crap_max: 0.0,
1223                crap_above_threshold: 0,
1224                crap_exempted: 0,
1225                crap_suppressed: 0,
1226                crap_unsuppressed_max: None,
1227                crap_unsuppressed_effective_threshold: None,
1228                crap_effective_threshold: None,
1229            },
1230        ];
1231        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1232            rustc_hash::FxHashMap::default();
1233        churn_files.insert(
1234            std::path::PathBuf::from("/src/frequent.ts"),
1235            crate::churn::FileChurn {
1236                path: std::path::PathBuf::from("/src/frequent.ts"),
1237                commits: 10,
1238                weighted_commits: 7.0,
1239                lines_added: 150,
1240                lines_deleted: 40,
1241                trend: crate::churn::ChurnTrend::Stable,
1242                authors: rustc_hash::FxHashMap::default(),
1243            },
1244        );
1245        churn_files.insert(
1246            std::path::PathBuf::from("/src/rare.ts"),
1247            crate::churn::FileChurn {
1248                path: std::path::PathBuf::from("/src/rare.ts"),
1249                commits: 1, // below min_commits=5
1250                weighted_commits: 0.9,
1251                lines_added: 10,
1252                lines_deleted: 2,
1253                trend: crate::churn::ChurnTrend::Cooling,
1254                authors: rustc_hash::FxHashMap::default(),
1255            },
1256        );
1257
1258        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 5);
1259        assert!((max_w - 7.0).abs() < f64::EPSILON);
1260        assert!((max_d - 0.4).abs() < f64::EPSILON);
1261    }
1262
1263    #[test]
1264    fn normalization_maxima_file_score_without_churn() {
1265        let scores = vec![FileHealthScore {
1266            path: std::path::PathBuf::from("/src/no_churn.ts"),
1267            fan_in: 0,
1268            fan_out: 0,
1269            dead_code_ratio: 0.0,
1270            complexity_density: 5.0,
1271            maintainability_index: 30.0,
1272            total_cyclomatic: 100,
1273            total_cognitive: 80,
1274            function_count: 20,
1275            lines: 500,
1276            crap_max: 0.0,
1277            crap_above_threshold: 0,
1278            crap_exempted: 0,
1279            crap_suppressed: 0,
1280            crap_unsuppressed_max: None,
1281            crap_unsuppressed_effective_threshold: None,
1282            crap_effective_threshold: None,
1283        }];
1284        let churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1285            rustc_hash::FxHashMap::default();
1286
1287        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 1);
1288        assert!((max_w).abs() < f64::EPSILON);
1289        assert!((max_d).abs() < f64::EPSILON);
1290    }
1291
1292    #[test]
1293    fn normalization_maxima_min_commits_zero() {
1294        let scores = vec![FileHealthScore {
1295            path: std::path::PathBuf::from("/src/foo.ts"),
1296            fan_in: 0,
1297            fan_out: 0,
1298            dead_code_ratio: 0.0,
1299            complexity_density: 0.3,
1300            maintainability_index: 90.0,
1301            total_cyclomatic: 3,
1302            total_cognitive: 2,
1303            function_count: 1,
1304            lines: 10,
1305            crap_max: 0.0,
1306            crap_above_threshold: 0,
1307            crap_exempted: 0,
1308            crap_suppressed: 0,
1309            crap_unsuppressed_max: None,
1310            crap_unsuppressed_effective_threshold: None,
1311            crap_effective_threshold: None,
1312        }];
1313        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1314            rustc_hash::FxHashMap::default();
1315        churn_files.insert(
1316            std::path::PathBuf::from("/src/foo.ts"),
1317            crate::churn::FileChurn {
1318                path: std::path::PathBuf::from("/src/foo.ts"),
1319                commits: 0,
1320                weighted_commits: 0.0,
1321                lines_added: 0,
1322                lines_deleted: 0,
1323                trend: crate::churn::ChurnTrend::Stable,
1324                authors: rustc_hash::FxHashMap::default(),
1325            },
1326        );
1327
1328        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 0);
1329        assert!((max_w).abs() < f64::EPSILON);
1330        assert!((max_d - 0.3).abs() < f64::EPSILON);
1331    }
1332
1333    #[test]
1334    fn normalization_maxima_exactly_at_threshold() {
1335        let scores = vec![FileHealthScore {
1336            path: std::path::PathBuf::from("/src/foo.ts"),
1337            fan_in: 0,
1338            fan_out: 0,
1339            dead_code_ratio: 0.0,
1340            complexity_density: 1.5,
1341            maintainability_index: 65.0,
1342            total_cyclomatic: 25,
1343            total_cognitive: 18,
1344            function_count: 5,
1345            lines: 120,
1346            crap_max: 0.0,
1347            crap_above_threshold: 0,
1348            crap_exempted: 0,
1349            crap_suppressed: 0,
1350            crap_unsuppressed_max: None,
1351            crap_unsuppressed_effective_threshold: None,
1352            crap_effective_threshold: None,
1353        }];
1354        let mut churn_files: rustc_hash::FxHashMap<std::path::PathBuf, crate::churn::FileChurn> =
1355            rustc_hash::FxHashMap::default();
1356        churn_files.insert(
1357            std::path::PathBuf::from("/src/foo.ts"),
1358            crate::churn::FileChurn {
1359                path: std::path::PathBuf::from("/src/foo.ts"),
1360                commits: 3, // exactly at min_commits=3
1361                weighted_commits: 2.8,
1362                lines_added: 60,
1363                lines_deleted: 15,
1364                trend: crate::churn::ChurnTrend::Stable,
1365                authors: rustc_hash::FxHashMap::default(),
1366            },
1367        );
1368
1369        let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3);
1370        assert!((max_w - 2.8).abs() < f64::EPSILON);
1371        assert!((max_d - 1.5).abs() < f64::EPSILON);
1372    }
1373
1374    #[test]
1375    fn excluded_workspace_and_glob_combined() {
1376        let path = std::path::Path::new("/project/packages/a/src/generated/types.ts");
1377        let root = std::path::Path::new("/project");
1378        let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
1379        let mut builder = globset::GlobSetBuilder::new();
1380        builder.add(globset::Glob::new("**/generated/**").unwrap());
1381        let ignore_set = builder.build().unwrap();
1382
1383        assert!(is_excluded_from_hotspots(
1384            path,
1385            root,
1386            &ignore_set,
1387            Some(&ws_roots)
1388        ));
1389    }
1390
1391    #[test]
1392    fn excluded_workspace_match_but_glob_no_match() {
1393        let path = std::path::Path::new("/project/packages/a/src/index.ts");
1394        let root = std::path::Path::new("/project");
1395        let ws_roots = [std::path::PathBuf::from("/project/packages/a")];
1396        let mut builder = globset::GlobSetBuilder::new();
1397        builder.add(globset::Glob::new("**/generated/**").unwrap());
1398        let ignore_set = builder.build().unwrap();
1399
1400        assert!(!is_excluded_from_hotspots(
1401            path,
1402            root,
1403            &ignore_set,
1404            Some(&ws_roots)
1405        ));
1406    }
1407
1408    #[test]
1409    fn excluded_path_equals_root() {
1410        let path = std::path::Path::new("/project");
1411        let root = std::path::Path::new("/project");
1412        let ignore_set = globset::GlobSet::empty();
1413
1414        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1415    }
1416
1417    #[test]
1418    fn excluded_path_outside_root() {
1419        let path = std::path::Path::new("/other/src/foo.ts");
1420        let root = std::path::Path::new("/project");
1421        let mut builder = globset::GlobSetBuilder::new();
1422        builder.add(globset::Glob::new("src/foo.ts").unwrap());
1423        let ignore_set = builder.build().unwrap();
1424
1425        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1426    }
1427
1428    #[test]
1429    fn excluded_multiple_globs_first_matches() {
1430        let path = std::path::Path::new("/project/dist/bundle.js");
1431        let root = std::path::Path::new("/project");
1432        let mut builder = globset::GlobSetBuilder::new();
1433        builder.add(globset::Glob::new("dist/**").unwrap());
1434        builder.add(globset::Glob::new("node_modules/**").unwrap());
1435        let ignore_set = builder.build().unwrap();
1436
1437        assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
1438    }
1439
1440    #[test]
1441    fn excluded_multiple_globs_second_matches() {
1442        let path = std::path::Path::new("/project/node_modules/lodash/index.js");
1443        let root = std::path::Path::new("/project");
1444        let mut builder = globset::GlobSetBuilder::new();
1445        builder.add(globset::Glob::new("dist/**").unwrap());
1446        builder.add(globset::Glob::new("node_modules/**").unwrap());
1447        let ignore_set = builder.build().unwrap();
1448
1449        assert!(is_excluded_from_hotspots(path, root, &ignore_set, None));
1450    }
1451
1452    #[test]
1453    fn excluded_multiple_globs_none_matches() {
1454        let path = std::path::Path::new("/project/src/app.ts");
1455        let root = std::path::Path::new("/project");
1456        let mut builder = globset::GlobSetBuilder::new();
1457        builder.add(globset::Glob::new("dist/**").unwrap());
1458        builder.add(globset::Glob::new("node_modules/**").unwrap());
1459        let ignore_set = builder.build().unwrap();
1460
1461        assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None));
1462    }
1463}