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<'_>) -> 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);
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 },
318 )
319}
320
321pub type HealthResult =
323 fallow_engine::health::HealthAnalysisResult<crate::report::OwnershipResolver>;
324
325#[derive(Clone, Copy)]
345pub struct HealthPrintOptions {
346 pub quiet: bool,
347 pub explain: bool,
348 pub gates: HealthGateOptions,
349 pub summary: bool,
350 pub summary_heading: bool,
351 pub show_explain_tip: bool,
352 pub skip_score_and_trend: bool,
353 pub css_requested: bool,
357}
358
359pub fn print_health_result(result: &HealthResult, options: HealthPrintOptions) -> ExitCode {
360 let ctx = health_report_context(result, options);
361 let report_code = report::print_health_report(
362 &result.report,
363 result.grouping.as_ref(),
364 result.group_resolver.as_ref(),
365 &ctx,
366 result.config.output,
367 );
368 if report_code != ExitCode::SUCCESS {
369 return report_code;
370 }
371
372 if options.gates.report_only {
373 return ExitCode::SUCCESS;
374 }
375
376 if health_exit_gate_failed(result, options) {
377 return ExitCode::from(1);
378 }
379 if result.should_fail_on_coverage_gaps && result.coverage_gaps_has_findings {
380 return ExitCode::from(1);
381 }
382 maybe_print_score_gate_note(result, options);
383
384 ExitCode::SUCCESS
385}
386
387fn health_report_context(
388 result: &HealthResult,
389 options: HealthPrintOptions,
390) -> report::ReportContext<'_> {
391 report::ReportContext {
392 root: &result.config.root,
393 rules: &result.config.rules,
394 elapsed: result.elapsed,
395 quiet: options.quiet,
396 explain: options.explain,
397 group_by: None,
398 top: None,
399 summary: options.summary,
400 summary_heading: options.summary_heading,
401 show_explain_tip: options.show_explain_tip,
402 baseline_matched: None,
403 config_fixable: false,
404 skip_score_and_trend: options.skip_score_and_trend,
405 css_requested: options.css_requested,
406 }
407}
408
409fn health_exit_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
410 score_gate_failed(result, options)
411 || findings_gate_failed(result, options)
412 || has_failing_runtime_coverage(result)
413}
414
415fn score_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
416 let Some(threshold) = options.gates.min_score else {
417 return false;
418 };
419 let Some(ref hs) = result.report.health_score else {
420 return false;
421 };
422 if hs.score >= threshold {
423 return false;
424 }
425
426 if !options.quiet {
427 eprintln!(
428 "Health score {:.1} ({}) is below minimum threshold {:.0}",
429 hs.score, hs.grade, threshold
430 );
431 }
432 true
433}
434
435fn findings_gate_failed(result: &HealthResult, options: HealthPrintOptions) -> bool {
436 if let Some(min_sev) = options.gates.min_severity {
437 result.report.findings.iter().any(|f| f.severity >= min_sev)
438 } else if options.gates.min_score.is_none() {
439 !result.report.findings.is_empty()
440 } else {
441 false
442 }
443}
444
445fn has_failing_runtime_coverage(result: &HealthResult) -> bool {
446 result
447 .report
448 .runtime_coverage
449 .as_ref()
450 .is_some_and(|report| report.findings.iter().any(is_failing_runtime_coverage))
451}
452
453fn is_failing_runtime_coverage(finding: &fallow_output::RuntimeCoverageFinding) -> bool {
454 matches!(
455 finding.verdict,
456 fallow_output::RuntimeCoverageVerdict::SafeToDelete
457 | fallow_output::RuntimeCoverageVerdict::ReviewRequired
458 | fallow_output::RuntimeCoverageVerdict::LowTraffic
459 )
460}
461
462fn maybe_print_score_gate_note(result: &HealthResult, options: HealthPrintOptions) {
463 if options.gates.min_score.is_none()
464 || options.gates.min_severity.is_some()
465 || options.quiet
466 || result.report.findings.is_empty()
467 || !matches!(result.config.output, OutputFormat::Human)
468 {
469 return;
470 }
471
472 {
473 eprintln!(
474 "{}",
475 "Findings above are informational: --min-score gates on the score, not on findings."
476 .dimmed()
477 );
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use fallow_config::{FallowConfig, OutputFormat};
485 use fallow_output::{ComplexityViolation, ExceededThreshold, FindingSeverity};
486 use std::path::PathBuf;
487 use std::time::Duration;
488
489 fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
490 ComplexityViolation {
491 path: PathBuf::from("/project/src/a.ts"),
492 name: name.to_string(),
493 line: 1,
494 col: 0,
495 cyclomatic: match exceeded {
496 ExceededThreshold::Cyclomatic
497 | ExceededThreshold::Both
498 | ExceededThreshold::CyclomaticCrap
499 | ExceededThreshold::All => 25,
500 _ => 8,
501 },
502 cognitive: match exceeded {
503 ExceededThreshold::Cognitive
504 | ExceededThreshold::Both
505 | ExceededThreshold::CognitiveCrap
506 | ExceededThreshold::All => 20,
507 _ => 5,
508 },
509 line_count: 10,
510 param_count: 0,
511 react_hook_count: 0,
512 react_jsx_max_depth: 0,
513 react_prop_count: 0,
514 react_hook_profile: None,
515 exceeded,
516 severity: FindingSeverity::Moderate,
517 crap: exceeded.includes_crap().then_some(30.0),
518 coverage_pct: None,
519 coverage_tier: None,
520 coverage_source: None,
521 inherited_from: None,
522 component_rollup: None,
523 contributions: Vec::new(),
524 effective_thresholds: None,
525 threshold_source: None,
526 }
527 }
528
529 fn test_resolved_config() -> fallow_config::ResolvedConfig {
530 FallowConfig::default().resolve(
531 PathBuf::from("/project"),
532 OutputFormat::Json,
533 1,
534 true,
535 true,
536 None,
537 )
538 }
539
540 fn fx_summary(
541 tracked: usize,
542 hit: usize,
543 unhit: usize,
544 untracked: usize,
545 ) -> fallow_output::RuntimeCoverageSummary {
546 #[expect(
547 clippy::cast_precision_loss,
548 reason = "test fixture totals are tiny, f64 precision is fine"
549 )]
550 let coverage_percent = if tracked == 0 {
551 0.0
552 } else {
553 (hit as f64 / tracked as f64) * 100.0
554 };
555 fallow_output::RuntimeCoverageSummary {
556 data_source: fallow_output::RuntimeCoverageDataSource::Local,
557 last_received_at: None,
558 functions_tracked: tracked,
559 functions_hit: hit,
560 functions_unhit: unhit,
561 functions_untracked: untracked,
562 coverage_percent,
563 trace_count: 512,
564 period_days: 7,
565 deployments_seen: 2,
566 capture_quality: None,
567 }
568 }
569
570 fn fx_evidence(
571 static_status: &str,
572 test_coverage: &str,
573 v8_tracking: &str,
574 ) -> fallow_output::RuntimeCoverageEvidence {
575 fallow_output::RuntimeCoverageEvidence {
576 static_status: static_status.to_owned(),
577 test_coverage: test_coverage.to_owned(),
578 v8_tracking: v8_tracking.to_owned(),
579 untracked_reason: None,
580 observation_days: 7,
581 deployments_observed: 2,
582 }
583 }
584
585 fn fx_health_score(score: f64, grade: &'static str) -> fallow_output::HealthScore {
586 fallow_output::HealthScore {
587 formula_version: 2,
588 score,
589 grade,
590 penalties: fallow_output::HealthScorePenalties {
591 dead_files: None,
592 dead_exports: None,
593 complexity: 0.0,
594 p90_complexity: 0.0,
595 maintainability: None,
596 hotspots: None,
597 unused_deps: None,
598 circular_deps: None,
599 unit_size: None,
600 coupling: None,
601 duplication: None,
602 prop_drilling: None,
603 },
604 }
605 }
606
607 fn fx_gate_result(
608 findings: Vec<fallow_output::HealthFinding>,
609 score: Option<fallow_output::HealthScore>,
610 ) -> HealthResult {
611 HealthResult {
612 report: fallow_output::HealthReport {
613 findings,
614 health_score: score,
615 ..fallow_output::HealthReport::default()
616 },
617 grouping: None,
618 group_resolver: None,
619 config: test_resolved_config(),
620 workspace_diagnostics: Vec::new(),
621 elapsed: Duration::default(),
622 timings: None,
623 coverage_gaps_has_findings: false,
624 should_fail_on_coverage_gaps: false,
625 }
626 }
627
628 fn moderate_finding() -> fallow_output::HealthFinding {
629 make_finding("moderate", ExceededThreshold::Cyclomatic).into()
630 }
631
632 fn critical_finding() -> fallow_output::HealthFinding {
633 let mut v = make_finding("critical", ExceededThreshold::All);
634 v.severity = FindingSeverity::Critical;
635 v.into()
636 }
637
638 fn gate_exit(
640 result: &HealthResult,
641 min_score: Option<f64>,
642 min_severity: Option<FindingSeverity>,
643 report_only: bool,
644 ) -> ExitCode {
645 print_health_result(
646 result,
647 HealthPrintOptions {
648 quiet: true,
649 explain: false,
650 gates: HealthGateOptions {
651 min_score,
652 min_severity,
653 report_only,
654 },
655 summary: false,
656 summary_heading: true,
657 show_explain_tip: true,
658 skip_score_and_trend: false,
659 css_requested: false,
660 },
661 )
662 }
663
664 #[test]
665 fn plain_health_with_findings_fails() {
666 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
667 assert_eq!(gate_exit(&result, None, None, false), ExitCode::from(1));
668 }
669
670 #[test]
671 fn plain_health_with_no_findings_succeeds() {
672 let result = fx_gate_result(vec![], Some(fx_health_score(100.0, "A")));
673 assert_eq!(gate_exit(&result, None, None, false), ExitCode::SUCCESS);
674 }
675
676 #[test]
677 fn min_score_zero_never_fails_even_with_findings() {
678 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
679 assert_eq!(
680 gate_exit(&result, Some(0.0), None, false),
681 ExitCode::SUCCESS
682 );
683 }
684
685 #[test]
686 fn min_score_passing_demotes_findings_to_informational() {
687 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
688 assert_eq!(
689 gate_exit(&result, Some(80.0), None, false),
690 ExitCode::SUCCESS
691 );
692 }
693
694 #[test]
695 fn min_score_below_threshold_fails() {
696 let result = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
697 assert_eq!(
698 gate_exit(&result, Some(80.0), None, false),
699 ExitCode::from(1)
700 );
701 }
702
703 #[test]
704 fn min_severity_gates_on_severity_independent_of_min_score() {
705 let only_moderate =
706 fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
707 assert_eq!(
708 gate_exit(&only_moderate, None, Some(FindingSeverity::Critical), false),
709 ExitCode::SUCCESS,
710 );
711 let with_critical = fx_gate_result(
712 vec![moderate_finding(), critical_finding()],
713 Some(fx_health_score(87.5, "A")),
714 );
715 assert_eq!(
716 gate_exit(&with_critical, None, Some(FindingSeverity::Critical), false),
717 ExitCode::from(1),
718 );
719 }
720
721 #[test]
722 fn min_score_and_min_severity_compose_as_or() {
723 let pass = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(87.5, "A")));
724 assert_eq!(
725 gate_exit(&pass, Some(80.0), Some(FindingSeverity::Critical), false),
726 ExitCode::SUCCESS,
727 );
728 let low_score = fx_gate_result(vec![moderate_finding()], Some(fx_health_score(50.0, "D")));
729 assert_eq!(
730 gate_exit(
731 &low_score,
732 Some(80.0),
733 Some(FindingSeverity::Critical),
734 false
735 ),
736 ExitCode::from(1),
737 );
738 let critical = fx_gate_result(vec![critical_finding()], Some(fx_health_score(87.5, "A")));
739 assert_eq!(
740 gate_exit(
741 &critical,
742 Some(80.0),
743 Some(FindingSeverity::Critical),
744 false
745 ),
746 ExitCode::from(1),
747 );
748 }
749
750 #[test]
751 fn report_only_never_fails_on_findings_or_low_score() {
752 let result = fx_gate_result(
753 vec![moderate_finding(), critical_finding()],
754 Some(fx_health_score(10.0, "F")),
755 );
756 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
757 }
758
759 #[test]
760 fn runtime_coverage_gate_independent_of_min_score() {
761 let result = fx_low_traffic_runtime_result();
762 assert_eq!(
763 gate_exit(&result, Some(0.0), None, false),
764 ExitCode::from(1)
765 );
766 assert_eq!(gate_exit(&result, None, None, true), ExitCode::SUCCESS);
767 }
768
769 fn fx_low_traffic_runtime_result() -> HealthResult {
770 HealthResult {
771 report: fallow_output::HealthReport {
772 runtime_coverage: Some(fallow_output::RuntimeCoverageReport {
773 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
774 verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
775 signals: Vec::new(),
776 summary: fx_summary(1, 0, 1, 0),
777 findings: vec![fallow_output::RuntimeCoverageFinding {
778 id: "fallow:prod:lowtraffic".to_owned(),
779 stable_id: None,
780 path: PathBuf::from("/project/src/cold.ts"),
781 function: "coldPath".to_owned(),
782 line: 14,
783 verdict: fallow_output::RuntimeCoverageVerdict::LowTraffic,
784 invocations: Some(1),
785 confidence: fallow_output::RuntimeCoverageConfidence::Low,
786 evidence: fx_evidence("used", "not_covered", "tracked"),
787 actions: vec![],
788 source_hash: None,
789 discriminators: None,
790 }],
791 hot_paths: vec![],
792 blast_radius: vec![],
793 importance: vec![],
794 watermark: None,
795 warnings: vec![],
796 actionable: true,
797 actionability_reason: None,
798 actionability_verdict: None,
799 provenance: fallow_output::RuntimeCoverageProvenance::default(),
800 }),
801 ..fallow_output::HealthReport::default()
802 },
803 grouping: None,
804 group_resolver: None,
805 config: test_resolved_config(),
806 workspace_diagnostics: Vec::new(),
807 elapsed: Duration::default(),
808 timings: None,
809 coverage_gaps_has_findings: false,
810 should_fail_on_coverage_gaps: false,
811 }
812 }
813
814 #[test]
815 fn print_health_result_fails_on_low_traffic_runtime_coverage() {
816 let result = fx_low_traffic_runtime_result();
817
818 assert_eq!(
819 print_health_result(
820 &result,
821 HealthPrintOptions {
822 quiet: true,
823 explain: false,
824 gates: HealthGateOptions::default(),
825 summary: false,
826 summary_heading: true,
827 show_explain_tip: true,
828 skip_score_and_trend: false,
829 css_requested: false,
830 },
831 ),
832 ExitCode::from(1),
833 );
834 }
835}