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