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 allow_remote_extends: bool,
378    pub changed_since: Option<&'a str>,
379    pub diff_index: Option<&'a DiffIndex>,
380    pub use_shared_diff_index: bool,
381    pub workspace: Option<&'a [String]>,
382    pub changed_workspaces: Option<&'a str>,
383    pub baseline: Option<&'a Path>,
384    pub save_baseline: Option<&'a Path>,
385    pub complexity: bool,
386    pub file_scores: bool,
387    pub coverage_gaps: bool,
388    pub config_activates_coverage_gaps: bool,
389    pub hotspots: bool,
390    pub ownership: bool,
391    pub ownership_emails: Option<EmailMode>,
392    pub targets: bool,
393    pub css: bool,
394    pub css_deep: bool,
395    pub force_full: bool,
396    pub score_only_output: bool,
397    pub enforce_coverage_gap_gate: bool,
398    pub effort: Option<EffortEstimate>,
399    pub score: bool,
400    pub gates: HealthGateOptions,
401    pub since: Option<&'a str>,
402    pub min_commits: Option<u32>,
403    pub explain: bool,
404    pub summary: bool,
405    pub save_snapshot: Option<PathBuf>,
406    pub trend: bool,
407    pub coverage_inputs: HealthCoverageInputs<'a>,
408    pub performance: bool,
409    pub runtime_coverage: Option<RuntimeCoverageOptions>,
410    pub churn_file: Option<&'a Path>,
411    /// Optional grouping mode for typed health output.
412    pub group_by: Option<GroupByMode>,
413}
414
415/// Derive effective health section flags for CLI and embedders.
416#[must_use]
417pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
418    let score = options.score
419        || options.score_gate
420        || options.trend
421        || matches!(options.output, OutputFormat::Badge);
422    let any_section = options.complexity
423        || options.file_scores
424        || options.coverage_gaps
425        || options.hotspots
426        || options.targets
427        || score;
428    let effective_score = if any_section { score } else { true } || options.snapshot_requested;
429    let force_full = options.snapshot_requested || effective_score;
430
431    DerivedHealthSections {
432        any_section,
433        complexity: if any_section {
434            options.complexity
435        } else {
436            true
437        },
438        file_scores: if any_section {
439            options.file_scores
440        } else {
441            true
442        } || force_full,
443        coverage_gaps: if any_section {
444            options.coverage_gaps
445        } else {
446            false
447        },
448        hotspots: if any_section { options.hotspots } else { true }
449            || options.snapshot_requested
450            || options.trend,
451        targets: if any_section { options.targets } else { true },
452        css: options.css,
453        score: effective_score,
454        force_full,
455        score_only_output: is_health_score_only_output(options, score),
456    }
457}
458
459/// Normalize health run inputs into the engine-owned run contract.
460#[must_use]
461pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
462    let targets = input.targets || input.effort.is_some();
463    let sections = derive_health_sections(&HealthSectionOptions {
464        output: input.output,
465        complexity: input.complexity,
466        file_scores: input.file_scores,
467        coverage_gaps: input.coverage_gaps,
468        hotspots: input.hotspots,
469        targets,
470        css: input.css,
471        score: input.score,
472        score_gate: input.gates.min_score.is_some(),
473        snapshot_requested: input.snapshot_requested,
474        trend: input.trend,
475    });
476
477    HealthRunOptions {
478        thresholds: input.thresholds,
479        top: input.top,
480        sort: input.sort,
481        sections,
482        ownership: input.ownership && sections.hotspots,
483        ownership_emails: input.ownership_emails,
484        effort: input.effort,
485        gates: input.gates,
486        since: input.since,
487        min_commits: input.min_commits,
488        coverage_inputs: input.coverage_inputs,
489        runtime_coverage: input.runtime_coverage,
490    }
491}
492
493fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
494    score
495        && !options.complexity
496        && !options.file_scores
497        && !options.coverage_gaps
498        && !options.hotspots
499        && !options.targets
500        && !options.trend
501}
502
503/// Input for deriving effective programmatic complexity sections.
504#[derive(Debug, Clone)]
505pub struct ComplexitySectionOptions {
506    pub complexity: bool,
507    pub file_scores: bool,
508    pub coverage_gaps: bool,
509    pub hotspots: bool,
510    pub ownership: bool,
511    pub targets: bool,
512    pub css: bool,
513    pub score: bool,
514}
515
516/// Derived section selection for programmatic health / complexity runs.
517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
518pub struct DerivedComplexityOptions {
519    pub any_section: bool,
520    pub complexity: bool,
521    pub file_scores: bool,
522    pub coverage_gaps: bool,
523    pub hotspots: bool,
524    pub ownership: bool,
525    pub targets: bool,
526    pub force_full: bool,
527    pub score_only_output: bool,
528    pub score: bool,
529}
530
531/// Derive effective programmatic health / complexity section flags.
532#[must_use]
533pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
534    let requested_hotspots = options.hotspots || options.ownership;
535    let sections = derive_health_sections(&HealthSectionOptions {
536        output: OutputFormat::Human,
537        complexity: options.complexity,
538        file_scores: options.file_scores,
539        coverage_gaps: options.coverage_gaps,
540        hotspots: requested_hotspots,
541        targets: options.targets,
542        css: options.css,
543        score: options.score,
544        score_gate: false,
545        snapshot_requested: false,
546        trend: false,
547    });
548
549    DerivedComplexityOptions {
550        any_section: sections.any_section,
551        complexity: sections.complexity,
552        file_scores: sections.file_scores,
553        coverage_gaps: sections.coverage_gaps,
554        hotspots: sections.hotspots,
555        ownership: options.ownership && sections.hotspots,
556        targets: sections.targets,
557        force_full: sections.force_full,
558        score_only_output: sections.score_only_output,
559        score: sections.score,
560    }
561}
562
563/// Normalized programmatic complexity / health inputs shared by API, NAPI, and
564/// engine-backed runners.
565#[derive(Debug, Clone, PartialEq)]
566pub struct ComplexityRunOptions<'a> {
567    pub thresholds: HealthThresholdOverrides,
568    pub top: Option<usize>,
569    pub sort: HealthSort,
570    pub complexity_breakdown: bool,
571    pub sections: DerivedComplexityOptions,
572    pub ownership_emails: Option<EmailMode>,
573    pub effort: Option<EffortEstimate>,
574    pub css: bool,
575    pub since: Option<&'a str>,
576    pub min_commits: Option<u32>,
577    pub coverage_inputs: HealthCoverageInputs<'a>,
578}
579
580/// Command-neutral runtime coverage input for health analysis.
581#[derive(Debug, Clone)]
582pub struct RuntimeCoverageOptions {
583    pub path: PathBuf,
584    pub min_invocations_hot: u64,
585    /// Minimum total trace volume before high-confidence `safe_to_delete` /
586    /// `review_required` verdicts may be emitted. Below this the sidecar caps
587    /// confidence at `medium`. `None` lets the sidecar use its spec-default
588    /// (5000).
589    pub min_observation_volume: Option<u32>,
590    /// Fraction of total trace count below which an invoked function is
591    /// classified as `low_traffic` rather than `active`. `None` lets the
592    /// sidecar use its spec-default (0.001 = 0.1%).
593    pub low_traffic_threshold: Option<f64>,
594    pub license_jwt: String,
595    pub watermark: Option<RuntimeCoverageWatermark>,
596}
597
598/// Pre-parsed health input reused from another analysis in the same process.
599pub struct HealthSharedParseData {
600    pub files: Vec<fallow_types::discover::DiscoveredFile>,
601    pub modules: Vec<fallow_types::extract::ModuleInfo>,
602    /// Dead-code results reused by advisory health surfaces that do not need the graph.
603    pub dead_code_results: Option<AnalysisResults>,
604    pub workspaces: Vec<WorkspaceInfo>,
605    /// Full analysis output (graph + results) for file scoring.
606    pub analysis_output: Option<DeadCodeAnalysisArtifacts>,
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    fn health_run_input() -> HealthRunOptionsInput<'static> {
614        HealthRunOptionsInput {
615            output: OutputFormat::Json,
616            thresholds: HealthThresholdOverrides::default(),
617            top: None,
618            sort: HealthSort::Cyclomatic,
619            complexity: false,
620            file_scores: false,
621            coverage_gaps: false,
622            hotspots: false,
623            ownership: false,
624            ownership_emails: None,
625            targets: false,
626            css: false,
627            effort: None,
628            score: false,
629            gates: HealthGateOptions::default(),
630            snapshot_requested: false,
631            trend: false,
632            since: None,
633            min_commits: None,
634            coverage_inputs: HealthCoverageInputs::default(),
635            runtime_coverage: None,
636        }
637    }
638
639    #[test]
640    fn health_execution_options_own_shared_runner_scope() {
641        let root = Path::new("/project");
642        let config_path = None;
643        let workspace = vec!["packages/app".to_string()];
644        let diff = DiffIndex::from_unified_diff(
645            "diff --git a/src/a.ts b/src/a.ts\n\
646             --- a/src/a.ts\n\
647             +++ b/src/a.ts\n\
648             @@ -0,0 +1,1 @@\n\
649             +new line\n",
650        );
651        let runtime_coverage = RuntimeCoverageOptions {
652            path: PathBuf::from("coverage/v8"),
653            min_invocations_hot: 10,
654            min_observation_volume: Some(500),
655            low_traffic_threshold: Some(0.01),
656            license_jwt: "test.jwt".to_string(),
657            watermark: None,
658        };
659
660        let options = HealthExecutionOptions {
661            root,
662            config_path: &config_path,
663            output: OutputFormat::Json,
664            no_cache: true,
665            threads: 2,
666            quiet: true,
667            complexity_breakdown: true,
668            thresholds: HealthThresholdOverrides::default(),
669            top: Some(5),
670            sort: HealthSort::Cognitive,
671            production: true,
672            production_override: Some(true),
673            allow_remote_extends: false,
674            changed_since: Some("HEAD~1"),
675            diff_index: Some(&diff),
676            use_shared_diff_index: false,
677            workspace: Some(&workspace),
678            changed_workspaces: None,
679            baseline: Some(Path::new(".fallow/health-baseline.json")),
680            save_baseline: None,
681            complexity: true,
682            file_scores: true,
683            coverage_gaps: false,
684            config_activates_coverage_gaps: false,
685            hotspots: true,
686            ownership: false,
687            ownership_emails: None,
688            targets: true,
689            css: false,
690            css_deep: false,
691            force_full: true,
692            score_only_output: false,
693            enforce_coverage_gap_gate: true,
694            effort: Some(EffortEstimate::Low),
695            score: true,
696            gates: HealthGateOptions {
697                min_score: Some(80.0),
698                min_severity: None,
699                report_only: false,
700            },
701            since: Some("30d"),
702            min_commits: Some(2),
703            explain: true,
704            summary: false,
705            save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
706            trend: true,
707            coverage_inputs: HealthCoverageInputs::default(),
708            performance: true,
709            runtime_coverage: Some(runtime_coverage),
710            churn_file: Some(Path::new("churn.json")),
711            group_by: Some(GroupByMode::Directory),
712        };
713
714        assert_eq!(options.root, root);
715        assert!(
716            options
717                .diff_index
718                .is_some_and(|index| index.line_is_added("src/a.ts", 1))
719        );
720        assert_eq!(options.workspace, Some(workspace.as_slice()));
721        assert!(options.runtime_coverage.is_some());
722        assert_eq!(options.group_by, Some(GroupByMode::Directory));
723        assert_eq!(
724            options.save_snapshot.as_deref(),
725            Some(Path::new(".fallow/snapshots/health.json"))
726        );
727    }
728
729    #[test]
730    fn health_run_options_default_sections_match_health_defaults() {
731        let run = derive_health_run_options(health_run_input());
732
733        assert!(run.sections.complexity);
734        assert!(run.sections.file_scores);
735        assert!(run.sections.hotspots);
736        assert!(run.sections.targets);
737        assert!(run.sections.score);
738        assert!(!run.ownership);
739    }
740
741    #[test]
742    fn health_run_options_effort_requests_targets() {
743        let mut input = health_run_input();
744        input.effort = Some(EffortEstimate::Low);
745
746        let run = derive_health_run_options(input);
747
748        assert!(run.sections.targets);
749        assert_eq!(run.effort, Some(EffortEstimate::Low));
750    }
751
752    struct HealthExecutionOptionsFixture {
753        config_path: Option<PathBuf>,
754    }
755
756    impl HealthExecutionOptionsFixture {
757        const fn new() -> Self {
758            Self { config_path: None }
759        }
760
761        fn options<'a>(&'a self, root: &'a Path) -> HealthExecutionOptions<'a> {
762            HealthExecutionOptions {
763                root,
764                config_path: &self.config_path,
765                output: OutputFormat::Human,
766                no_cache: true,
767                threads: 1,
768                quiet: true,
769                complexity_breakdown: false,
770                thresholds: HealthThresholdOverrides::default(),
771                top: None,
772                sort: HealthSort::Cyclomatic,
773                production: false,
774                production_override: None,
775                allow_remote_extends: false,
776                changed_since: None,
777                diff_index: None,
778                use_shared_diff_index: false,
779                workspace: None,
780                changed_workspaces: None,
781                baseline: None,
782                save_baseline: None,
783                complexity: true,
784                file_scores: false,
785                coverage_gaps: false,
786                config_activates_coverage_gaps: false,
787                hotspots: false,
788                ownership: false,
789                ownership_emails: None,
790                targets: false,
791                css: false,
792                css_deep: false,
793                force_full: false,
794                score_only_output: false,
795                enforce_coverage_gap_gate: true,
796                effort: None,
797                score: false,
798                gates: HealthGateOptions::default(),
799                since: None,
800                min_commits: None,
801                explain: false,
802                summary: false,
803                save_snapshot: None,
804                trend: false,
805                coverage_inputs: HealthCoverageInputs::default(),
806                performance: false,
807                runtime_coverage: None,
808                churn_file: None,
809                group_by: None,
810            }
811        }
812    }
813
814    #[test]
815    fn standalone_health_precomputes_dead_code_when_default_crap_can_use_graph() {
816        let project = tempfile::tempdir().expect("temp dir");
817        let fixture = HealthExecutionOptionsFixture::new();
818        let options = fixture.options(project.path());
819        let config = crate::project_config::default_project_config(project.path()).config;
820
821        assert!(should_precompute_dead_code_analysis(&options, &config));
822    }
823
824    #[test]
825    fn standalone_health_skips_precompute_when_no_section_needs_analysis_artifacts() {
826        let project = tempfile::tempdir().expect("temp dir");
827        let fixture = HealthExecutionOptionsFixture::new();
828        let mut options = fixture.options(project.path());
829        options.thresholds.max_crap = Some(0.0);
830        let config = crate::project_config::default_project_config(project.path()).config;
831
832        assert!(!should_precompute_dead_code_analysis(&options, &config));
833    }
834
835    #[test]
836    fn standalone_health_precomputes_dead_code_for_target_sections() {
837        let project = tempfile::tempdir().expect("temp dir");
838        let fixture = HealthExecutionOptionsFixture::new();
839        let mut options = fixture.options(project.path());
840        options.thresholds.max_crap = Some(0.0);
841        options.targets = true;
842        let config = crate::project_config::default_project_config(project.path()).config;
843
844        assert!(should_precompute_dead_code_analysis(&options, &config));
845    }
846
847    #[test]
848    fn health_run_options_ownership_requires_hotspots() {
849        let mut input = health_run_input();
850        input.complexity = true;
851        input.ownership = true;
852
853        let run = derive_health_run_options(input);
854
855        assert!(!run.sections.hotspots);
856        assert!(!run.ownership);
857
858        let mut input = health_run_input();
859        input.ownership = true;
860        input.hotspots = true;
861
862        let run = derive_health_run_options(input);
863
864        assert!(run.sections.hotspots);
865        assert!(run.ownership);
866    }
867
868    #[test]
869    fn health_run_options_score_gate_forces_score() {
870        let mut input = health_run_input();
871        input.gates.min_score = Some(90.0);
872
873        let run = derive_health_run_options(input);
874
875        assert!(run.sections.score);
876        assert_eq!(run.gates.min_score, Some(90.0));
877    }
878
879    #[test]
880    fn coverage_root_accepts_posix_absolute() {
881        assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
882        assert!(
883            validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
884        );
885    }
886
887    #[test]
888    fn coverage_root_rejects_relative() {
889        assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
890        assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
891        assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
892    }
893
894    #[test]
895    fn coverage_root_accepts_none() {
896        assert!(validate_coverage_root_absolute(None).is_ok());
897    }
898
899    #[test]
900    fn coverage_root_accepts_windows_absolute_on_all_hosts() {
901        assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
902    }
903}