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