1pub mod coverage;
12
13pub use fallow_engine::health::scoring;
16
17use std::process::ExitCode;
18use std::time::Instant;
19
20use colored::Colorize;
21use fallow_config::OutputFormat;
22use fallow_engine::health::{
23 HealthError, HealthExecutionOptions, HealthGateOptions, HealthGroupResolver,
24 HealthPipelineInputs, HealthScopeInputs, HealthSeams, HealthSharedParseData, HealthSort,
25 RuntimeCoverageSeamInput, execute_health_inner, validate_health_churn_file,
26};
27
28use crate::check::{get_changed_files, resolve_workspace_scope};
29use crate::error::emit_error;
30use crate::report;
31use crate::report::OwnershipResolver;
32
33#[derive(Clone, clap::ValueEnum)]
35pub enum SortBy {
36 Severity,
37 Cyclomatic,
38 Cognitive,
39 Lines,
40}
41
42impl From<SortBy> for HealthSort {
43 fn from(sort: SortBy) -> Self {
44 match sort {
45 SortBy::Severity => Self::Severity,
46 SortBy::Cyclomatic => Self::Cyclomatic,
47 SortBy::Cognitive => Self::Cognitive,
48 SortBy::Lines => Self::Lines,
49 }
50 }
51}
52
53pub type HealthOptions<'a> = HealthExecutionOptions<'a>;
54
55impl HealthGroupResolver for OwnershipResolver {
56 fn mode_label(&self) -> &'static str {
57 OwnershipResolver::mode_label(self)
58 }
59
60 fn resolve_with_rule(&self, rel_path: &std::path::Path) -> (String, Option<String>) {
61 OwnershipResolver::resolve_with_rule(self, rel_path)
62 }
63
64 fn section_owners_of(&self, rel_path: &std::path::Path) -> Option<&[String]> {
65 OwnershipResolver::section_owners_of(self, rel_path)
66 }
67}
68
69fn health_diff_index<'a>(opts: &HealthOptions<'a>) -> Option<&'a fallow_output::DiffIndex> {
72 match opts.diff_index {
73 Some(index) => Some(index),
74 None if opts.use_shared_diff_index => crate::report::ci::diff_filter::shared_diff_index(),
75 None => None,
76 }
77}
78
79fn build_health_group_resolver(
81 opts: &HealthOptions<'_>,
82 config: &fallow_config::ResolvedConfig,
83) -> Result<Option<OwnershipResolver>, ExitCode> {
84 crate::runtime_support::build_ownership_resolver_for_mode(
85 opts.group_by,
86 opts.root,
87 config.codeowners.as_deref(),
88 opts.output,
89 )
90}
91
92fn record_health_telemetry(report: &fallow_output::HealthReport, coverage_gaps_has_findings: bool) {
96 if coverage_gaps_has_findings && report.findings.is_empty() {
97 crate::telemetry::note_findings_present(true);
98 } else {
99 crate::telemetry::note_result_count(report.findings.len());
100 }
101 crate::telemetry::note_analysis_scale(
102 Some(report.summary.files_analyzed),
103 Some(report.summary.functions_analyzed),
104 );
105}
106
107fn health_seams<'a>() -> HealthSeams<'a> {
110 HealthSeams {
111 runtime_coverage_analyzer: &runtime_coverage_seam,
112 note_graph_structure: &|module_count, edge_count| {
113 crate::telemetry::note_graph_structure_counts(module_count, edge_count);
114 },
115 }
116}
117
118#[expect(
122 clippy::needless_pass_by_value,
123 reason = "by-value input matches the engine RuntimeCoverageAnalyzer seam signature"
124)]
125fn runtime_coverage_seam(
126 options: &fallow_engine::health::RuntimeCoverageOptions,
127 input: RuntimeCoverageSeamInput<'_>,
128) -> Result<fallow_output::RuntimeCoverageReport, u8> {
129 coverage::analyze(
130 options,
131 &coverage::RuntimeCoverageAnalysisInput {
132 root: input.root,
133 modules: input.modules,
134 analysis_output: input.analysis_output,
135 istanbul_coverage: input.istanbul_coverage,
136 file_paths: input.file_paths,
137 ignore_set: input.ignore_set,
138 changed_files: input.changed_files,
139 ws_roots: input.ws_roots,
140 top: input.top,
141 codeowners_path: input.codeowners_path,
142 quiet: input.quiet,
143 output: input.output,
144 },
145 )
146}
147
148fn build_health_scope_inputs<'a>(
151 opts: &HealthOptions<'a>,
152 config: &fallow_config::ResolvedConfig,
153) -> Result<HealthScopeInputs<'a, OwnershipResolver>, ExitCode> {
154 let changed_files = opts
155 .changed_since
156 .and_then(|git_ref| get_changed_files(opts.root, git_ref));
157 let diff_index = health_diff_index(opts);
158 let ws_roots = resolve_workspace_scope(
159 opts.root,
160 opts.workspace,
161 opts.changed_workspaces,
162 opts.output,
163 )?;
164 let group_resolver = build_health_group_resolver(opts, config)?;
165 Ok(HealthScopeInputs {
166 changed_files,
167 diff_index,
168 ws_roots,
169 group_resolver,
170 })
171}
172
173fn health_err_to_exit(error: HealthError, output: OutputFormat) -> ExitCode {
178 match error {
179 HealthError::Message { message, exit_code } => emit_error(&message, exit_code, output),
180 HealthError::Printed(code) => ExitCode::from(code),
181 }
182}
183
184fn load_health_config(
187 opts: &HealthOptions<'_>,
188) -> Result<(fallow_config::ResolvedConfig, f64), ExitCode> {
189 fallow_engine::health::validate_coverage_root_absolute(opts.coverage_inputs.coverage_root)
190 .map_err(|e| emit_error(&e, 2, opts.output))?;
191 validate_health_churn_file(opts).map_err(|e| health_err_to_exit(e, opts.output))?;
192 let t = Instant::now();
193 let config = crate::load_config_for_analysis(
194 opts.root,
195 opts.config_path,
196 crate::ConfigLoadOptions {
197 output: opts.output,
198 no_cache: opts.no_cache,
199 threads: opts.threads,
200 production_override: opts
201 .production_override
202 .or_else(|| opts.production.then_some(true)),
203 quiet: opts.quiet,
204 allow_remote_extends: opts.allow_remote_extends,
205 },
206 fallow_config::ProductionAnalysis::Health,
207 )?;
208 let config_ms = t.elapsed().as_secs_f64() * 1000.0;
209 Ok((config, config_ms))
210}
211
212pub fn execute_health_with_shared_parse(
216 opts: &HealthOptions<'_>,
217 shared: HealthSharedParseData,
218) -> Result<HealthResult, ExitCode> {
219 let (config, config_ms) = load_health_config(opts)?;
220 let scope_inputs = build_health_scope_inputs(opts, &config)?;
221 let workspace_diagnostics = fallow_config::workspace_diagnostics_for(&config.root);
222 let workspaces = shared.workspaces;
223 let seams = health_seams();
224 let result = execute_health_inner(
225 opts,
226 HealthPipelineInputs {
227 config,
228 files: shared.files,
229 modules: shared.modules,
230 config_ms,
231 discover_ms: 0.0,
232 parse_ms: 0.0,
233 parse_cpu_ms: 0.0,
234 shared_parse: true,
235 pre_computed_analysis: shared.analysis_output,
236 dead_code_results: shared.dead_code_results,
237 styling_artifacts: None,
238 pre_computed_duplication: None,
239 workspaces,
240 workspace_diagnostics,
241 },
242 scope_inputs,
243 &seams,
244 )
245 .map_err(|e| health_err_to_exit(e, opts.output))?;
246 record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
247 Ok(result)
248}
249
250pub fn execute_health(opts: &HealthOptions<'_>) -> Result<HealthResult, ExitCode> {
251 let (config, config_ms) = load_health_config(opts)?;
252
253 let t = Instant::now();
254 let session = fallow_engine::session::AnalysisSession::from_resolved_config(config);
255 let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
256 let parts = session.parsed_parts_uncached(true);
257 let pre_computed_analysis =
258 fallow_engine::health::should_precompute_dead_code_analysis(opts, session.config())
259 .then(|| session.analyze_dead_code_with_parsed_modules(&parts.modules))
260 .transpose()
261 .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))?;
262 let config = parts.config;
263 let files = parts.files;
264 let modules = parts.modules;
265 let workspaces = parts.workspaces;
266 let workspace_diagnostics = parts.workspace_diagnostics;
267 let parse_ms = parts.parse_ms;
268 let parse_cpu_ms = parts.parse_cpu_ms;
269
270 let scope_inputs = build_health_scope_inputs(opts, &config)?;
271 let seams = health_seams();
272 let result = execute_health_inner(
273 opts,
274 HealthPipelineInputs {
275 config,
276 files,
277 modules,
278 config_ms,
279 discover_ms,
280 parse_ms,
281 parse_cpu_ms,
282 shared_parse: false,
283 dead_code_results: None,
284 styling_artifacts: None,
285 pre_computed_analysis,
286 pre_computed_duplication: None,
287 workspaces,
288 workspace_diagnostics,
289 },
290 scope_inputs,
291 &seams,
292 )
293 .map_err(|e| health_err_to_exit(e, opts.output))?;
294 record_health_telemetry(&result.report, result.coverage_gaps_has_findings);
295 Ok(result)
296}
297
298pub fn run_health(opts: &HealthOptions<'_>, json_style: crate::json_style::JsonStyle) -> ExitCode {
299 let result = match execute_health(opts) {
300 Ok(r) => r,
301 Err(code) => return code,
302 };
303 if let Some(ref timings) = result.timings {
304 report::print_health_performance(timings, opts.output, json_style);
305 }
306 print_health_result(
307 &result,
308 HealthPrintOptions {
309 quiet: opts.quiet,
310 explain: opts.explain,
311 gates: opts.gates,
312 summary: opts.summary,
313 summary_heading: true,
314 show_explain_tip: true,
315 skip_score_and_trend: false,
316 css_requested: opts.css,
317 json_style,
318 },
319 )
320}
321
322pub type HealthResult =
324 fallow_engine::health::HealthAnalysisResult<crate::report::OwnershipResolver>;
325
326#[derive(Clone, Copy)]
346pub struct HealthPrintOptions {
347 pub quiet: bool,
348 pub explain: bool,
349 pub gates: HealthGateOptions,
350 pub summary: bool,
351 pub summary_heading: bool,
352 pub show_explain_tip: bool,
353 pub skip_score_and_trend: bool,
354 pub css_requested: bool,
358 pub json_style: crate::json_style::JsonStyle,
359}
360
361pub fn print_health_result(result: &HealthResult, options: HealthPrintOptions) -> ExitCode {
362 let ctx = health_report_context(result, options);
363 let report_code = report::print_health_report(
364 &result.report,
365 result.grouping.as_ref(),
366 result.group_resolver.as_ref(),
367 &ctx,
368 result.config.output,
369 );
370 if report_code != ExitCode::SUCCESS {
371 return report_code;
372 }
373
374 if options.gates.report_only {
375 return ExitCode::SUCCESS;
376 }
377
378 if health_exit_gate_failed(result, options) {
379 return ExitCode::from(1);
380 }
381 if result.should_fail_on_coverage_gaps && result.coverage_gaps_has_findings {
382 return ExitCode::from(1);
383 }
384 maybe_print_score_gate_note(result, options);
385
386 ExitCode::SUCCESS
387}
388
389fn health_report_context(
390 result: &HealthResult,
391 options: HealthPrintOptions,
392) -> report::ReportContext<'_> {
393 report::ReportContext {
394 root: &result.config.root,
395 rules: &result.config.rules,
396 elapsed: result.elapsed,
397 quiet: options.quiet,
398 explain: options.explain,
399 group_by: None,
400 top: None,
401 summary: options.summary,
402 summary_heading: options.summary_heading,
403 show_explain_tip: options.show_explain_tip,
404 baseline_matched: None,
405 config_fixable: false,
406 skip_score_and_trend: options.skip_score_and_trend,
407 css_requested: options.css_requested,
408 json_style: options.json_style,
409 }
410}
411
412fn health_exit_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
413 score_gate_failed(result, options)
414 || findings_gate_failed(result, options)
415 || has_failing_runtime_coverage(result)
416}
417
418fn score_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
419 let Some(threshold) = options.gates.min_score else {
420 return false;
421 };
422 let Some(ref hs) = result.report.health_score else {
423 return false;
424 };
425 if hs.score >= threshold {
426 return false;
427 }
428
429 if !options.quiet {
430 eprintln!(
431 "Health score {:.1} ({}) is below minimum threshold {:.0}",
432 hs.score, hs.grade, threshold
433 );
434 }
435 true
436}
437
438fn findings_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
439 if let Some(min_sev) = options.gates.min_severity {
440 result.report.findings.iter().any(|f| f.severity >= min_sev)
441 } else if options.gates.min_score.is_none() {
442 !result.report.findings.is_empty()
443 } else {
444 false
445 }
446}
447
448fn has_failing_runtime_coverage(result: &HealthResult) -> bool {
449 result
450 .report
451 .runtime_coverage
452 .as_ref()
453 .is_some_and(|report| report.findings.iter().any(is_failing_runtime_coverage))
454}
455
456fn is_failing_runtime_coverage(finding: &fallow_output::RuntimeCoverageFinding) -> bool {
457 matches!(
458 finding.verdict,
459 fallow_output::RuntimeCoverageVerdict::SafeToDelete
460 | fallow_output::RuntimeCoverageVerdict::ReviewRequired
461 | fallow_output::RuntimeCoverageVerdict::LowTraffic
462 )
463}
464
465fn maybe_print_score_gate_note(result: &HealthResult, options: HealthPrintOptions) {
466 if options.gates.min_score.is_none()
467 || options.gates.min_severity.is_some()
468 || options.quiet
469 || result.report.findings.is_empty()
470 || !matches!(result.config.output, OutputFormat::Human)
471 {
472 return;
473 }
474
475 {
476 eprintln!(
477 "{}",
478 "Findings above are informational: --min-score gates on the score, not on findings."
479 .dimmed()
480 );
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use fallow_config::{FallowConfig, OutputFormat};
488 use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
489 use std::path::PathBuf;
490 use std::time::Duration;
491
492 fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
493 ComplexityViolation {
494 path: PathBuf::from("/project/src/a.ts"),
495 name: name.to_string(),
496 line: 1,
497 col: 0,
498 cyclomatic: match exceeded {
499 ExceededThreshold::Cyclomatic
500 | ExceededThreshold::Both
501 | ExceededThreshold::CyclomaticCrap
502 | ExceededThreshold::All => 25,
503 _ => 8,
504 },
505 cognitive: match exceeded {
506 ExceededThreshold::Cognitive
507 | ExceededThreshold::Both
508 | ExceededThreshold::CognitiveCrap
509 | ExceededThreshold::All => 20,
510 _ => 5,
511 },
512 line_count: 10,
513 param_count: 0,
514 react_hook_count: 0,
515 react_jsx_max_depth: 0,
516 react_prop_count: 0,
517 react_hook_profile: None,
518 exceeded,
519 severity: FindingSeverity::Moderate,
520 crap: exceeded.includes_crap().then_some(30.0),
521 coverage_pct: None,
522 coverage_tier: None,
523 coverage_source: None,
524 inherited_from: None,
525 component_rollup: None,
526 contributions: Vec::new(),
527 effective_thresholds: None,
528 threshold_source: None,
529 }
530 }
531
532 fn test_resolved_config() -> fallow_config::ResolvedConfig {
533 FallowConfig::default().resolve(
534 PathBuf::from("/project"),
535 OutputFormat::Json,
536 1,
537 true,
538 true,
539 None,
540 )
541 }
542
543 fn fx_summary(
544 tracked: usize,
545 hit: usize,
546 unhit: usize,
547 untracked: usize,
548 ) -> fallow_output::RuntimeCoverageSummary {
549 #[expect(
550 clippy::cast_precision_loss,
551 reason = "test fixture totals are tiny, f64 precision is fine"
552 )]
553 let coverage_percent = if tracked == 0 {
554 0.0
555 } else {
556 (hit as f64 / tracked as f64) * 100.0
557 };
558 fallow_output::RuntimeCoverageSummary {
559 data_source: fallow_output::RuntimeCoverageDataSource::Local,
560 last_received_at: None,
561 functions_tracked: tracked,
562 functions_hit: hit,
563 functions_unhit: unhit,
564 functions_untracked: untracked,
565 coverage_percent,
566 trace_count: 512,
567 period_days: 7,
568 deployments_seen: 2,
569 capture_quality: None,
570 }
571 }
572
573 fn fx_evidence(
574 static_status: &str,
575 test_coverage: &str,
576 v8_tracking: &str,
577 ) -> fallow_output::RuntimeCoverageEvidence {
578 fallow_output::RuntimeCoverageEvidence {
579 static_status: static_status.to_owned(),
580 test_coverage: test_coverage.to_owned(),
581 v8_tracking: v8_tracking.to_owned(),
582 untracked_reason: None,
583 observation_days: 7,
584 deployments_observed: 2,
585 }
586 }
587
588 fn fx_health_score(score: f64, grade: &'static str) -> fallow_output::HealthScore {
589 fallow_output::HealthScore {
590 formula_version: 2,
591 score,
592 grade,
593 penalties: fallow_output::HealthScorePenalties {
594 dead_files: None,
595 dead_exports: None,
596 complexity: 0.0,
597 p90_complexity: 0.0,
598 maintainability: None,
599 hotspots: None,
600 unused_deps: None,
601 circular_deps: None,
602 unit_size: None,
603 coupling: None,
604 duplication: None,
605 prop_drilling: None,
606 },
607 }
608 }
609
610 fn fx_gate_result(
611 findings: Vec<fallow_output::HealthFinding>,
612 score: Option<fallow_output::HealthScore>,
613 ) -> HealthResult {
614 HealthResult {
615 report: fallow_output::HealthReport {
616 findings,
617 health_score: score,
618 ..fallow_output::HealthReport::default()
619 },
620 grouping: None,
621 group_resolver: None,
622 config: test_resolved_config(),
623 workspace_diagnostics: Vec::new(),
624 elapsed: Duration::default(),
625 timings: None,
626 coverage_gaps_has_findings: false,
627 should_fail_on_coverage_gaps: false,
628 }
629 }
630
631 fn moderate_finding() -> fallow_output::HealthFinding {
632 make_finding("moderate", ExceededThreshold::Cyclomatic).into()
633 }
634
635 fn critical_finding() -> fallow_output::HealthFinding {
636 let mut v = make_finding("critical", ExceededThreshold::All);
637 v.severity = FindingSeverity::Critical;
638 v.into()
639 }
640
641 fn gate_exit(
643 result: &HealthResult,
644 min_score: Option<f64>,
645 min_severity: Option<FindingSeverity>,
646 report_only: bool,
647 ) -> ExitCode {
648 print_health_result(
649 result,
650 HealthPrintOptions {
651 quiet: true,
652 explain: false,
653 gates: HealthGateOptions {
654 min_score,
655 min_severity,
656 report_only,
657 },
658 summary: false,
659 summary_heading: true,
660 show_explain_tip: true,
661 skip_score_and_trend: false,
662 css_requested: false,
663 json_style: crate::json_style::JsonStyle::Compact,
664 },
665 )
666 }
667
668 #[test]
669 fn plain_health_with_findings_fails() {
670 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
671 assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
672 }
673
674 #[test]
675 fn plain_health_with_no_findings_succeeds() {
676 let result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
677 assert_eq!(gate_exit(&result, None, None, false), ExitCode::SUCCESS);
678 }
679
680 #[test]
681 fn min_score_zero_never_fails_even_with_findings() {
682 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
683 assert_eq!(
684 gate_exit(&result, Some(0.0), None, false),
685 ExitCode::SUCCESS
686 );
687 }
688
689 #[test]
690 fn min_score_passing_demotes_findings_to_informational() {
691 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
692 assert_eq!(
693 gate_exit(&result, Some(80.0), None, false),
694 ExitCode::SUCCESS
695 );
696 }
697
698 #[test]
699 fn min_score_below_threshold_fails() {
700 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
701 assert_eq!(
702 gate_exit(&result, Some(80.0), None, false),
703 ExitCode::from(1)
704 );
705 }
706
707 #[test]
708 fn min_severity_gates_on_severity_independent_of_min_score() {
709 let only_moderate =
710 fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
711 assert_eq!(
712 gate_exit(&only_moderate, None, Some(FindingSeverity::Critical), false),
713 ExitCode::SUCCESS,
714 );
715 let with_critical = fx_gate_result(
716 vec![moderate_finding(), critical_finding()],
717 Some(fx_health_score(87.5, "A")),
718 );
719 assert_eq!(
720 gate_exit(&with_critical, None, Some(FindingSeverity::Critical), false),
721 ExitCode::from(1),
722 );
723 }
724
725 #[test]
726 fn min_score_and_min_severity_compose_as_or() {
727 let pass = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
728 assert_eq!(
729 gate_exit(&pass, Some(80.0), Some(FindingSeverity::Critical), false),
730 ExitCode::SUCCESS,
731 );
732 let low_score = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
733 assert_eq!(
734 gate_exit(
735 &low_score,
736 Some(80.0),
737 Some(FindingSeverity::Critical),
738 false
739 ),
740 ExitCode::from(1),
741 );
742 let critical = fx_gate_result(vec![critical_finding()], Some(fx_health_score(87.5, "A")));
743 assert_eq!(
744 gate_exit(
745 &critical,
746 Some(80.0),
747 Some(FindingSeverity::Critical),
748 false
749 ),
750 ExitCode::from(1),
751 );
752 }
753
754 #[test]
755 fn report_only_never_fails_on_findings_or_low_score() {
756 let result = fx_gate_result(
757 vec![moderate_finding(), critical_finding()],
758 Some(fx_health_score(10.0, "F")),
759 );
760 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
761 }
762
763 #[test]
764 fn runtime_coverage_gate_independent_of_min_score() {
765 let result = fx_low_traffic_runtime_result();
766 assert_eq!(
767 gate_exit(&result, Some(0.0), None, false),
768 ExitCode::from(1)
769 );
770 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
771 }
772
773 fn fx_low_traffic_runtime_result() -> HealthResult {
774 HealthResult {
775 report: fallow_output::HealthReport {
776 runtime_coverage: Some(fallow_output::RuntimeCoverageReport {
777 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
778 verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
779 signals: Vec::new(),
780 summary: fx_summary(1, 0, 1, 0),
781 findings: vec![fallow_output::RuntimeCoverageFinding {
782 id: "fallow:prod:lowtraffic".to_owned(),
783 stable_id: None,
784 path: PathBuf::from("/project/src/cold.ts"),
785 function: "coldPath".to_owned(),
786 line: 14,
787 verdict: fallow_output::RuntimeCoverageVerdict::LowTraffic,
788 invocations: Some(1),
789 confidence: fallow_output::RuntimeCoverageConfidence::Low,
790 evidence: fx_evidence("used", "not_covered", "tracked"),
791 actions: vec![],
792 source_hash: None,
793 discriminators: None,
794 }],
795 hot_paths: vec![],
796 blast_radius: vec![],
797 importance: vec![],
798 watermark: None,
799 warnings: vec![],
800 actionable: true,
801 actionability_reason: None,
802 actionability_verdict: None,
803 provenance: fallow_output::RuntimeCoverageProvenance::default(),
804 }),
805 ..fallow_output::HealthReport::default()
806 },
807 grouping: None,
808 group_resolver: None,
809 config: test_resolved_config(),
810 workspace_diagnostics: Vec::new(),
811 elapsed: Duration::default(),
812 timings: None,
813 coverage_gaps_has_findings: false,
814 should_fail_on_coverage_gaps: false,
815 }
816 }
817
818 #[test]
819 fn print_health_result_fails_on_low_traffic_runtime_coverage() {
820 let result = fx_low_traffic_runtime_result();
821
822 assert_eq!(
823 print_health_result(
824 &result,
825 HealthPrintOptions {
826 quiet: true,
827 explain: false,
828 gates: HealthGateOptions::default(),
829 summary: false,
830 summary_heading: true,
831 show_explain_tip: true,
832 skip_score_and_trend: false,
833 css_requested: false,
834 json_style: crate::json_style::JsonStyle::Compact,
835 },
836 ),
837 ExitCode::from(1),
838 );
839 }
840}