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;
53pub mod styling_score;
54mod tailwind_theme;
55mod targets;
56mod threshold_overrides;
57mod timings;
58mod vital_data;
59mod vital_signs_scope;
60
61pub use crate::results::HealthAnalysisResult;
62pub use churn_file::validate_health_churn_file;
63pub use css_analytics::StylingAnalysisArtifacts;
64use derived_sections::{
65 HealthDerivedSectionInput, HealthDerivedSections, prepare_health_derived_sections,
66};
67use execute::HealthOptions;
68pub use execute::execute_health_inner;
69use file_scores::{
70 FileScoresAndChurnInput, compute_file_scores_and_churn, health_file_scores_slice,
71 print_slow_churn_note,
72};
73use finding_sort::sort_findings;
74pub use health_error::HealthError;
75pub use pipeline::{HealthPipelineInputs, HealthScopeInputs};
76pub use runner::{
77 run_ungrouped_health, run_ungrouped_health_with_session,
78 run_ungrouped_health_with_session_artifacts,
79};
80use vital_data::{HealthVitalData, HealthVitalDataInput, prepare_health_vital_data};
81use vital_signs_scope::{
82 SubsetFilter, VitalSignsAndCountsInput, apply_duplication_metrics,
83 compute_vital_signs_and_counts,
84};
85
86pub(crate) fn build_styling_analysis_artifacts(
87 files: &[crate::discover::DiscoveredFile],
88 config: &fallow_config::ResolvedConfig,
89) -> StylingAnalysisArtifacts {
90 css_analytics::build_styling_analysis_artifacts(files, config)
91}
92
93#[must_use]
95pub fn shared_parse_data_from_artifacts(
96 results: &AnalysisResults,
97 graph: Option<RetainedModuleGraph>,
98 modules: Option<Vec<crate::source::ModuleInfo>>,
99 files: Option<Vec<crate::discover::DiscoveredFile>>,
100 workspaces: Vec<WorkspaceInfo>,
101 script_used_packages: impl IntoIterator<Item = String>,
102) -> Option<HealthSharedParseData> {
103 let (Some(modules), Some(files)) = (modules, files) else {
104 return None;
105 };
106 let script_used_packages: FxHashSet<String> = script_used_packages.into_iter().collect();
107 let analysis_output = graph.map(|graph| DeadCodeAnalysisArtifacts {
108 results: results.clone(),
109 timings: None,
110 graph: Some(graph),
111 modules: None,
112 files: None,
113 script_used_packages: script_used_packages.clone(),
114 file_hashes: FxHashMap::default(),
115 });
116 Some(HealthSharedParseData {
117 files,
118 modules,
119 dead_code_results: Some(results.clone()),
120 workspaces,
121 analysis_output,
122 })
123}
124
125#[must_use]
131pub fn should_precompute_dead_code_analysis(
132 options: &HealthExecutionOptions<'_>,
133 config: &fallow_config::ResolvedConfig,
134) -> bool {
135 let max_crap = options
136 .thresholds
137 .max_crap
138 .unwrap_or(config.health.max_crap);
139 options.file_scores
140 || options.coverage_gaps
141 || options.config_activates_coverage_gaps
142 || options.hotspots
143 || options.targets
144 || options.force_full
145 || max_crap > 0.0
146 || options.runtime_coverage.is_some()
147}
148
149pub trait HealthGroupResolver {
155 fn mode_label(&self) -> &'static str;
157 fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>);
159 fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]>;
161}
162
163#[derive(Debug, Clone, Copy)]
166pub enum NoGroupResolver {}
167
168#[expect(
169 clippy::uninhabited_references,
170 reason = "NoGroupResolver is uninhabited; these methods are unreachable and exist only to satisfy the trait bound for the group-less programmatic path"
171)]
172impl HealthGroupResolver for NoGroupResolver {
173 fn mode_label(&self) -> &'static str {
174 match *self {}
175 }
176 fn resolve_with_rule(&self, _rel_path: &Path) -> (String, Option<String>) {
177 match *self {}
178 }
179 fn section_owners_of(&self, _rel_path: &Path) -> Option<&[String]> {
180 match *self {}
181 }
182}
183
184pub type RuntimeCoverageAnalyzer<'a> = dyn Fn(&RuntimeCoverageOptions, RuntimeCoverageSeamInput<'_>) -> Result<RuntimeCoverageReport, u8>
196 + 'a;
197
198pub struct RuntimeCoverageSeamInput<'a> {
200 pub root: &'a Path,
201 pub modules: &'a [fallow_types::extract::ModuleInfo],
202 pub analysis_output: &'a DeadCodeAnalysisArtifacts,
203 pub istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
204 pub file_paths: &'a rustc_hash::FxHashMap<fallow_types::discover::FileId, &'a PathBuf>,
205 pub ignore_set: &'a globset::GlobSet,
206 pub changed_files: Option<&'a rustc_hash::FxHashSet<PathBuf>>,
207 pub ws_roots: Option<&'a [PathBuf]>,
208 pub top: Option<usize>,
209 pub codeowners_path: Option<&'a str>,
210 pub quiet: bool,
211 pub output: OutputFormat,
212}
213
214pub struct HealthSeams<'a> {
218 pub runtime_coverage_analyzer: &'a RuntimeCoverageAnalyzer<'a>,
220 pub note_graph_structure: &'a dyn Fn(usize, usize),
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum HealthSort {
229 Severity,
230 Cyclomatic,
231 Cognitive,
232 Lines,
233}
234
235#[derive(Debug, Clone, Copy, Default, PartialEq)]
237pub struct HealthThresholdOverrides {
238 pub max_cyclomatic: Option<u16>,
239 pub max_cognitive: Option<u16>,
240 pub max_crap: Option<f64>,
243}
244
245#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
247pub struct HealthCoverageInputs<'a> {
248 pub coverage: Option<&'a Path>,
249 pub coverage_root: Option<&'a Path>,
252}
253
254pub fn validate_coverage_root_absolute(coverage_root: Option<&Path>) -> Result<(), String> {
261 if let Some(path) = coverage_root
262 && !is_absolute_path_any_platform(path)
263 {
264 return Err(format!(
265 "--coverage-root expects an absolute path prefix from the coverage data, got '{}'. Use the checkout prefix from the machine that generated coverage, for example '/home/runner/work/myapp'.",
266 path.display()
267 ));
268 }
269 Ok(())
270}
271
272#[derive(Debug, Clone, Copy, Default, PartialEq)]
274pub struct HealthGateOptions {
275 pub min_score: Option<f64>,
276 pub min_severity: Option<FindingSeverity>,
277 pub report_only: bool,
279}
280
281#[derive(Debug, Clone)]
283pub struct HealthSectionOptions {
284 pub output: OutputFormat,
285 pub complexity: bool,
286 pub file_scores: bool,
287 pub coverage_gaps: bool,
288 pub hotspots: bool,
289 pub targets: bool,
290 pub css: bool,
291 pub score: bool,
292 pub score_gate: bool,
293 pub snapshot_requested: bool,
294 pub trend: bool,
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub struct DerivedHealthSections {
300 pub any_section: bool,
301 pub complexity: bool,
302 pub file_scores: bool,
303 pub coverage_gaps: bool,
304 pub hotspots: bool,
305 pub targets: bool,
306 pub css: bool,
307 pub score: bool,
308 pub force_full: bool,
309 pub score_only_output: bool,
310}
311
312#[derive(Debug, Clone)]
315pub struct HealthRunOptionsInput<'a> {
316 pub output: OutputFormat,
317 pub thresholds: HealthThresholdOverrides,
318 pub top: Option<usize>,
319 pub sort: HealthSort,
320 pub complexity: bool,
321 pub file_scores: bool,
322 pub coverage_gaps: bool,
323 pub hotspots: bool,
324 pub ownership: bool,
325 pub ownership_emails: Option<EmailMode>,
326 pub targets: bool,
327 pub css: bool,
328 pub effort: Option<EffortEstimate>,
329 pub score: bool,
330 pub gates: HealthGateOptions,
331 pub snapshot_requested: bool,
332 pub trend: bool,
333 pub since: Option<&'a str>,
334 pub min_commits: Option<u32>,
335 pub coverage_inputs: HealthCoverageInputs<'a>,
336 pub runtime_coverage: Option<RuntimeCoverageOptions>,
337}
338
339#[derive(Debug, Clone)]
341pub struct HealthRunOptions<'a> {
342 pub thresholds: HealthThresholdOverrides,
343 pub top: Option<usize>,
344 pub sort: HealthSort,
345 pub sections: DerivedHealthSections,
346 pub ownership: bool,
347 pub ownership_emails: Option<EmailMode>,
348 pub effort: Option<EffortEstimate>,
349 pub gates: HealthGateOptions,
350 pub since: Option<&'a str>,
351 pub min_commits: Option<u32>,
352 pub coverage_inputs: HealthCoverageInputs<'a>,
353 pub runtime_coverage: Option<RuntimeCoverageOptions>,
354}
355
356#[derive(Debug, Clone)]
360pub struct HealthExecutionOptions<'a> {
361 pub root: &'a Path,
362 pub config_path: &'a Option<PathBuf>,
363 pub output: OutputFormat,
364 pub no_cache: bool,
365 pub threads: usize,
366 pub quiet: bool,
367 pub complexity_breakdown: bool,
372 pub thresholds: HealthThresholdOverrides,
373 pub top: Option<usize>,
374 pub sort: HealthSort,
375 pub production: bool,
376 pub production_override: Option<bool>,
377 pub allow_remote_extends: bool,
378 pub changed_since: Option<&'a str>,
379 pub diff_index: Option<&'a DiffIndex>,
380 pub use_shared_diff_index: bool,
381 pub workspace: Option<&'a [String]>,
382 pub changed_workspaces: Option<&'a str>,
383 pub baseline: Option<&'a Path>,
384 pub save_baseline: Option<&'a Path>,
385 pub complexity: bool,
386 pub file_scores: bool,
387 pub coverage_gaps: bool,
388 pub config_activates_coverage_gaps: bool,
389 pub hotspots: bool,
390 pub ownership: bool,
391 pub ownership_emails: Option<EmailMode>,
392 pub targets: bool,
393 pub css: bool,
394 pub css_deep: bool,
395 pub force_full: bool,
396 pub score_only_output: bool,
397 pub enforce_coverage_gap_gate: bool,
398 pub effort: Option<EffortEstimate>,
399 pub score: bool,
400 pub gates: HealthGateOptions,
401 pub since: Option<&'a str>,
402 pub min_commits: Option<u32>,
403 pub explain: bool,
404 pub summary: bool,
405 pub save_snapshot: Option<PathBuf>,
406 pub trend: bool,
407 pub coverage_inputs: HealthCoverageInputs<'a>,
408 pub performance: bool,
409 pub runtime_coverage: Option<RuntimeCoverageOptions>,
410 pub churn_file: Option<&'a Path>,
411 pub group_by: Option<GroupByMode>,
413}
414
415#[must_use]
417pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
418 let score = options.score
419 || options.score_gate
420 || options.trend
421 || matches!(options.output, OutputFormat::Badge);
422 let any_section = options.complexity
423 || options.file_scores
424 || options.coverage_gaps
425 || options.hotspots
426 || options.targets
427 || score;
428 let effective_score = if any_section { score } else { true } || options.snapshot_requested;
429 let force_full = options.snapshot_requested || effective_score;
430
431 DerivedHealthSections {
432 any_section,
433 complexity: if any_section {
434 options.complexity
435 } else {
436 true
437 },
438 file_scores: if any_section {
439 options.file_scores
440 } else {
441 true
442 } || force_full,
443 coverage_gaps: if any_section {
444 options.coverage_gaps
445 } else {
446 false
447 },
448 hotspots: if any_section { options.hotspots } else { true }
449 || options.snapshot_requested
450 || options.trend,
451 targets: if any_section { options.targets } else { true },
452 css: options.css,
453 score: effective_score,
454 force_full,
455 score_only_output: is_health_score_only_output(options, score),
456 }
457}
458
459#[must_use]
461pub fn derive_health_run_options(input: HealthRunOptionsInput<'_>) -> HealthRunOptions<'_> {
462 let targets = input.targets || input.effort.is_some();
463 let sections = derive_health_sections(&HealthSectionOptions {
464 output: input.output,
465 complexity: input.complexity,
466 file_scores: input.file_scores,
467 coverage_gaps: input.coverage_gaps,
468 hotspots: input.hotspots,
469 targets,
470 css: input.css,
471 score: input.score,
472 score_gate: input.gates.min_score.is_some(),
473 snapshot_requested: input.snapshot_requested,
474 trend: input.trend,
475 });
476
477 HealthRunOptions {
478 thresholds: input.thresholds,
479 top: input.top,
480 sort: input.sort,
481 sections,
482 ownership: input.ownership && sections.hotspots,
483 ownership_emails: input.ownership_emails,
484 effort: input.effort,
485 gates: input.gates,
486 since: input.since,
487 min_commits: input.min_commits,
488 coverage_inputs: input.coverage_inputs,
489 runtime_coverage: input.runtime_coverage,
490 }
491}
492
493fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
494 score
495 && !options.complexity
496 && !options.file_scores
497 && !options.coverage_gaps
498 && !options.hotspots
499 && !options.targets
500 && !options.trend
501}
502
503#[derive(Debug, Clone)]
505pub struct ComplexitySectionOptions {
506 pub complexity: bool,
507 pub file_scores: bool,
508 pub coverage_gaps: bool,
509 pub hotspots: bool,
510 pub ownership: bool,
511 pub targets: bool,
512 pub css: bool,
513 pub score: bool,
514}
515
516#[derive(Debug, Clone, Copy, PartialEq, Eq)]
518pub struct DerivedComplexityOptions {
519 pub any_section: bool,
520 pub complexity: bool,
521 pub file_scores: bool,
522 pub coverage_gaps: bool,
523 pub hotspots: bool,
524 pub ownership: bool,
525 pub targets: bool,
526 pub force_full: bool,
527 pub score_only_output: bool,
528 pub score: bool,
529}
530
531#[must_use]
533pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
534 let requested_hotspots = options.hotspots || options.ownership;
535 let sections = derive_health_sections(&HealthSectionOptions {
536 output: OutputFormat::Human,
537 complexity: options.complexity,
538 file_scores: options.file_scores,
539 coverage_gaps: options.coverage_gaps,
540 hotspots: requested_hotspots,
541 targets: options.targets,
542 css: options.css,
543 score: options.score,
544 score_gate: false,
545 snapshot_requested: false,
546 trend: false,
547 });
548
549 DerivedComplexityOptions {
550 any_section: sections.any_section,
551 complexity: sections.complexity,
552 file_scores: sections.file_scores,
553 coverage_gaps: sections.coverage_gaps,
554 hotspots: sections.hotspots,
555 ownership: options.ownership && sections.hotspots,
556 targets: sections.targets,
557 force_full: sections.force_full,
558 score_only_output: sections.score_only_output,
559 score: sections.score,
560 }
561}
562
563#[derive(Debug, Clone, PartialEq)]
566pub struct ComplexityRunOptions<'a> {
567 pub thresholds: HealthThresholdOverrides,
568 pub top: Option<usize>,
569 pub sort: HealthSort,
570 pub complexity_breakdown: bool,
571 pub sections: DerivedComplexityOptions,
572 pub ownership_emails: Option<EmailMode>,
573 pub effort: Option<EffortEstimate>,
574 pub css: bool,
575 pub since: Option<&'a str>,
576 pub min_commits: Option<u32>,
577 pub coverage_inputs: HealthCoverageInputs<'a>,
578}
579
580#[derive(Debug, Clone)]
582pub struct RuntimeCoverageOptions {
583 pub path: PathBuf,
584 pub min_invocations_hot: u64,
585 pub min_observation_volume: Option<u32>,
590 pub low_traffic_threshold: Option<f64>,
594 pub license_jwt: String,
595 pub watermark: Option<RuntimeCoverageWatermark>,
596}
597
598pub struct HealthSharedParseData {
600 pub files: Vec<fallow_types::discover::DiscoveredFile>,
601 pub modules: Vec<fallow_types::extract::ModuleInfo>,
602 pub dead_code_results: Option<AnalysisResults>,
604 pub workspaces: Vec<WorkspaceInfo>,
605 pub analysis_output: Option<DeadCodeAnalysisArtifacts>,
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 fn health_run_input() -> HealthRunOptionsInput<'static> {
614 HealthRunOptionsInput {
615 output: OutputFormat::Json,
616 thresholds: HealthThresholdOverrides::default(),
617 top: None,
618 sort: HealthSort::Cyclomatic,
619 complexity: false,
620 file_scores: false,
621 coverage_gaps: false,
622 hotspots: false,
623 ownership: false,
624 ownership_emails: None,
625 targets: false,
626 css: false,
627 effort: None,
628 score: false,
629 gates: HealthGateOptions::default(),
630 snapshot_requested: false,
631 trend: false,
632 since: None,
633 min_commits: None,
634 coverage_inputs: HealthCoverageInputs::default(),
635 runtime_coverage: None,
636 }
637 }
638
639 #[test]
640 fn health_execution_options_own_shared_runner_scope() {
641 let root = Path::new("/project");
642 let config_path = None;
643 let workspace = vec!["packages/app".to_string()];
644 let diff = DiffIndex::from_unified_diff(
645 "diff --git a/src/a.ts b/src/a.ts\n\
646 --- a/src/a.ts\n\
647 +++ b/src/a.ts\n\
648 @@ -0,0 +1,1 @@\n\
649 +new line\n",
650 );
651 let runtime_coverage = RuntimeCoverageOptions {
652 path: PathBuf::from("coverage/v8"),
653 min_invocations_hot: 10,
654 min_observation_volume: Some(500),
655 low_traffic_threshold: Some(0.01),
656 license_jwt: "test.jwt".to_string(),
657 watermark: None,
658 };
659
660 let options = HealthExecutionOptions {
661 root,
662 config_path: &config_path,
663 output: OutputFormat::Json,
664 no_cache: true,
665 threads: 2,
666 quiet: true,
667 complexity_breakdown: true,
668 thresholds: HealthThresholdOverrides::default(),
669 top: Some(5),
670 sort: HealthSort::Cognitive,
671 production: true,
672 production_override: Some(true),
673 allow_remote_extends: false,
674 changed_since: Some("HEAD~1"),
675 diff_index: Some(&diff),
676 use_shared_diff_index: false,
677 workspace: Some(&workspace),
678 changed_workspaces: None,
679 baseline: Some(Path::new(".fallow/health-baseline.json")),
680 save_baseline: None,
681 complexity: true,
682 file_scores: true,
683 coverage_gaps: false,
684 config_activates_coverage_gaps: false,
685 hotspots: true,
686 ownership: false,
687 ownership_emails: None,
688 targets: true,
689 css: false,
690 css_deep: false,
691 force_full: true,
692 score_only_output: false,
693 enforce_coverage_gap_gate: true,
694 effort: Some(EffortEstimate::Low),
695 score: true,
696 gates: HealthGateOptions {
697 min_score: Some(80.0),
698 min_severity: None,
699 report_only: false,
700 },
701 since: Some("30d"),
702 min_commits: Some(2),
703 explain: true,
704 summary: false,
705 save_snapshot: Some(PathBuf::from(".fallow/snapshots/health.json")),
706 trend: true,
707 coverage_inputs: HealthCoverageInputs::default(),
708 performance: true,
709 runtime_coverage: Some(runtime_coverage),
710 churn_file: Some(Path::new("churn.json")),
711 group_by: Some(GroupByMode::Directory),
712 };
713
714 assert_eq!(options.root, root);
715 assert!(
716 options
717 .diff_index
718 .is_some_and(|index| index.line_is_added("src/a.ts", 1))
719 );
720 assert_eq!(options.workspace, Some(workspace.as_slice()));
721 assert!(options.runtime_coverage.is_some());
722 assert_eq!(options.group_by, Some(GroupByMode::Directory));
723 assert_eq!(
724 options.save_snapshot.as_deref(),
725 Some(Path::new(".fallow/snapshots/health.json"))
726 );
727 }
728
729 #[test]
730 fn health_run_options_default_sections_match_health_defaults() {
731 let run = derive_health_run_options(health_run_input());
732
733 assert!(run.sections.complexity);
734 assert!(run.sections.file_scores);
735 assert!(run.sections.hotspots);
736 assert!(run.sections.targets);
737 assert!(run.sections.score);
738 assert!(!run.ownership);
739 }
740
741 #[test]
742 fn health_run_options_effort_requests_targets() {
743 let mut input = health_run_input();
744 input.effort = Some(EffortEstimate::Low);
745
746 let run = derive_health_run_options(input);
747
748 assert!(run.sections.targets);
749 assert_eq!(run.effort, Some(EffortEstimate::Low));
750 }
751
752 struct HealthExecutionOptionsFixture {
753 config_path: Option<PathBuf>,
754 }
755
756 impl HealthExecutionOptionsFixture {
757 const fn new() -> Self {
758 Self { config_path: None }
759 }
760
761 fn options<'a>(&'a self, root: &'a Path) -> HealthExecutionOptions<'a> {
762 HealthExecutionOptions {
763 root,
764 config_path: &self.config_path,
765 output: OutputFormat::Human,
766 no_cache: true,
767 threads: 1,
768 quiet: true,
769 complexity_breakdown: false,
770 thresholds: HealthThresholdOverrides::default(),
771 top: None,
772 sort: HealthSort::Cyclomatic,
773 production: false,
774 production_override: None,
775 allow_remote_extends: false,
776 changed_since: None,
777 diff_index: None,
778 use_shared_diff_index: false,
779 workspace: None,
780 changed_workspaces: None,
781 baseline: None,
782 save_baseline: None,
783 complexity: true,
784 file_scores: false,
785 coverage_gaps: false,
786 config_activates_coverage_gaps: false,
787 hotspots: false,
788 ownership: false,
789 ownership_emails: None,
790 targets: false,
791 css: false,
792 css_deep: false,
793 force_full: false,
794 score_only_output: false,
795 enforce_coverage_gap_gate: true,
796 effort: None,
797 score: false,
798 gates: HealthGateOptions::default(),
799 since: None,
800 min_commits: None,
801 explain: false,
802 summary: false,
803 save_snapshot: None,
804 trend: false,
805 coverage_inputs: HealthCoverageInputs::default(),
806 performance: false,
807 runtime_coverage: None,
808 churn_file: None,
809 group_by: None,
810 }
811 }
812 }
813
814 #[test]
815 fn standalone_health_precomputes_dead_code_when_default_crap_can_use_graph() {
816 let project = tempfile::tempdir().expect("temp dir");
817 let fixture = HealthExecutionOptionsFixture::new();
818 let options = fixture.options(project.path());
819 let config = crate::project_config::default_project_config(project.path()).config;
820
821 assert!(should_precompute_dead_code_analysis(&options, &config));
822 }
823
824 #[test]
825 fn standalone_health_skips_precompute_when_no_section_needs_analysis_artifacts() {
826 let project = tempfile::tempdir().expect("temp dir");
827 let fixture = HealthExecutionOptionsFixture::new();
828 let mut options = fixture.options(project.path());
829 options.thresholds.max_crap = Some(0.0);
830 let config = crate::project_config::default_project_config(project.path()).config;
831
832 assert!(!should_precompute_dead_code_analysis(&options, &config));
833 }
834
835 #[test]
836 fn standalone_health_precomputes_dead_code_for_target_sections() {
837 let project = tempfile::tempdir().expect("temp dir");
838 let fixture = HealthExecutionOptionsFixture::new();
839 let mut options = fixture.options(project.path());
840 options.thresholds.max_crap = Some(0.0);
841 options.targets = true;
842 let config = crate::project_config::default_project_config(project.path()).config;
843
844 assert!(should_precompute_dead_code_analysis(&options, &config));
845 }
846
847 #[test]
848 fn health_run_options_ownership_requires_hotspots() {
849 let mut input = health_run_input();
850 input.complexity = true;
851 input.ownership = true;
852
853 let run = derive_health_run_options(input);
854
855 assert!(!run.sections.hotspots);
856 assert!(!run.ownership);
857
858 let mut input = health_run_input();
859 input.ownership = true;
860 input.hotspots = true;
861
862 let run = derive_health_run_options(input);
863
864 assert!(run.sections.hotspots);
865 assert!(run.ownership);
866 }
867
868 #[test]
869 fn health_run_options_score_gate_forces_score() {
870 let mut input = health_run_input();
871 input.gates.min_score = Some(90.0);
872
873 let run = derive_health_run_options(input);
874
875 assert!(run.sections.score);
876 assert_eq!(run.gates.min_score, Some(90.0));
877 }
878
879 #[test]
880 fn coverage_root_accepts_posix_absolute() {
881 assert!(validate_coverage_root_absolute(Some(Path::new("/ci/workspace"))).is_ok());
882 assert!(
883 validate_coverage_root_absolute(Some(Path::new("/home/runner/work/myapp"))).is_ok()
884 );
885 }
886
887 #[test]
888 fn coverage_root_rejects_relative() {
889 assert!(validate_coverage_root_absolute(Some(Path::new("src"))).is_err());
890 assert!(validate_coverage_root_absolute(Some(Path::new("./coverage"))).is_err());
891 assert!(validate_coverage_root_absolute(Some(Path::new("a/b/c"))).is_err());
892 }
893
894 #[test]
895 fn coverage_root_accepts_none() {
896 assert!(validate_coverage_root_absolute(None).is_ok());
897 }
898
899 #[test]
900 fn coverage_root_accepts_windows_absolute_on_all_hosts() {
901 assert!(validate_coverage_root_absolute(Some(Path::new(r"C:\ci\workspace"))).is_ok());
902 }
903}