Skip to main content

fallow_engine/health/
mod.rs

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