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 baseline: Option<&'a Path>,
488 pub save_baseline: Option<&'a Path>,
490 pub baseline_mode: crate::baseline::HealthBaselineMode,
495 pub baseline_mode_explicit: bool,
501 pub complexity: bool,
503 pub file_scores: bool,
505 pub coverage_gaps: bool,
507 pub config_activates_coverage_gaps: bool,
510 pub hotspots: bool,
512 pub ownership: bool,
514 pub ownership_emails: Option<EmailMode>,
516 pub targets: bool,
518 pub css: bool,
520 pub css_deep: bool,
522 pub force_full: bool,
525 pub score_only_output: bool,
528 pub enforce_coverage_gap_gate: bool,
530 pub effort: Option<EffortEstimate>,
532 pub score: bool,
534 pub gates: HealthGateOptions,
536 pub since: Option<&'a str>,
538 pub min_commits: Option<u32>,
540 pub explain: bool,
542 pub summary: bool,
544 pub save_snapshot: Option<PathBuf>,
546 pub trend: bool,
548 pub coverage_inputs: HealthCoverageInputs<'a>,
550 pub performance: bool,
552 pub runtime_coverage: Option<RuntimeCoverageOptions>,
554 pub churn_file: Option<&'a Path>,
556 pub analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
558 pub group_by: Option<GroupByMode>,
560}
561
562#[must_use]
564fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
565 let score = options.score
566 || options.score_gate
567 || options.trend
568 || matches!(options.output, OutputFormat::Badge);
569 let any_section = options.complexity
570 || options.file_scores
571 || options.coverage_gaps
572 || options.hotspots
573 || options.targets
574 || score;
575 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
576 let force_full = options.snapshot_requested || effective_score;
577
578 DerivedHealthSections {
579 any_section,
580 complexity: if any_section {
581 options.complexity
582 } else {
583 true
584 },
585 file_scores: if any_section {
586 options.file_scores
587 } else {
588 true
589 } || force_full,
590 coverage_gaps: if any_section {
591 options.coverage_gaps
592 } else {
593 false
594 },
595 hotspots: if any_section { options.hotspots } else { true }
596 || options.snapshot_requested
597 || options.trend,
598 targets: if any_section { options.targets } else { true },
599 css: options.css,
600 score: effective_score,
601 force_full,
602 score_only_output: is_health_score_only_output(options, score),
603 }
604}
605
606#[must_use]
608pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
609 let targets = input.targets || input.effort.is_some();
610 let sections = derive_health_sections(&HealthSectionOptions {
611 output: input.output,
612 complexity: input.complexity,
613 file_scores: input.file_scores,
614 coverage_gaps: input.coverage_gaps,
615 hotspots: input.hotspots,
616 targets,
617 css: input.css,
618 score: input.score,
619 score_gate: input.gates.min_score.is_some(),
620 snapshot_requested: input.snapshot_requested,
621 trend: input.trend,
622 });
623
624 HealthRunOptions {
625 thresholds: input.thresholds,
626 top: input.top,
627 sort: input.sort,
628 sections,
629 ownership: input.ownership && sections.hotspots,
630 ownership_emails: input.ownership_emails,
631 effort: input.effort,
632 gates: input.gates,
633 since: input.since,
634 min_commits: input.min_commits,
635 coverage_inputs: input.coverage_inputs,
636 runtime_coverage: input.runtime_coverage,
637 }
638}
639
640fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
641 score
642 && !options.complexity
643 && !options.file_scores
644 && !options.coverage_gaps
645 && !options.hotspots
646 && !options.targets
647 && !options.trend
648}
649
650#[derive(Debug, Clone)]
652pub struct ComplexitySectionOptions {
653 complexity: bool,
654 file_scores: bool,
655 coverage_gaps: bool,
656 hotspots: bool,
657 ownership: bool,
658 targets: bool,
659 css: bool,
660 score: bool,
661}
662
663#[derive(Debug, Clone, Copy, PartialEq, Eq)]
665pub struct DerivedComplexityOptions {
666 any_section: bool,
667 complexity: bool,
668 file_scores: bool,
669 coverage_gaps: bool,
670 hotspots: bool,
671 ownership: bool,
672 targets: bool,
673 force_full: bool,
674 score_only_output: bool,
675 score: bool,
676}
677
678#[must_use]
680pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
681 let requested_hotspots = options.hotspots || options.ownership;
682 let sections = derive_health_sections(&HealthSectionOptions {
683 output: OutputFormat::Human,
684 complexity: options.complexity,
685 file_scores: options.file_scores,
686 coverage_gaps: options.coverage_gaps,
687 hotspots: requested_hotspots,
688 targets: options.targets,
689 css: options.css,
690 score: options.score,
691 score_gate: false,
692 snapshot_requested: false,
693 trend: false,
694 });
695
696 DerivedComplexityOptions {
697 any_section: sections.any_section,
698 complexity: sections.complexity,
699 file_scores: sections.file_scores,
700 coverage_gaps: sections.coverage_gaps,
701 hotspots: sections.hotspots,
702 ownership: options.ownership && sections.hotspots,
703 targets: sections.targets,
704 force_full: sections.force_full,
705 score_only_output: sections.score_only_output,
706 score: sections.score,
707 }
708}
709
710#[derive(Debug, Clone, PartialEq)]
713pub struct ComplexityRunOptions<'a> {
714 thresholds: HealthThresholdOverrides,
715 top: Option<usize>,
716 sort: HealthSort,
717 complexity_breakdown: bool,
718 sections: DerivedComplexityOptions,
719 ownership_emails: Option<EmailMode>,
720 effort: Option<EffortEstimate>,
721 css: bool,
722 since: Option<&'a str>,
723 min_commits: Option<u32>,
724 coverage_inputs: HealthCoverageInputs<'a>,
725}
726
727#[derive(Debug, Clone)]
729pub struct RuntimeCoverageOptions {
730 pub path: PathBuf,
732 pub min_invocations_hot: u64,
734 pub min_observation_volume: Option<u32>,
739 pub low_traffic_threshold: Option<f64>,
743 pub license_jwt: String,
745 pub watermark: Option<RuntimeCoverageWatermark>,
747}
748
749pub struct HealthSharedParseData {
751 pub files: Vec<fallow_types::discover::DiscoveredFile>,
753 pub modules: Vec<fallow_types::extract::ModuleInfo>,
755 pub dead_code_results: Option<AnalysisResults>,
757 pub workspaces: Vec<WorkspaceInfo>,
759 pub analysis_output: Option<DeadCodeAnalysisArtifacts>,
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766
767 fn health_run_input() -> HealthRunOptionsInput<'static> {
768 HealthRunOptionsInput {
769 output: OutputFormat::Json,
770 thresholds: HealthThresholdOverrides::default(),
771 top: None,
772 sort: HealthSort::Cyclomatic,
773 complexity: false,
774 file_scores: false,
775 coverage_gaps: false,
776 hotspots: false,
777 ownership: false,
778 ownership_emails: None,
779 targets: false,
780 css: false,
781 effort: None,
782 score: false,
783 gates: HealthGateOptions::default(),
784 snapshot_requested: false,
785 trend: false,
786 since: None,
787 min_commits: None,
788 coverage_inputs: HealthCoverageInputs::default(),
789 runtime_coverage: None,
790 }
791 }
792
793 #[test]
794 fn health_execution_options_own_shared_runner_scope() {
795 let root = Path::new("/project");
796 let config_path = None;
797 let workspace = vec!["packages/app".to_string()];
798 let diff = DiffIndex::from_unified_diff(
799 "diff --git a/src/a.ts b/src/a.ts\n\
800 --- a/src/a.ts\n\
801 +++ b/src/a.ts\n\
802 @@ -0,0 +1,1 @@\n\
803 +new line\n",
804 );
805 let runtime_coverage = RuntimeCoverageOptions {
806 path: PathBuf::from("coverage/v8"),
807 min_invocations_hot: 10,
808 min_observation_volume: Some(500),
809 low_traffic_threshold: Some(0.01),
810 license_jwt: "test.jwt".to_string(),
811 watermark: None,
812 };
813
814 let options = HealthExecutionOptions {
815 root,
816 config_path: &config_path,
817 output: OutputFormat::Json,
818 no_cache: true,
819 threads: 2,
820 quiet: true,
821 complexity_breakdown: true,
822 thresholds: HealthThresholdOverrides::default(),
823 top: Some(5),
824 sort: HealthSort::Cognitive,
825 production: true,
826 production_override: Some(true),
827 allow_remote_extends: false,
828 changed_since: Some("HEAD~1"),
829 diff_index: Some(&diff),
830 use_shared_diff_index: false,
831 workspace: Some(&workspace),
832 changed_workspaces: None,
833 baseline: Some(Path::new(".fallow/health-baseline.json")),
834 save_baseline: None,
835 baseline_mode: crate::baseline::HealthBaselineMode::Count,
836 baseline_mode_explicit: false,
837 complexity: true,
838 file_scores: true,
839 coverage_gaps: false,
840 config_activates_coverage_gaps: false,
841 hotspots: true,
842 ownership: false,
843 ownership_emails: None,
844 targets: true,
845 css: false,
846 css_deep: false,
847 force_full: true,
848 score_only_output: false,
849 enforce_coverage_gap_gate: true,
850 effort: Some(EffortEstimate::Low),
851 score: true,
852 gates: HealthGateOptions {
853 min_score: Some(80.0),
854 min_severity: None,
855 report_only: false,
856 },
857 since: Some("30d"),
858 min_commits: Some(2),
859 explain: true,
860 summary: false,
861 save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
862 trend: true,
863 coverage_inputs: HealthCoverageInputs::default(),
864 performance: true,
865 runtime_coverage: Some(runtime_coverage),
866 churn_file: Some(Path::new("churn.json")),
867 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
868 group_by: Some(GroupByMode::Directory),
869 };
870
871 assert_eq!(options.root, root);
872 assert!(
873 options
874 .diff_index
875 .is_some_and(|index| index.line_is_added("src/a.ts", 1))
876 );
877 assert_eq!(options.workspace, Some(workspace.as_slice()));
878 assert!(options.runtime_coverage.is_some());
879 assert_eq!(options.group_by, Some(GroupByMode::Directory));
880 assert_eq!(
881 options.save_snapshot.as_deref(),
882 Some(Path::new(".fallow/snapshots/health.json"))
883 );
884 }
885
886 #[test]
887 fn health_run_options_default_sections_match_health_defaults() {
888 let run = derive_health_run_options(health_run_input());
889
890 assert!(run.sections.complexity);
891 assert!(run.sections.file_scores);
892 assert!(run.sections.hotspots);
893 assert!(run.sections.targets);
894 assert!(run.sections.score);
895 assert!(!run.ownership);
896 }
897
898 #[test]
899 fn health_run_options_effort_requests_targets() {
900 let mut input = health_run_input();
901 input.effort = Some(EffortEstimate::Low);
902
903 let run = derive_health_run_options(input);
904
905 assert!(run.sections.targets);
906 assert_eq!(run.effort, Some(EffortEstimate::Low));
907 }
908
909 struct HealthExecutionOptionsFixture {
910 config_path: Option<PathBuf>,
911 }
912
913 impl HealthExecutionOptionsFixture {
914 const fn new() -> Self {
915 Self { config_path: None }
916 }
917
918 fn options<'a>(&'a self, root: &'a Path) -> HealthExecutionOptions<'a> {
919 HealthExecutionOptions {
920 root,
921 config_path: &self.config_path,
922 output: OutputFormat::Human,
923 no_cache: true,
924 threads: 1,
925 quiet: true,
926 complexity_breakdown: false,
927 thresholds: HealthThresholdOverrides::default(),
928 top: None,
929 sort: HealthSort::Cyclomatic,
930 production: false,
931 production_override: None,
932 allow_remote_extends: false,
933 changed_since: None,
934 diff_index: None,
935 use_shared_diff_index: false,
936 workspace: None,
937 changed_workspaces: None,
938 baseline: None,
939 save_baseline: None,
940 baseline_mode: crate::baseline::HealthBaselineMode::Count,
941 baseline_mode_explicit: false,
942 complexity: true,
943 file_scores: false,
944 coverage_gaps: false,
945 config_activates_coverage_gaps: false,
946 hotspots: false,
947 ownership: false,
948 ownership_emails: None,
949 targets: false,
950 css: false,
951 css_deep: false,
952 force_full: false,
953 score_only_output: false,
954 enforce_coverage_gap_gate: true,
955 effort: None,
956 score: false,
957 gates: HealthGateOptions::default(),
958 since: None,
959 min_commits: None,
960 explain: false,
961 summary: false,
962 save_snapshot: None,
963 trend: false,
964 coverage_inputs: HealthCoverageInputs::default(),
965 performance: false,
966 runtime_coverage: None,
967 churn_file: None,
968 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
969 group_by: None,
970 }
971 }
972 }
973
974 #[test]
975 fn standalone_health_precomputes_dead_code_when_default_crap_can_use_graph() {
976 let project = tempfile::tempdir().expect("temp dir");
977 let fixture = HealthExecutionOptionsFixture::new();
978 let options = fixture.options(project.path());
979 let config = crate::project_config::default_project_config(project.path()).config;
980
981 assert!(should_precompute_dead_code_analysis(&options, &config));
982 }
983
984 #[test]
985 fn standalone_health_skips_precompute_when_no_section_needs_analysis_artifacts() {
986 let project = tempfile::tempdir().expect("temp dir");
987 let fixture = HealthExecutionOptionsFixture::new();
988 let mut options = fixture.options(project.path());
989 options.thresholds.max_crap = Some(0.0);
990 let config = crate::project_config::default_project_config(project.path()).config;
991
992 assert!(!should_precompute_dead_code_analysis(&options, &config));
993 }
994
995 #[test]
996 fn standalone_health_precomputes_dead_code_for_target_sections() {
997 let project = tempfile::tempdir().expect("temp dir");
998 let fixture = HealthExecutionOptionsFixture::new();
999 let mut options = fixture.options(project.path());
1000 options.thresholds.max_crap = Some(0.0);
1001 options.targets = true;
1002 let config = crate::project_config::default_project_config(project.path()).config;
1003
1004 assert!(should_precompute_dead_code_analysis(&options, &config));
1005 }
1006
1007 #[test]
1008 fn health_run_options_ownership_requires_hotspots() {
1009 let mut input = health_run_input();
1010 input.complexity = true;
1011 input.ownership = true;
1012
1013 let run = derive_health_run_options(input);
1014
1015 assert!(!run.sections.hotspots);
1016 assert!(!run.ownership);
1017
1018 let mut input = health_run_input();
1019 input.ownership = true;
1020 input.hotspots = true;
1021
1022 let run = derive_health_run_options(input);
1023
1024 assert!(run.sections.hotspots);
1025 assert!(run.ownership);
1026 }
1027
1028 #[test]
1029 fn health_run_options_score_gate_forces_score() {
1030 let mut input = health_run_input();
1031 input.gates.min_score = Some(90.0);
1032
1033 let run = derive_health_run_options(input);
1034
1035 assert!(run.sections.score);
1036 assert_eq!(run.gates.min_score, Some(90.0));
1037 }
1038
1039 #[test]
1040 fn coverage_root_accepts_posix_absolute() {
1041 assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
1042 assert!(
1043 validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
1044 );
1045 }
1046
1047 #[test]
1048 fn coverage_root_rejects_relative() {
1049 assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
1050 assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
1051 assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
1052 }
1053
1054 #[test]
1055 fn coverage_root_accepts_none() {
1056 assert!(validate_coverage_root_absolute(None).is_ok());
1057 }
1058
1059 #[test]
1060 fn coverage_root_accepts_windows_absolute_on_all_hosts() {
1061 assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
1062 }
1063}