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