Skip to main content

fallow_engine/health/
mod.rs

1//! Command-neutral health execution options and runners.
2
3use std::path::{Path, PathBuf};
4use std::process::ExitCode;
5
6use fallow_config::EmailMode;
7use fallow_output::{
8    DiffIndex, EffortEstimate, FindingSeverity, GroupByMode, RuntimeCoverageReport,
9    RuntimeCoverageWatermark,
10};
11use fallow_types::output_format::OutputFormat;
12use fallow_types::path_util::is_absolute_path_any_platform;
13
14mod actions;
15mod analysis_data;
16mod assembly;
17mod baseline_io;
18mod churn_file;
19mod component_rollup;
20mod core_pipeline;
21mod coverage_gaps;
22mod coverage_intelligence;
23mod coverage_settings;
24mod css_analytics;
25mod derived_sections;
26mod execute;
27mod file_scores;
28mod filters;
29mod finding_sort;
30mod findings;
31mod findings_pipeline;
32mod framework_health;
33mod grouping;
34mod hotspots;
35mod ignore;
36mod large_functions;
37mod output_build;
38pub mod ownership;
39mod package_json;
40mod pipeline;
41mod react_hooks;
42mod result;
43mod runner;
44mod runtime_filter;
45mod runtime_sections;
46mod scope;
47pub mod scoring;
48pub mod styling_score;
49mod tailwind_theme;
50mod targets;
51mod threshold_overrides;
52mod timings;
53mod vital_data;
54mod vital_signs_scope;
55
56pub use crate::results::HealthAnalysisResult;
57pub use churn_file::validate_health_churn_file;
58use derived_sections::{
59    HealthDerivedSectionInput, HealthDerivedSections, prepare_health_derived_sections,
60};
61use execute::HealthOptions;
62pub use execute::execute_health_inner;
63use file_scores::{
64    FileScoresAndChurnInput, compute_file_scores_and_churn, health_file_scores_slice,
65    print_slow_churn_note,
66};
67use finding_sort::sort_findings;
68pub use pipeline::{HealthPipelineInputs, HealthScopeInputs};
69pub use runner::run_ungrouped_health;
70use vital_data::{HealthVitalData, HealthVitalDataInput, prepare_health_vital_data};
71use vital_signs_scope::{
72    SubsetFilter, VitalSignsAndCountsInput, apply_duplication_metrics,
73    compute_vital_signs_and_counts,
74};
75
76/// Command-neutral grouping resolver contract for `--group-by` health output.
77///
78/// The CLI owns the concrete resolver (CODEOWNERS parsing, package discovery);
79/// the engine grouping pass only needs these three read operations, so it stays
80/// generic over the resolver instead of depending on the CLI type.
81pub trait HealthGroupResolver {
82    /// Stable label for the active grouping mode (`owner` / `directory` / ...).
83    fn mode_label(&self) -> &'static str;
84    /// Resolve a repo-relative path to its group key and the matching rule.
85    fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>);
86    /// Section owners for the group a path belongs to, when known.
87    fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]>;
88}
89
90/// Placeholder grouping resolver for runs without `--group-by` (the programmatic
91/// API path). Constructed only as `None`, so its methods are never invoked.
92#[derive(Debug, Clone, Copy)]
93pub enum NoGroupResolver {}
94
95#[expect(
96    clippy::uninhabited_references,
97    reason = "NoGroupResolver is uninhabited; these methods are unreachable and exist only to satisfy the trait bound for the group-less programmatic path"
98)]
99impl HealthGroupResolver for NoGroupResolver {
100    fn mode_label(&self) -> &'static str {
101        match *self {}
102    }
103    fn resolve_with_rule(&self, _rel_path: &Path) -> (String, Option<String>) {
104        match *self {}
105    }
106    fn section_owners_of(&self, _rel_path: &Path) -> Option<&[String]> {
107        match *self {}
108    }
109}
110
111/// Runtime coverage analysis seam.
112///
113/// Runtime coverage execution drives the closed-source `fallow-cov` sidecar
114/// (license verification, subprocess spawning), which stays in the CLI. The
115/// engine calls this callback only when [`HealthExecutionOptions::runtime_coverage`]
116/// is set, so the default and programmatic paths never touch it.
117pub type RuntimeCoverageAnalyzer<'a> = dyn Fn(
118        &RuntimeCoverageOptions,
119        RuntimeCoverageSeamInput<'_>,
120    ) -> Result<RuntimeCoverageReport, ExitCode>
121    + 'a;
122
123/// Inputs the runtime coverage seam needs from the analysis core.
124pub struct RuntimeCoverageSeamInput<'a> {
125    pub root: &'a Path,
126    pub modules: &'a [fallow_types::extract::ModuleInfo],
127    pub analysis_output: &'a crate::DeadCodeAnalysisArtifacts,
128    pub istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
129    pub file_paths: &'a rustc_hash::FxHashMap<fallow_types::discover::FileId, &'a PathBuf>,
130    pub ignore_set: &'a globset::GlobSet,
131    pub changed_files: Option<&'a rustc_hash::FxHashSet<PathBuf>>,
132    pub ws_roots: Option<&'a [PathBuf]>,
133    pub top: Option<usize>,
134    pub codeowners_path: Option<&'a str>,
135    pub quiet: bool,
136    pub output: OutputFormat,
137}
138
139/// CLI-supplied callbacks the command-neutral health pipeline needs.
140///
141/// The pipeline itself stays cli-free; these are the seams the CLI threads in.
142pub struct HealthSeams<'a> {
143    /// Runs the runtime coverage sidecar (only when runtime coverage is set).
144    pub runtime_coverage_analyzer: &'a RuntimeCoverageAnalyzer<'a>,
145    /// Records module-graph structure facts (graph node count, edge count) into
146    /// the CLI's process-global telemetry sinks. Best-effort; the engine never
147    /// owns telemetry state.
148    pub note_graph_structure: &'a dyn Fn(usize, usize),
149}
150
151/// Command-neutral sort criteria for health complexity findings.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum HealthSort {
154    Severity,
155    Cyclomatic,
156    Cognitive,
157    Lines,
158}
159
160/// Command-neutral threshold overrides for health complexity findings.
161#[derive(Debug, Clone, Copy, Default, PartialEq)]
162pub struct HealthThresholdOverrides {
163    pub max_cyclomatic: Option<u16>,
164    pub max_cognitive: Option<u16>,
165    /// Maximum CRAP score threshold. Functions meeting or exceeding this score
166    /// are reported as complexity findings.
167    pub max_crap: Option<f64>,
168}
169
170/// Command-neutral Istanbul coverage inputs for health CRAP scoring.
171#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
172pub struct HealthCoverageInputs<'a> {
173    pub coverage: Option<&'a Path>,
174    /// Absolute coverage-path prefix to strip before rebasing files onto the
175    /// project root.
176    pub coverage_root: Option<&'a Path>,
177}
178
179/// Validate that a coverage-data root is absolute under Unix or Windows path
180/// conventions.
181///
182/// Istanbul coverage paths often come from a Linux CI runner even when fallow
183/// is invoked on another host, so POSIX-rooted paths and Windows drive paths
184/// are both accepted on every platform.
185pub fn validate_coverage_root_absolute(coverage_root: Option<&Path>) -> Result<(), String> {
186    if let Some(path) = coverage_root
187        && !is_absolute_path_any_platform(path)
188    {
189        return Err(format!(
190            "--coverage-root expects an absolute path prefix from the coverage data, got '{}'. Use the checkout prefix from the machine that generated coverage, for example '/home/runner/work/myapp'.",
191            path.display()
192        ));
193    }
194    Ok(())
195}
196
197/// Command-neutral health exit gate options.
198#[derive(Debug, Clone, Copy, Default, PartialEq)]
199pub struct HealthGateOptions {
200    pub min_score: Option<f64>,
201    pub min_severity: Option<FindingSeverity>,
202    /// Render the score and findings but never fail CI on a health gate.
203    pub report_only: bool,
204}
205
206/// Input for deriving effective health sections from command-neutral flags.
207#[derive(Debug, Clone)]
208pub struct HealthSectionOptions {
209    pub output: OutputFormat,
210    pub complexity: bool,
211    pub file_scores: bool,
212    pub coverage_gaps: bool,
213    pub hotspots: bool,
214    pub targets: bool,
215    pub css: bool,
216    pub score: bool,
217    pub score_gate: bool,
218    pub snapshot_requested: bool,
219    pub trend: bool,
220}
221
222/// Derived section selection for health runs.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub struct DerivedHealthSections {
225    pub any_section: bool,
226    pub complexity: bool,
227    pub file_scores: bool,
228    pub coverage_gaps: bool,
229    pub hotspots: bool,
230    pub targets: bool,
231    pub css: bool,
232    pub score: bool,
233    pub force_full: bool,
234    pub score_only_output: bool,
235}
236
237/// Command-neutral inputs used to normalize a health run before it reaches a
238/// concrete runner.
239#[derive(Debug, Clone)]
240pub struct HealthRunOptionsInput<'a> {
241    pub output: OutputFormat,
242    pub thresholds: HealthThresholdOverrides,
243    pub top: Option<usize>,
244    pub sort: HealthSort,
245    pub complexity: bool,
246    pub file_scores: bool,
247    pub coverage_gaps: bool,
248    pub hotspots: bool,
249    pub ownership: bool,
250    pub ownership_emails: Option<EmailMode>,
251    pub targets: bool,
252    pub css: bool,
253    pub effort: Option<EffortEstimate>,
254    pub score: bool,
255    pub gates: HealthGateOptions,
256    pub snapshot_requested: bool,
257    pub trend: bool,
258    pub since: Option<&'a str>,
259    pub min_commits: Option<u32>,
260    pub coverage_inputs: HealthCoverageInputs<'a>,
261    pub runtime_coverage: Option<RuntimeCoverageOptions>,
262}
263
264/// Normalized health inputs shared by CLI, API, NAPI, and future runners.
265#[derive(Debug, Clone)]
266pub struct HealthRunOptions<'a> {
267    pub thresholds: HealthThresholdOverrides,
268    pub top: Option<usize>,
269    pub sort: HealthSort,
270    pub sections: DerivedHealthSections,
271    pub ownership: bool,
272    pub ownership_emails: Option<EmailMode>,
273    pub effort: Option<EffortEstimate>,
274    pub gates: HealthGateOptions,
275    pub since: Option<&'a str>,
276    pub min_commits: Option<u32>,
277    pub coverage_inputs: HealthCoverageInputs<'a>,
278    pub runtime_coverage: Option<RuntimeCoverageOptions>,
279}
280
281/// Command-neutral inputs needed to execute a health analysis.
282///
283/// These fields are shared runner inputs rather than rendering concerns.
284#[derive(Debug, Clone)]
285pub struct HealthExecutionOptions<'a> {
286    pub root: &'a Path,
287    pub config_path: &'a Option<PathBuf>,
288    pub output: OutputFormat,
289    pub no_cache: bool,
290    pub threads: usize,
291    pub quiet: bool,
292    /// Include per-decision-point complexity contributions in typed findings.
293    ///
294    /// This changes the produced health result shape, so it belongs to the
295    /// runner input contract rather than CLI rendering options.
296    pub complexity_breakdown: bool,
297    pub thresholds: HealthThresholdOverrides,
298    pub top: Option<usize>,
299    pub sort: HealthSort,
300    pub production: bool,
301    pub production_override: Option<bool>,
302    pub changed_since: Option<&'a str>,
303    pub diff_index: Option<&'a DiffIndex>,
304    pub use_shared_diff_index: bool,
305    pub workspace: Option<&'a [String]>,
306    pub changed_workspaces: Option<&'a str>,
307    pub baseline: Option<&'a Path>,
308    pub save_baseline: Option<&'a Path>,
309    pub complexity: bool,
310    pub file_scores: bool,
311    pub coverage_gaps: bool,
312    pub config_activates_coverage_gaps: bool,
313    pub hotspots: bool,
314    pub ownership: bool,
315    pub ownership_emails: Option<EmailMode>,
316    pub targets: bool,
317    pub css: bool,
318    pub force_full: bool,
319    pub score_only_output: bool,
320    pub enforce_coverage_gap_gate: bool,
321    pub effort: Option<EffortEstimate>,
322    pub score: bool,
323    pub gates: HealthGateOptions,
324    pub since: Option<&'a str>,
325    pub min_commits: Option<u32>,
326    pub explain: bool,
327    pub summary: bool,
328    pub save_snapshot: Option<PathBuf>,
329    pub trend: bool,
330    pub coverage_inputs: HealthCoverageInputs<'a>,
331    pub performance: bool,
332    pub runtime_coverage: Option<RuntimeCoverageOptions>,
333    pub churn_file: Option<&'a Path>,
334    /// Optional grouping mode for typed health output.
335    pub group_by: Option<GroupByMode>,
336}
337
338/// Derive effective health section flags for CLI and embedders.
339#[must_use]
340pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
341    let score = options.score
342        || options.score_gate
343        || options.trend
344        || matches!(options.output, OutputFormat::Badge);
345    let any_section = options.complexity
346        || options.file_scores
347        || options.coverage_gaps
348        || options.hotspots
349        || options.targets
350        || score;
351    let effective_score = if any_section { score } else { true } || options.snapshot_requested;
352    let force_full = options.snapshot_requested || effective_score;
353
354    DerivedHealthSections {
355        any_section,
356        complexity: if any_section {
357            options.complexity
358        } else {
359            true
360        },
361        file_scores: if any_section {
362            options.file_scores
363        } else {
364            true
365        } || force_full,
366        coverage_gaps: if any_section {
367            options.coverage_gaps
368        } else {
369            false
370        },
371        hotspots: if any_section { options.hotspots } else { true }
372            || options.snapshot_requested
373            || options.trend,
374        targets: if any_section { options.targets } else { true },
375        css: options.css,
376        score: effective_score,
377        force_full,
378        score_only_output: is_health_score_only_output(options, score),
379    }
380}
381
382/// Normalize health run inputs into the engine-owned run contract.
383#[must_use]
384pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
385    let targets = input.targets || input.effort.is_some();
386    let sections = derive_health_sections(&HealthSectionOptions {
387        output: input.output,
388        complexity: input.complexity,
389        file_scores: input.file_scores,
390        coverage_gaps: input.coverage_gaps,
391        hotspots: input.hotspots,
392        targets,
393        css: input.css,
394        score: input.score,
395        score_gate: input.gates.min_score.is_some(),
396        snapshot_requested: input.snapshot_requested,
397        trend: input.trend,
398    });
399
400    HealthRunOptions {
401        thresholds: input.thresholds,
402        top: input.top,
403        sort: input.sort,
404        sections,
405        ownership: input.ownership && sections.hotspots,
406        ownership_emails: input.ownership_emails,
407        effort: input.effort,
408        gates: input.gates,
409        since: input.since,
410        min_commits: input.min_commits,
411        coverage_inputs: input.coverage_inputs,
412        runtime_coverage: input.runtime_coverage,
413    }
414}
415
416fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
417    score
418        && !options.complexity
419        && !options.file_scores
420        && !options.coverage_gaps
421        && !options.hotspots
422        && !options.targets
423        && !options.trend
424}
425
426/// Input for deriving effective programmatic complexity sections.
427#[derive(Debug, Clone)]
428pub struct ComplexitySectionOptions {
429    pub complexity: bool,
430    pub file_scores: bool,
431    pub coverage_gaps: bool,
432    pub hotspots: bool,
433    pub ownership: bool,
434    pub targets: bool,
435    pub css: bool,
436    pub score: bool,
437}
438
439/// Derived section selection for programmatic health / complexity runs.
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441pub struct DerivedComplexityOptions {
442    pub any_section: bool,
443    pub complexity: bool,
444    pub file_scores: bool,
445    pub coverage_gaps: bool,
446    pub hotspots: bool,
447    pub ownership: bool,
448    pub targets: bool,
449    pub force_full: bool,
450    pub score_only_output: bool,
451    pub score: bool,
452}
453
454/// Derive effective programmatic health / complexity section flags.
455#[must_use]
456pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
457    let requested_hotspots = options.hotspots || options.ownership;
458    let sections = derive_health_sections(&HealthSectionOptions {
459        output: OutputFormat::Human,
460        complexity: options.complexity,
461        file_scores: options.file_scores,
462        coverage_gaps: options.coverage_gaps,
463        hotspots: requested_hotspots,
464        targets: options.targets,
465        css: options.css,
466        score: options.score,
467        score_gate: false,
468        snapshot_requested: false,
469        trend: false,
470    });
471
472    DerivedComplexityOptions {
473        any_section: sections.any_section,
474        complexity: sections.complexity,
475        file_scores: sections.file_scores,
476        coverage_gaps: sections.coverage_gaps,
477        hotspots: sections.hotspots,
478        ownership: options.ownership && sections.hotspots,
479        targets: sections.targets,
480        force_full: sections.force_full,
481        score_only_output: sections.score_only_output,
482        score: sections.score,
483    }
484}
485
486/// Normalized programmatic complexity / health inputs shared by API, NAPI, and
487/// engine-backed runners.
488#[derive(Debug, Clone, PartialEq)]
489pub struct ComplexityRunOptions<'a> {
490    pub thresholds: HealthThresholdOverrides,
491    pub top: Option<usize>,
492    pub sort: HealthSort,
493    pub complexity_breakdown: bool,
494    pub sections: DerivedComplexityOptions,
495    pub ownership_emails: Option<EmailMode>,
496    pub effort: Option<EffortEstimate>,
497    pub css: bool,
498    pub since: Option<&'a str>,
499    pub min_commits: Option<u32>,
500    pub coverage_inputs: HealthCoverageInputs<'a>,
501}
502
503/// Command-neutral runtime coverage input for health analysis.
504#[derive(Debug, Clone)]
505pub struct RuntimeCoverageOptions {
506    pub path: PathBuf,
507    pub min_invocations_hot: u64,
508    /// Minimum total trace volume before high-confidence `safe_to_delete` /
509    /// `review_required` verdicts may be emitted. Below this the sidecar caps
510    /// confidence at `medium`. `None` lets the sidecar use its spec-default
511    /// (5000).
512    pub min_observation_volume: Option<u32>,
513    /// Fraction of total trace count below which an invoked function is
514    /// classified as `low_traffic` rather than `active`. `None` lets the
515    /// sidecar use its spec-default (0.001 = 0.1%).
516    pub low_traffic_threshold: Option<f64>,
517    pub license_jwt: String,
518    pub watermark: Option<RuntimeCoverageWatermark>,
519}
520
521/// Pre-parsed health input reused from another analysis in the same process.
522pub struct HealthSharedParseData {
523    pub files: Vec<fallow_types::discover::DiscoveredFile>,
524    pub modules: Vec<fallow_types::extract::ModuleInfo>,
525    /// Full analysis output (graph + results) for file scoring.
526    pub analysis_output: Option<crate::DeadCodeAnalysisArtifacts>,
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    fn health_run_input() -> HealthRunOptionsInput<'static> {
534        HealthRunOptionsInput {
535            output: OutputFormat::Json,
536            thresholds: HealthThresholdOverrides::default(),
537            top: None,
538            sort: HealthSort::Cyclomatic,
539            complexity: false,
540            file_scores: false,
541            coverage_gaps: false,
542            hotspots: false,
543            ownership: false,
544            ownership_emails: None,
545            targets: false,
546            css: false,
547            effort: None,
548            score: false,
549            gates: HealthGateOptions::default(),
550            snapshot_requested: false,
551            trend: false,
552            since: None,
553            min_commits: None,
554            coverage_inputs: HealthCoverageInputs::default(),
555            runtime_coverage: None,
556        }
557    }
558
559    #[test]
560    fn health_execution_options_own_shared_runner_scope() {
561        let root = Path::new("/project");
562        let config_path = None;
563        let workspace = vec!["packages/app".to_string()];
564        let diff = DiffIndex::from_unified_diff(
565            "diff --git a/src/a.ts b/src/a.ts\n\
566             --- a/src/a.ts\n\
567             +++ b/src/a.ts\n\
568             @@ -0,0 +1,1 @@\n\
569             +new line\n",
570        );
571        let runtime_coverage = RuntimeCoverageOptions {
572            path: PathBuf::from("coverage/v8"),
573            min_invocations_hot: 10,
574            min_observation_volume: Some(500),
575            low_traffic_threshold: Some(0.01),
576            license_jwt: "test.jwt".to_string(),
577            watermark: None,
578        };
579
580        let options = HealthExecutionOptions {
581            root,
582            config_path: &config_path,
583            output: OutputFormat::Json,
584            no_cache: true,
585            threads: 2,
586            quiet: true,
587            complexity_breakdown: true,
588            thresholds: HealthThresholdOverrides::default(),
589            top: Some(5),
590            sort: HealthSort::Cognitive,
591            production: true,
592            production_override: Some(true),
593            changed_since: Some("HEAD~1"),
594            diff_index: Some(&diff),
595            use_shared_diff_index: false,
596            workspace: Some(&workspace),
597            changed_workspaces: None,
598            baseline: Some(Path::new(".fallow/health-baseline.json")),
599            save_baseline: None,
600            complexity: true,
601            file_scores: true,
602            coverage_gaps: false,
603            config_activates_coverage_gaps: false,
604            hotspots: true,
605            ownership: false,
606            ownership_emails: None,
607            targets: true,
608            css: false,
609            force_full: true,
610            score_only_output: false,
611            enforce_coverage_gap_gate: true,
612            effort: Some(EffortEstimate::Low),
613            score: true,
614            gates: HealthGateOptions {
615                min_score: Some(80.0),
616                min_severity: None,
617                report_only: false,
618            },
619            since: Some("30d"),
620            min_commits: Some(2),
621            explain: true,
622            summary: false,
623            save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
624            trend: true,
625            coverage_inputs: HealthCoverageInputs::default(),
626            performance: true,
627            runtime_coverage: Some(runtime_coverage),
628            churn_file: Some(Path::new("churn.json")),
629            group_by: Some(GroupByMode::Directory),
630        };
631
632        assert_eq!(options.root, root);
633        assert!(
634            options
635                .diff_index
636                .is_some_and(|index| index.line_is_added("src/a.ts", 1))
637        );
638        assert_eq!(options.workspace, Some(workspace.as_slice()));
639        assert!(options.runtime_coverage.is_some());
640        assert_eq!(options.group_by, Some(GroupByMode::Directory));
641        assert_eq!(
642            options.save_snapshot.as_deref(),
643            Some(Path::new(".fallow/snapshots/health.json"))
644        );
645    }
646
647    #[test]
648    fn health_run_options_default_sections_match_health_defaults() {
649        let run = derive_health_run_options(health_run_input());
650
651        assert!(run.sections.complexity);
652        assert!(run.sections.file_scores);
653        assert!(run.sections.hotspots);
654        assert!(run.sections.targets);
655        assert!(run.sections.score);
656        assert!(!run.ownership);
657    }
658
659    #[test]
660    fn health_run_options_effort_requests_targets() {
661        let mut input = health_run_input();
662        input.effort = Some(EffortEstimate::Low);
663
664        let run = derive_health_run_options(input);
665
666        assert!(run.sections.targets);
667        assert_eq!(run.effort, Some(EffortEstimate::Low));
668    }
669
670    #[test]
671    fn health_run_options_ownership_requires_hotspots() {
672        let mut input = health_run_input();
673        input.complexity = true;
674        input.ownership = true;
675
676        let run = derive_health_run_options(input);
677
678        assert!(!run.sections.hotspots);
679        assert!(!run.ownership);
680
681        let mut input = health_run_input();
682        input.ownership = true;
683        input.hotspots = true;
684
685        let run = derive_health_run_options(input);
686
687        assert!(run.sections.hotspots);
688        assert!(run.ownership);
689    }
690
691    #[test]
692    fn health_run_options_score_gate_forces_score() {
693        let mut input = health_run_input();
694        input.gates.min_score = Some(90.0);
695
696        let run = derive_health_run_options(input);
697
698        assert!(run.sections.score);
699        assert_eq!(run.gates.min_score, Some(90.0));
700    }
701
702    #[test]
703    fn coverage_root_accepts_posix_absolute() {
704        assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
705        assert!(
706            validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
707        );
708    }
709
710    #[test]
711    fn coverage_root_rejects_relative() {
712        assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
713        assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
714        assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
715    }
716
717    #[test]
718    fn coverage_root_accepts_none() {
719        assert!(validate_coverage_root_absolute(None).is_ok());
720    }
721
722    #[test]
723    fn coverage_root_accepts_windows_absolute_on_all_hosts() {
724        assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
725    }
726}