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