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