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}
317
318#[derive(Debug, Clone)]
320pub struct HealthSectionOptions {
321 output: OutputFormat,
322 complexity: bool,
323 file_scores: bool,
324 coverage_gaps: bool,
325 hotspots: bool,
326 targets: bool,
327 css: bool,
328 score: bool,
329 score_gate: bool,
330 snapshot_requested: bool,
331 trend: bool,
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub struct DerivedHealthSections {
337 pub any_section: bool,
340 pub complexity: bool,
342 pub file_scores: bool,
344 pub coverage_gaps: bool,
346 pub hotspots: bool,
348 pub targets: bool,
350 pub css: bool,
352 pub score: bool,
354 pub force_full: bool,
357 pub score_only_output: bool,
360}
361
362#[derive(Debug, Clone)]
365pub struct HealthRunOptionsInput<'a> {
366 pub output: OutputFormat,
368 pub thresholds: HealthThresholdOverrides,
370 pub top: Option<usize>,
372 pub sort: HealthSort,
374 pub complexity: bool,
376 pub file_scores: bool,
378 pub coverage_gaps: bool,
380 pub hotspots: bool,
382 pub ownership: bool,
384 pub ownership_emails: Option<EmailMode>,
386 pub targets: bool,
388 pub css: bool,
390 pub effort: Option<EffortEstimate>,
392 pub score: bool,
394 pub gates: HealthGateOptions,
396 pub snapshot_requested: bool,
398 pub trend: bool,
400 pub since: Option<&'a str>,
402 pub min_commits: Option<u32>,
404 pub coverage_inputs: HealthCoverageInputs<'a>,
406 pub runtime_coverage: Option<RuntimeCoverageOptions>,
408}
409
410#[derive(Debug, Clone)]
412pub struct HealthRunOptions<'a> {
413 pub thresholds: HealthThresholdOverrides,
415 pub top: Option<usize>,
417 pub sort: HealthSort,
419 pub sections: DerivedHealthSections,
421 pub ownership: bool,
423 pub ownership_emails: Option<EmailMode>,
425 pub effort: Option<EffortEstimate>,
427 pub gates: HealthGateOptions,
429 pub since: Option<&'a str>,
431 pub min_commits: Option<u32>,
433 pub coverage_inputs: HealthCoverageInputs<'a>,
435 pub runtime_coverage: Option<RuntimeCoverageOptions>,
437}
438
439#[derive(Debug, Clone)]
443pub struct HealthExecutionOptions<'a> {
444 pub root: &'a Path,
446 pub config_path: &'a Option<PathBuf>,
448 pub output: OutputFormat,
450 pub no_cache: bool,
452 pub threads: usize,
454 pub quiet: bool,
456 pub complexity_breakdown: bool,
461 pub thresholds: HealthThresholdOverrides,
463 pub top: Option<usize>,
465 pub sort: HealthSort,
467 pub production: bool,
470 pub production_override: Option<bool>,
473 pub allow_remote_extends: bool,
475 pub changed_since: Option<&'a str>,
477 pub diff_index: Option<&'a DiffIndex>,
479 pub use_shared_diff_index: bool,
482 pub workspace: Option<&'a [String]>,
484 pub changed_workspaces: Option<&'a str>,
486 pub scope: Option<PathBuf>,
491 pub baseline: Option<&'a Path>,
493 pub save_baseline: Option<&'a Path>,
495 pub baseline_mode: crate::baseline::HealthBaselineMode,
500 pub baseline_mode_explicit: bool,
506 pub complexity: bool,
508 pub file_scores: bool,
510 pub coverage_gaps: bool,
512 pub config_activates_coverage_gaps: bool,
515 pub hotspots: bool,
517 pub ownership: bool,
519 pub ownership_emails: Option<EmailMode>,
521 pub targets: bool,
523 pub css: bool,
525 pub css_deep: bool,
527 pub force_full: bool,
530 pub score_only_output: bool,
533 pub enforce_coverage_gap_gate: bool,
535 pub effort: Option<EffortEstimate>,
537 pub score: bool,
539 pub gates: HealthGateOptions,
541 pub since: Option<&'a str>,
543 pub min_commits: Option<u32>,
545 pub explain: bool,
547 pub summary: bool,
549 pub save_snapshot: Option<PathBuf>,
551 pub trend: bool,
553 pub coverage_inputs: HealthCoverageInputs<'a>,
555 pub performance: bool,
557 pub runtime_coverage: Option<RuntimeCoverageOptions>,
559 pub churn_file: Option<&'a Path>,
561 pub analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
563 pub group_by: Option<GroupByMode>,
565}
566
567#[must_use]
569fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
570 let score = options.score
571 || options.score_gate
572 || options.trend
573 || matches!(options.output, OutputFormat::Badge);
574 let any_section = options.complexity
575 || options.file_scores
576 || options.coverage_gaps
577 || options.hotspots
578 || options.targets
579 || score;
580 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
581 let force_full = options.snapshot_requested || effective_score;
582
583 DerivedHealthSections {
584 any_section,
585 complexity: if any_section {
586 options.complexity
587 } else {
588 true
589 },
590 file_scores: if any_section {
591 options.file_scores
592 } else {
593 true
594 } || force_full,
595 coverage_gaps: if any_section {
596 options.coverage_gaps
597 } else {
598 false
599 },
600 hotspots: if any_section { options.hotspots } else { true }
601 || options.snapshot_requested
602 || options.trend,
603 targets: if any_section { options.targets } else { true },
604 css: options.css,
605 score: effective_score,
606 force_full,
607 score_only_output: is_health_score_only_output(options, score),
608 }
609}
610
611#[must_use]
613pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
614 let targets = input.targets || input.effort.is_some();
615 let sections = derive_health_sections(&HealthSectionOptions {
616 output: input.output,
617 complexity: input.complexity,
618 file_scores: input.file_scores,
619 coverage_gaps: input.coverage_gaps,
620 hotspots: input.hotspots,
621 targets,
622 css: input.css,
623 score: input.score,
624 score_gate: input.gates.min_score.is_some(),
625 snapshot_requested: input.snapshot_requested,
626 trend: input.trend,
627 });
628
629 HealthRunOptions {
630 thresholds: input.thresholds,
631 top: input.top,
632 sort: input.sort,
633 sections,
634 ownership: input.ownership && sections.hotspots,
635 ownership_emails: input.ownership_emails,
636 effort: input.effort,
637 gates: input.gates,
638 since: input.since,
639 min_commits: input.min_commits,
640 coverage_inputs: input.coverage_inputs,
641 runtime_coverage: input.runtime_coverage,
642 }
643}
644
645fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
646 score
647 && !options.complexity
648 && !options.file_scores
649 && !options.coverage_gaps
650 && !options.hotspots
651 && !options.targets
652 && !options.trend
653}
654
655#[derive(Debug, Clone)]
657pub struct ComplexitySectionOptions {
658 complexity: bool,
659 file_scores: bool,
660 coverage_gaps: bool,
661 hotspots: bool,
662 ownership: bool,
663 targets: bool,
664 css: bool,
665 score: bool,
666}
667
668#[derive(Debug, Clone, Copy, PartialEq, Eq)]
670pub struct DerivedComplexityOptions {
671 any_section: bool,
672 complexity: bool,
673 file_scores: bool,
674 coverage_gaps: bool,
675 hotspots: bool,
676 ownership: bool,
677 targets: bool,
678 force_full: bool,
679 score_only_output: bool,
680 score: bool,
681}
682
683#[must_use]
685pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
686 let requested_hotspots = options.hotspots || options.ownership;
687 let sections = derive_health_sections(&HealthSectionOptions {
688 output: OutputFormat::Human,
689 complexity: options.complexity,
690 file_scores: options.file_scores,
691 coverage_gaps: options.coverage_gaps,
692 hotspots: requested_hotspots,
693 targets: options.targets,
694 css: options.css,
695 score: options.score,
696 score_gate: false,
697 snapshot_requested: false,
698 trend: false,
699 });
700
701 DerivedComplexityOptions {
702 any_section: sections.any_section,
703 complexity: sections.complexity,
704 file_scores: sections.file_scores,
705 coverage_gaps: sections.coverage_gaps,
706 hotspots: sections.hotspots,
707 ownership: options.ownership && sections.hotspots,
708 targets: sections.targets,
709 force_full: sections.force_full,
710 score_only_output: sections.score_only_output,
711 score: sections.score,
712 }
713}
714
715#[derive(Debug, Clone, PartialEq)]
718pub struct ComplexityRunOptions<'a> {
719 thresholds: HealthThresholdOverrides,
720 top: Option<usize>,
721 sort: HealthSort,
722 complexity_breakdown: bool,
723 sections: DerivedComplexityOptions,
724 ownership_emails: Option<EmailMode>,
725 effort: Option<EffortEstimate>,
726 css: bool,
727 since: Option<&'a str>,
728 min_commits: Option<u32>,
729 coverage_inputs: HealthCoverageInputs<'a>,
730}
731
732#[derive(Debug, Clone)]
734pub struct RuntimeCoverageOptions {
735 pub path: PathBuf,
737 pub min_invocations_hot: u64,
739 pub min_observation_volume: Option<u32>,
744 pub low_traffic_threshold: Option<f64>,
748 pub license_jwt: String,
750 pub watermark: Option<RuntimeCoverageWatermark>,
752}
753
754pub struct HealthSharedParseData {
756 pub files: Vec<fallow_types::discover::DiscoveredFile>,
758 pub modules: Vec<fallow_types::extract::ModuleInfo>,
760 pub dead_code_results: Option<AnalysisResults>,
762 pub workspaces: Vec<WorkspaceInfo>,
764 pub analysis_output: Option<DeadCodeAnalysisArtifacts>,
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771
772 fn health_run_input() -> HealthRunOptionsInput<'static> {
773 HealthRunOptionsInput {
774 output: OutputFormat::Json,
775 thresholds: HealthThresholdOverrides::default(),
776 top: None,
777 sort: HealthSort::Cyclomatic,
778 complexity: false,
779 file_scores: false,
780 coverage_gaps: false,
781 hotspots: false,
782 ownership: false,
783 ownership_emails: None,
784 targets: false,
785 css: false,
786 effort: None,
787 score: false,
788 gates: HealthGateOptions::default(),
789 snapshot_requested: false,
790 trend: false,
791 since: None,
792 min_commits: None,
793 coverage_inputs: HealthCoverageInputs::default(),
794 runtime_coverage: None,
795 }
796 }
797
798 #[test]
799 fn health_execution_options_own_shared_runner_scope() {
800 let root = Path::new("/project");
801 let config_path = None;
802 let workspace = vec!["packages/app".to_string()];
803 let diff = DiffIndex::from_unified_diff(
804 "diff --git a/src/a.ts b/src/a.ts\n\
805 --- a/src/a.ts\n\
806 +++ b/src/a.ts\n\
807 @@ -0,0 +1,1 @@\n\
808 +new line\n",
809 );
810 let runtime_coverage = RuntimeCoverageOptions {
811 path: PathBuf::from("coverage/v8"),
812 min_invocations_hot: 10,
813 min_observation_volume: Some(500),
814 low_traffic_threshold: Some(0.01),
815 license_jwt: "test.jwt".to_string(),
816 watermark: None,
817 };
818
819 let options = HealthExecutionOptions {
820 root,
821 config_path: &config_path,
822 output: OutputFormat::Json,
823 no_cache: true,
824 threads: 2,
825 quiet: true,
826 complexity_breakdown: true,
827 thresholds: HealthThresholdOverrides::default(),
828 top: Some(5),
829 sort: HealthSort::Cognitive,
830 production: true,
831 production_override: Some(true),
832 allow_remote_extends: false,
833 changed_since: Some("HEAD~1"),
834 diff_index: Some(&diff),
835 use_shared_diff_index: false,
836 workspace: Some(&workspace),
837 changed_workspaces: None,
838 scope: None,
839 baseline: Some(Path::new(".fallow/health-baseline.json")),
840 save_baseline: None,
841 baseline_mode: crate::baseline::HealthBaselineMode::Count,
842 baseline_mode_explicit: false,
843 complexity: true,
844 file_scores: true,
845 coverage_gaps: false,
846 config_activates_coverage_gaps: false,
847 hotspots: true,
848 ownership: false,
849 ownership_emails: None,
850 targets: true,
851 css: false,
852 css_deep: false,
853 force_full: true,
854 score_only_output: false,
855 enforce_coverage_gap_gate: true,
856 effort: Some(EffortEstimate::Low),
857 score: true,
858 gates: HealthGateOptions {
859 min_score: Some(80.0),
860 min_severity: None,
861 report_only: false,
862 },
863 since: Some("30d"),
864 min_commits: Some(2),
865 explain: true,
866 summary: false,
867 save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
868 trend: true,
869 coverage_inputs: HealthCoverageInputs::default(),
870 performance: true,
871 runtime_coverage: Some(runtime_coverage),
872 churn_file: Some(Path::new("churn.json")),
873 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
874 group_by: Some(GroupByMode::Directory),
875 };
876
877 assert_eq!(options.root, root);
878 assert!(
879 options
880 .diff_index
881 .is_some_and(|index| index.line_is_added("src/a.ts", 1))
882 );
883 assert_eq!(options.workspace, Some(workspace.as_slice()));
884 assert!(options.runtime_coverage.is_some());
885 assert_eq!(options.group_by, Some(GroupByMode::Directory));
886 assert_eq!(
887 options.save_snapshot.as_deref(),
888 Some(Path::new(".fallow/snapshots/health.json"))
889 );
890 }
891
892 #[test]
893 fn health_run_options_default_sections_match_health_defaults() {
894 let run = derive_health_run_options(health_run_input());
895
896 assert!(run.sections.complexity);
897 assert!(run.sections.file_scores);
898 assert!(run.sections.hotspots);
899 assert!(run.sections.targets);
900 assert!(run.sections.score);
901 assert!(!run.ownership);
902 }
903
904 #[test]
905 fn health_run_options_effort_requests_targets() {
906 let mut input = health_run_input();
907 input.effort = Some(EffortEstimate::Low);
908
909 let run = derive_health_run_options(input);
910
911 assert!(run.sections.targets);
912 assert_eq!(run.effort, Some(EffortEstimate::Low));
913 }
914
915 struct HealthExecutionOptionsFixture {
916 config_path: Option<PathBuf>,
917 }
918
919 impl HealthExecutionOptionsFixture {
920 const fn new() -> Self {
921 Self { config_path: None }
922 }
923
924 fn options<'a>(&'a self, root: &'a Path) -> HealthExecutionOptions<'a> {
925 HealthExecutionOptions {
926 root,
927 config_path: &self.config_path,
928 output: OutputFormat::Human,
929 no_cache: true,
930 threads: 1,
931 quiet: true,
932 complexity_breakdown: false,
933 thresholds: HealthThresholdOverrides::default(),
934 top: None,
935 sort: HealthSort::Cyclomatic,
936 production: false,
937 production_override: None,
938 allow_remote_extends: false,
939 changed_since: None,
940 diff_index: None,
941 use_shared_diff_index: false,
942 workspace: None,
943 changed_workspaces: None,
944 scope: None,
945 baseline: None,
946 save_baseline: None,
947 baseline_mode: crate::baseline::HealthBaselineMode::Count,
948 baseline_mode_explicit: false,
949 complexity: true,
950 file_scores: false,
951 coverage_gaps: false,
952 config_activates_coverage_gaps: false,
953 hotspots: false,
954 ownership: false,
955 ownership_emails: None,
956 targets: false,
957 css: false,
958 css_deep: false,
959 force_full: false,
960 score_only_output: false,
961 enforce_coverage_gap_gate: true,
962 effort: None,
963 score: false,
964 gates: HealthGateOptions::default(),
965 since: None,
966 min_commits: None,
967 explain: false,
968 summary: false,
969 save_snapshot: None,
970 trend: false,
971 coverage_inputs: HealthCoverageInputs::default(),
972 performance: false,
973 runtime_coverage: None,
974 churn_file: None,
975 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
976 group_by: None,
977 }
978 }
979 }
980
981 #[test]
982 fn standalone_health_precomputes_dead_code_when_default_crap_can_use_graph() {
983 let project = tempfile::tempdir().expect("temp dir");
984 let fixture = HealthExecutionOptionsFixture::new();
985 let options = fixture.options(project.path());
986 let config = crate::project_config::default_project_config(project.path()).config;
987
988 assert!(should_precompute_dead_code_analysis(&options, &config));
989 }
990
991 #[test]
992 fn standalone_health_skips_precompute_when_no_section_needs_analysis_artifacts() {
993 let project = tempfile::tempdir().expect("temp dir");
994 let fixture = HealthExecutionOptionsFixture::new();
995 let mut options = fixture.options(project.path());
996 options.thresholds.max_crap = Some(0.0);
997 let config = crate::project_config::default_project_config(project.path()).config;
998
999 assert!(!should_precompute_dead_code_analysis(&options, &config));
1000 }
1001
1002 #[test]
1003 fn standalone_health_precomputes_dead_code_for_target_sections() {
1004 let project = tempfile::tempdir().expect("temp dir");
1005 let fixture = HealthExecutionOptionsFixture::new();
1006 let mut options = fixture.options(project.path());
1007 options.thresholds.max_crap = Some(0.0);
1008 options.targets = true;
1009 let config = crate::project_config::default_project_config(project.path()).config;
1010
1011 assert!(should_precompute_dead_code_analysis(&options, &config));
1012 }
1013
1014 #[test]
1015 fn health_run_options_ownership_requires_hotspots() {
1016 let mut input = health_run_input();
1017 input.complexity = true;
1018 input.ownership = true;
1019
1020 let run = derive_health_run_options(input);
1021
1022 assert!(!run.sections.hotspots);
1023 assert!(!run.ownership);
1024
1025 let mut input = health_run_input();
1026 input.ownership = true;
1027 input.hotspots = true;
1028
1029 let run = derive_health_run_options(input);
1030
1031 assert!(run.sections.hotspots);
1032 assert!(run.ownership);
1033 }
1034
1035 #[test]
1036 fn health_run_options_score_gate_forces_score() {
1037 let mut input = health_run_input();
1038 input.gates.min_score = Some(90.0);
1039
1040 let run = derive_health_run_options(input);
1041
1042 assert!(run.sections.score);
1043 assert_eq!(run.gates.min_score, Some(90.0));
1044 }
1045
1046 #[test]
1047 fn coverage_root_accepts_posix_absolute() {
1048 assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
1049 assert!(
1050 validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
1051 );
1052 }
1053
1054 #[test]
1055 fn coverage_root_rejects_relative() {
1056 assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
1057 assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
1058 assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
1059 }
1060
1061 #[test]
1062 fn coverage_root_accepts_none() {
1063 assert!(validate_coverage_root_absolute(None).is_ok());
1064 }
1065
1066 #[test]
1067 fn coverage_root_accepts_windows_absolute_on_all_hosts() {
1068 assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
1069 }
1070}