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