Skip to main content

fallow_engine/health/
mod.rs

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