1use 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 branching;
23pub use branching::{BranchingByFile, branching_by_file};
24mod churn_file;
25mod component_rollup;
26mod core_pipeline;
27mod coverage_gaps;
28mod coverage_intelligence;
29mod coverage_settings;
30mod css_analytics;
31mod derived_sections;
32mod execute;
33mod file_scores;
34mod filters;
35mod finding_sort;
36mod findings;
37mod findings_pipeline;
38mod framework_health;
39mod grouping;
40mod health_error;
41mod hotspots;
42mod ignore;
43mod large_functions;
44mod output_build;
45pub mod ownership;
46mod package_json;
47mod pipeline;
48mod react_hooks;
49mod result;
50mod runner;
51mod runtime_filter;
52mod runtime_sections;
53mod scope;
54pub mod scoring;
57pub mod styling_score;
58mod tailwind_theme;
59mod targets;
60mod threshold_overrides;
61mod timings;
62mod vital_data;
63mod vital_signs_scope;
64
65pub use crate::results::HealthAnalysisResult;
66pub use churn_file::validate_health_churn_file;
67pub use css_analytics::StylingAnalysisArtifacts;
68use derived_sections::{
69 HealthDerivedSectionInput, HealthDerivedSections, prepare_health_derived_sections,
70};
71use execute::HealthOptions;
72pub use execute::execute_health_inner;
73use file_scores::{
74 FileScoresAndChurnInput, compute_file_scores_and_churn, health_file_scores_slice,
75 print_slow_churn_note,
76};
77use finding_sort::sort_findings;
78pub use health_error::HealthError;
79pub use hotspots::{
80 TargetChurnEvidence, TargetChurnOptions, TargetChurnOutcome, analyze_target_churn,
81};
82pub use pipeline::{HealthPipelineInputs, HealthScopeInputs};
83pub use runner::{
84 run_ungrouped_health, run_ungrouped_health_with_session,
85 run_ungrouped_health_with_session_artifacts,
86};
87use vital_data::{HealthVitalData, HealthVitalDataInput, prepare_health_vital_data};
88use vital_signs_scope::{
89 SubsetFilter, VitalSignsAndCountsInput, apply_duplication_metrics,
90 compute_vital_signs_and_counts,
91};
92
93pub(crate) fn build_styling_analysis_artifacts(
94 files: &[crate::discover::DiscoveredFile],
95 modules: &[crate::source::ModuleInfo],
96 config: &fallow_config::ResolvedConfig,
97) -> StylingAnalysisArtifacts {
98 css_analytics::build_styling_analysis_artifacts(files, modules, config)
99}
100
101#[must_use]
103pub fn shared_parse_data_from_artifacts(
104 results: &AnalysisResults,
105 graph: Option<RetainedModuleGraph>,
106 modules: Option<Vec<crate::source::ModuleInfo>>,
107 files: Option<Vec<crate::discover::DiscoveredFile>>,
108 workspaces: Vec<WorkspaceInfo>,
109 script_used_packages: impl IntoIterator<Item = String>,
110) -> Option<HealthSharedParseData> {
111 let (Some(modules), Some(files)) = (modules, files) else {
112 return None;
113 };
114 let script_used_packages: FxHashSet<String> = script_used_packages.into_iter().collect();
115 let analysis_output = graph.map(|graph| DeadCodeAnalysisArtifacts {
116 results: results.clone(),
117 timings: None,
118 graph: Some(graph),
119 modules: None,
120 files: None,
121 script_used_packages: script_used_packages.clone(),
122 file_hashes: FxHashMap::default(),
123 });
124 Some(HealthSharedParseData {
125 files,
126 modules,
127 dead_code_results: Some(results.clone()),
128 workspaces,
129 analysis_output,
130 })
131}
132
133#[must_use]
139pub fn should_precompute_dead_code_analysis(
140 options: &HealthExecutionOptions<'_>,
141 config: &fallow_config::ResolvedConfig,
142) -> bool {
143 let max_crap = options
144 .thresholds
145 .max_crap
146 .unwrap_or(config.health.max_crap);
147 options.file_scores
148 || options.coverage_gaps
149 || options.config_activates_coverage_gaps
150 || options.hotspots
151 || options.targets
152 || options.force_full
153 || max_crap > 0.0
154 || options.runtime_coverage.is_some()
155}
156
157pub trait HealthGroupResolver {
163 fn mode_label(&self) -> &'static str;
165 fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>);
167 fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]>;
169}
170
171#[derive(Debug, Clone, Copy)]
174pub enum NoGroupResolver {}
175
176#[expect(
177 clippy::uninhabited_references,
178 reason = "NoGroupResolver is uninhabited; these methods are unreachable and exist only to satisfy the trait bound for the group-less programmatic path"
179)]
180impl HealthGroupResolver for NoGroupResolver {
181 fn mode_label(&self) -> &'static str {
182 match *self {}
183 }
184 fn resolve_with_rule(&self, _rel_path: &Path) -> (String, Option<String>) {
185 match *self {}
186 }
187 fn section_owners_of(&self, _rel_path: &Path) -> Option<&[String]> {
188 match *self {}
189 }
190}
191
192pub type RuntimeCoverageAnalyzer<'a> = dyn Fn(&RuntimeCoverageOptions, RuntimeCoverageSeamInput<'_>) -> Result<RuntimeCoverageReport, u8>
204 + 'a;
205
206pub struct RuntimeCoverageSeamInput<'a> {
208 pub root: &'a Path,
210 pub modules: &'a [fallow_types::extract::ModuleInfo],
212 pub analysis_output: &'a DeadCodeAnalysisArtifacts,
215 pub istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
217 pub file_paths: &'a rustc_hash::FxHashMap<fallow_types::discover::FileId, &'a PathBuf>,
219 pub ignore_set: &'a globset::GlobSet,
221 pub changed_files: Option<&'a rustc_hash::FxHashSet<PathBuf>>,
223 pub ws_roots: Option<&'a [PathBuf]>,
225 pub top: Option<usize>,
227 pub codeowners_path: Option<&'a str>,
229 pub quiet: bool,
231 pub output: OutputFormat,
233}
234
235pub struct HealthSeams<'a> {
239 pub runtime_coverage_analyzer: &'a RuntimeCoverageAnalyzer<'a>,
241 pub note_graph_structure: &'a dyn Fn(usize, usize),
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum HealthSort {
250 Severity,
253 Cyclomatic,
255 Cognitive,
257 Lines,
259}
260
261#[derive(Debug, Clone, Copy, Default, PartialEq)]
263pub struct HealthThresholdOverrides {
264 pub max_cyclomatic: Option<u16>,
266 pub max_cognitive: Option<u16>,
268 pub max_crap: Option<f64>,
271}
272
273#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
275pub struct HealthCoverageInputs<'a> {
276 pub coverage: Option<&'a Path>,
278 pub coverage_root: Option<&'a Path>,
281 pub coverage_relocated: bool,
287}
288
289pub fn validate_coverage_root_absolute(coverage_root: Option<&Path>) -> Result<(), String> {
296 if let Some(path) = coverage_root
297 && !is_absolute_path_any_platform(path)
298 {
299 return Err(format!(
300 "--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'.",
301 path.display()
302 ));
303 }
304 Ok(())
305}
306
307#[derive(Debug, Clone, Copy, Default, PartialEq)]
309pub struct HealthGateOptions {
310 pub min_score: Option<f64>,
312 pub min_severity: Option<FindingSeverity>,
314 pub report_only: bool,
316 pub fail_on_stale_baseline: bool,
318}
319
320#[derive(Debug, Clone)]
322pub struct HealthSectionOptions {
323 output: OutputFormat,
324 complexity: bool,
325 file_scores: bool,
326 coverage_gaps: bool,
327 hotspots: bool,
328 targets: bool,
329 css: bool,
330 score: bool,
331 score_gate: bool,
332 snapshot_requested: bool,
333 trend: bool,
334}
335
336#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338pub struct DerivedHealthSections {
339 pub any_section: bool,
342 pub complexity: bool,
344 pub file_scores: bool,
346 pub coverage_gaps: bool,
348 pub hotspots: bool,
350 pub targets: bool,
352 pub css: bool,
354 pub score: bool,
356 pub force_full: bool,
359 pub score_only_output: bool,
362}
363
364#[derive(Debug, Clone)]
367pub struct HealthRunOptionsInput<'a> {
368 pub output: OutputFormat,
370 pub thresholds: HealthThresholdOverrides,
372 pub top: Option<usize>,
374 pub sort: HealthSort,
376 pub complexity: bool,
378 pub file_scores: bool,
380 pub coverage_gaps: bool,
382 pub hotspots: bool,
384 pub ownership: bool,
386 pub ownership_emails: Option<EmailMode>,
388 pub targets: bool,
390 pub css: bool,
392 pub effort: Option<EffortEstimate>,
394 pub score: bool,
396 pub gates: HealthGateOptions,
398 pub snapshot_requested: bool,
400 pub trend: bool,
402 pub since: Option<&'a str>,
404 pub min_commits: Option<u32>,
406 pub coverage_inputs: HealthCoverageInputs<'a>,
408 pub runtime_coverage: Option<RuntimeCoverageOptions>,
410}
411
412#[derive(Debug, Clone)]
414pub struct HealthRunOptions<'a> {
415 pub thresholds: HealthThresholdOverrides,
417 pub top: Option<usize>,
419 pub sort: HealthSort,
421 pub sections: DerivedHealthSections,
423 pub ownership: bool,
425 pub ownership_emails: Option<EmailMode>,
427 pub effort: Option<EffortEstimate>,
429 pub gates: HealthGateOptions,
431 pub since: Option<&'a str>,
433 pub min_commits: Option<u32>,
435 pub coverage_inputs: HealthCoverageInputs<'a>,
437 pub runtime_coverage: Option<RuntimeCoverageOptions>,
439}
440
441#[derive(Debug, Clone)]
445pub struct HealthExecutionOptions<'a> {
446 pub root: &'a Path,
448 pub config_path: &'a Option<PathBuf>,
450 pub output: OutputFormat,
452 pub no_cache: bool,
454 pub threads: usize,
456 pub quiet: bool,
458 pub complexity_breakdown: bool,
463 pub thresholds: HealthThresholdOverrides,
465 pub top: Option<usize>,
467 pub sort: HealthSort,
469 pub production: bool,
472 pub production_override: Option<bool>,
475 pub allow_remote_extends: bool,
477 pub changed_since: Option<&'a str>,
479 pub diff_index: Option<&'a DiffIndex>,
481 pub use_shared_diff_index: bool,
484 pub workspace: Option<&'a [String]>,
486 pub changed_workspaces: Option<&'a str>,
488 pub scope: Option<PathBuf>,
493 pub baseline: Option<&'a Path>,
495 pub save_baseline: Option<&'a Path>,
497 pub baseline_mode: crate::baseline::HealthBaselineMode,
502 pub baseline_mode_explicit: bool,
508 pub complexity: bool,
510 pub file_scores: bool,
512 pub coverage_gaps: bool,
514 pub config_activates_coverage_gaps: bool,
517 pub hotspots: bool,
519 pub ownership: bool,
521 pub ownership_emails: Option<EmailMode>,
523 pub targets: bool,
525 pub css: bool,
527 pub css_deep: bool,
529 pub force_full: bool,
532 pub score_only_output: bool,
535 pub enforce_coverage_gap_gate: bool,
537 pub effort: Option<EffortEstimate>,
539 pub score: bool,
541 pub gates: HealthGateOptions,
543 pub since: Option<&'a str>,
545 pub min_commits: Option<u32>,
547 pub explain: bool,
549 pub summary: bool,
551 pub save_snapshot: Option<PathBuf>,
553 pub trend: bool,
555 pub coverage_inputs: HealthCoverageInputs<'a>,
557 pub performance: bool,
559 pub runtime_coverage: Option<RuntimeCoverageOptions>,
561 pub churn_file: Option<&'a Path>,
563 pub analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
565 pub group_by: Option<GroupByMode>,
567}
568
569#[must_use]
571fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
572 let score = options.score
573 || options.score_gate
574 || options.trend
575 || matches!(options.output, OutputFormat::Badge);
576 let any_section = options.complexity
577 || options.file_scores
578 || options.coverage_gaps
579 || options.hotspots
580 || options.targets
581 || score;
582 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
583 let force_full = options.snapshot_requested || effective_score;
584
585 DerivedHealthSections {
586 any_section,
587 complexity: if any_section {
588 options.complexity
589 } else {
590 true
591 },
592 file_scores: if any_section {
593 options.file_scores
594 } else {
595 true
596 } || force_full,
597 coverage_gaps: if any_section {
598 options.coverage_gaps
599 } else {
600 false
601 },
602 hotspots: if any_section { options.hotspots } else { true }
603 || options.snapshot_requested
604 || options.trend,
605 targets: if any_section { options.targets } else { true },
606 css: options.css,
607 score: effective_score,
608 force_full,
609 score_only_output: is_health_score_only_output(options, score),
610 }
611}
612
613#[must_use]
615pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
616 let targets = input.targets || input.effort.is_some();
617 let sections = derive_health_sections(&HealthSectionOptions {
618 output: input.output,
619 complexity: input.complexity,
620 file_scores: input.file_scores,
621 coverage_gaps: input.coverage_gaps,
622 hotspots: input.hotspots,
623 targets,
624 css: input.css,
625 score: input.score,
626 score_gate: input.gates.min_score.is_some(),
627 snapshot_requested: input.snapshot_requested,
628 trend: input.trend,
629 });
630
631 HealthRunOptions {
632 thresholds: input.thresholds,
633 top: input.top,
634 sort: input.sort,
635 sections,
636 ownership: input.ownership && sections.hotspots,
637 ownership_emails: input.ownership_emails,
638 effort: input.effort,
639 gates: input.gates,
640 since: input.since,
641 min_commits: input.min_commits,
642 coverage_inputs: input.coverage_inputs,
643 runtime_coverage: input.runtime_coverage,
644 }
645}
646
647fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
648 score
649 && !options.complexity
650 && !options.file_scores
651 && !options.coverage_gaps
652 && !options.hotspots
653 && !options.targets
654 && !options.trend
655}
656
657#[derive(Debug, Clone)]
659pub struct ComplexitySectionOptions {
660 complexity: bool,
661 file_scores: bool,
662 coverage_gaps: bool,
663 hotspots: bool,
664 ownership: bool,
665 targets: bool,
666 css: bool,
667 score: bool,
668}
669
670#[derive(Debug, Clone, Copy, PartialEq, Eq)]
672pub struct DerivedComplexityOptions {
673 any_section: bool,
674 complexity: bool,
675 file_scores: bool,
676 coverage_gaps: bool,
677 hotspots: bool,
678 ownership: bool,
679 targets: bool,
680 force_full: bool,
681 score_only_output: bool,
682 score: bool,
683}
684
685#[must_use]
687pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
688 let requested_hotspots = options.hotspots || options.ownership;
689 let sections = derive_health_sections(&HealthSectionOptions {
690 output: OutputFormat::Human,
691 complexity: options.complexity,
692 file_scores: options.file_scores,
693 coverage_gaps: options.coverage_gaps,
694 hotspots: requested_hotspots,
695 targets: options.targets,
696 css: options.css,
697 score: options.score,
698 score_gate: false,
699 snapshot_requested: false,
700 trend: false,
701 });
702
703 DerivedComplexityOptions {
704 any_section: sections.any_section,
705 complexity: sections.complexity,
706 file_scores: sections.file_scores,
707 coverage_gaps: sections.coverage_gaps,
708 hotspots: sections.hotspots,
709 ownership: options.ownership && sections.hotspots,
710 targets: sections.targets,
711 force_full: sections.force_full,
712 score_only_output: sections.score_only_output,
713 score: sections.score,
714 }
715}
716
717#[derive(Debug, Clone, PartialEq)]
720pub struct ComplexityRunOptions<'a> {
721 thresholds: HealthThresholdOverrides,
722 top: Option<usize>,
723 sort: HealthSort,
724 complexity_breakdown: bool,
725 sections: DerivedComplexityOptions,
726 ownership_emails: Option<EmailMode>,
727 effort: Option<EffortEstimate>,
728 css: bool,
729 since: Option<&'a str>,
730 min_commits: Option<u32>,
731 coverage_inputs: HealthCoverageInputs<'a>,
732}
733
734#[derive(Debug, Clone)]
736pub struct RuntimeCoverageOptions {
737 pub path: PathBuf,
739 pub min_invocations_hot: u64,
741 pub min_observation_volume: Option<u32>,
746 pub low_traffic_threshold: Option<f64>,
750 pub license_jwt: String,
752 pub watermark: Option<RuntimeCoverageWatermark>,
754}
755
756pub struct HealthSharedParseData {
758 pub files: Vec<fallow_types::discover::DiscoveredFile>,
760 pub modules: Vec<fallow_types::extract::ModuleInfo>,
762 pub dead_code_results: Option<AnalysisResults>,
764 pub workspaces: Vec<WorkspaceInfo>,
766 pub analysis_output: Option<DeadCodeAnalysisArtifacts>,
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773
774 fn health_run_input() -> HealthRunOptionsInput<'static> {
775 HealthRunOptionsInput {
776 output: OutputFormat::Json,
777 thresholds: HealthThresholdOverrides::default(),
778 top: None,
779 sort: HealthSort::Cyclomatic,
780 complexity: false,
781 file_scores: false,
782 coverage_gaps: false,
783 hotspots: false,
784 ownership: false,
785 ownership_emails: None,
786 targets: false,
787 css: false,
788 effort: None,
789 score: false,
790 gates: HealthGateOptions::default(),
791 snapshot_requested: false,
792 trend: false,
793 since: None,
794 min_commits: None,
795 coverage_inputs: HealthCoverageInputs::default(),
796 runtime_coverage: None,
797 }
798 }
799
800 #[test]
801 fn health_execution_options_own_shared_runner_scope() {
802 let root = Path::new("/project");
803 let config_path = None;
804 let workspace = vec!["packages/app".to_string()];
805 let diff = DiffIndex::from_unified_diff(
806 "diff --git a/src/a.ts b/src/a.ts\n\
807 --- a/src/a.ts\n\
808 +++ b/src/a.ts\n\
809 @@ -0,0 +1,1 @@\n\
810 +new line\n",
811 );
812 let runtime_coverage = RuntimeCoverageOptions {
813 path: PathBuf::from("coverage/v8"),
814 min_invocations_hot: 10,
815 min_observation_volume: Some(500),
816 low_traffic_threshold: Some(0.01),
817 license_jwt: "test.jwt".to_string(),
818 watermark: None,
819 };
820
821 let options = HealthExecutionOptions {
822 root,
823 config_path: &config_path,
824 output: OutputFormat::Json,
825 no_cache: true,
826 threads: 2,
827 quiet: true,
828 complexity_breakdown: true,
829 thresholds: HealthThresholdOverrides::default(),
830 top: Some(5),
831 sort: HealthSort::Cognitive,
832 production: true,
833 production_override: Some(true),
834 allow_remote_extends: false,
835 changed_since: Some("HEAD~1"),
836 diff_index: Some(&diff),
837 use_shared_diff_index: false,
838 workspace: Some(&workspace),
839 changed_workspaces: None,
840 scope: None,
841 baseline: Some(Path::new(".fallow/health-baseline.json")),
842 save_baseline: None,
843 baseline_mode: crate::baseline::HealthBaselineMode::Count,
844 baseline_mode_explicit: false,
845 complexity: true,
846 file_scores: true,
847 coverage_gaps: false,
848 config_activates_coverage_gaps: false,
849 hotspots: true,
850 ownership: false,
851 ownership_emails: None,
852 targets: true,
853 css: false,
854 css_deep: false,
855 force_full: true,
856 score_only_output: false,
857 enforce_coverage_gap_gate: true,
858 effort: Some(EffortEstimate::Low),
859 score: true,
860 gates: HealthGateOptions {
861 min_score: Some(80.0),
862 min_severity: None,
863 report_only: false,
864 fail_on_stale_baseline: false,
865 },
866 since: Some("30d"),
867 min_commits: Some(2),
868 explain: true,
869 summary: false,
870 save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
871 trend: true,
872 coverage_inputs: HealthCoverageInputs::default(),
873 performance: true,
874 runtime_coverage: Some(runtime_coverage),
875 churn_file: Some(Path::new("churn.json")),
876 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
877 group_by: Some(GroupByMode::Directory),
878 };
879
880 assert_eq!(options.root, root);
881 assert!(
882 options
883 .diff_index
884 .is_some_and(|index| index.line_is_added("src/a.ts", 1))
885 );
886 assert_eq!(options.workspace, Some(workspace.as_slice()));
887 assert!(options.runtime_coverage.is_some());
888 assert_eq!(options.group_by, Some(GroupByMode::Directory));
889 assert_eq!(
890 options.save_snapshot.as_deref(),
891 Some(Path::new(".fallow/snapshots/health.json"))
892 );
893 }
894
895 #[test]
896 fn health_run_options_default_sections_match_health_defaults() {
897 let run = derive_health_run_options(health_run_input());
898
899 assert!(run.sections.complexity);
900 assert!(run.sections.file_scores);
901 assert!(run.sections.hotspots);
902 assert!(run.sections.targets);
903 assert!(run.sections.score);
904 assert!(!run.ownership);
905 }
906
907 #[test]
908 fn health_run_options_effort_requests_targets() {
909 let mut input = health_run_input();
910 input.effort = Some(EffortEstimate::Low);
911
912 let run = derive_health_run_options(input);
913
914 assert!(run.sections.targets);
915 assert_eq!(run.effort, Some(EffortEstimate::Low));
916 }
917
918 struct HealthExecutionOptionsFixture {
919 config_path: Option<PathBuf>,
920 }
921
922 impl HealthExecutionOptionsFixture {
923 const fn new() -> Self {
924 Self { config_path: None }
925 }
926
927 fn options<'a>(&'a self, root: &'a Path) -> HealthExecutionOptions<'a> {
928 HealthExecutionOptions {
929 root,
930 config_path: &self.config_path,
931 output: OutputFormat::Human,
932 no_cache: true,
933 threads: 1,
934 quiet: true,
935 complexity_breakdown: false,
936 thresholds: HealthThresholdOverrides::default(),
937 top: None,
938 sort: HealthSort::Cyclomatic,
939 production: false,
940 production_override: None,
941 allow_remote_extends: false,
942 changed_since: None,
943 diff_index: None,
944 use_shared_diff_index: false,
945 workspace: None,
946 changed_workspaces: None,
947 scope: None,
948 baseline: None,
949 save_baseline: None,
950 baseline_mode: crate::baseline::HealthBaselineMode::Count,
951 baseline_mode_explicit: false,
952 complexity: true,
953 file_scores: false,
954 coverage_gaps: false,
955 config_activates_coverage_gaps: false,
956 hotspots: false,
957 ownership: false,
958 ownership_emails: None,
959 targets: false,
960 css: false,
961 css_deep: false,
962 force_full: false,
963 score_only_output: false,
964 enforce_coverage_gap_gate: true,
965 effort: None,
966 score: false,
967 gates: HealthGateOptions::default(),
968 since: None,
969 min_commits: None,
970 explain: false,
971 summary: false,
972 save_snapshot: None,
973 trend: false,
974 coverage_inputs: HealthCoverageInputs::default(),
975 performance: false,
976 runtime_coverage: None,
977 churn_file: None,
978 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
979 group_by: None,
980 }
981 }
982 }
983
984 #[test]
985 fn standalone_health_precomputes_dead_code_when_default_crap_can_use_graph() {
986 let project = tempfile::tempdir().expect("temp dir");
987 let fixture = HealthExecutionOptionsFixture::new();
988 let options = fixture.options(project.path());
989 let config = crate::project_config::default_project_config(project.path()).config;
990
991 assert!(should_precompute_dead_code_analysis(&options, &config));
992 }
993
994 #[test]
995 fn standalone_health_skips_precompute_when_no_section_needs_analysis_artifacts() {
996 let project = tempfile::tempdir().expect("temp dir");
997 let fixture = HealthExecutionOptionsFixture::new();
998 let mut options = fixture.options(project.path());
999 options.thresholds.max_crap = Some(0.0);
1000 let config = crate::project_config::default_project_config(project.path()).config;
1001
1002 assert!(!should_precompute_dead_code_analysis(&options, &config));
1003 }
1004
1005 #[test]
1006 fn standalone_health_precomputes_dead_code_for_target_sections() {
1007 let project = tempfile::tempdir().expect("temp dir");
1008 let fixture = HealthExecutionOptionsFixture::new();
1009 let mut options = fixture.options(project.path());
1010 options.thresholds.max_crap = Some(0.0);
1011 options.targets = true;
1012 let config = crate::project_config::default_project_config(project.path()).config;
1013
1014 assert!(should_precompute_dead_code_analysis(&options, &config));
1015 }
1016
1017 #[test]
1018 fn health_run_options_ownership_requires_hotspots() {
1019 let mut input = health_run_input();
1020 input.complexity = true;
1021 input.ownership = true;
1022
1023 let run = derive_health_run_options(input);
1024
1025 assert!(!run.sections.hotspots);
1026 assert!(!run.ownership);
1027
1028 let mut input = health_run_input();
1029 input.ownership = true;
1030 input.hotspots = true;
1031
1032 let run = derive_health_run_options(input);
1033
1034 assert!(run.sections.hotspots);
1035 assert!(run.ownership);
1036 }
1037
1038 #[test]
1039 fn health_run_options_score_gate_forces_score() {
1040 let mut input = health_run_input();
1041 input.gates.min_score = Some(90.0);
1042
1043 let run = derive_health_run_options(input);
1044
1045 assert!(run.sections.score);
1046 assert_eq!(run.gates.min_score, Some(90.0));
1047 }
1048
1049 #[test]
1050 fn coverage_root_accepts_posix_absolute() {
1051 assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
1052 assert!(
1053 validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
1054 );
1055 }
1056
1057 #[test]
1058 fn coverage_root_rejects_relative() {
1059 assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
1060 assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
1061 assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
1062 }
1063
1064 #[test]
1065 fn coverage_root_accepts_none() {
1066 assert!(validate_coverage_root_absolute(None).is_ok());
1067 }
1068
1069 #[test]
1070 fn coverage_root_accepts_windows_absolute_on_all_hosts() {
1071 assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
1072 }
1073}