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