Skip to main content

fallow_engine/health/
hotspots.rs

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