1use crate::report::sink::outln;
2use std::fmt::Write as _;
3use std::path::Path;
4use std::time::Duration;
5
6use colored::Colorize;
7
8use super::health_hotspots::render_hotspots;
9use super::health_runtime::render_runtime_coverage;
10use super::health_targets::render_refactoring_targets;
11use super::{
12 MAX_FLAT_ITEMS, format_path, plural, print_explain_tip_if_tty, relative_path,
13 split_dir_filename, thousands,
14};
15use crate::health::scoring::{FileScoreConcern, file_score_concern_axis};
16
17const DOCS_HEALTH: &str = "https://docs.fallow.tools/explanations/health";
19
20pub(in crate::report) struct PrintHealthHumanInput<'a> {
21 pub(in crate::report) report: &'a crate::health_types::HealthReport,
22 pub(in crate::report) root: &'a Path,
23 pub(in crate::report) elapsed: Duration,
24 pub(in crate::report) quiet: bool,
25 pub(in crate::report) show_explain_tip: bool,
26 pub(in crate::report) explain: bool,
27 pub(in crate::report) skip_score_and_trend: bool,
28}
29
30pub(in crate::report) fn print_health_human(input: &PrintHealthHumanInput<'_>) {
31 let report = input.report;
32 let root = input.root;
33 let elapsed = input.elapsed;
34 let quiet = input.quiet;
35 let show_explain_tip = input.show_explain_tip;
36 let explain = input.explain;
37 let skip_score_and_trend = input.skip_score_and_trend;
38 if !quiet {
39 eprintln!();
40 }
41
42 let has_score = report.health_score.is_some();
43 if report.findings.is_empty()
44 && report.file_scores.is_empty()
45 && report.coverage_gaps.is_none()
46 && report.hotspots.is_empty()
47 && report.targets.is_empty()
48 && report.runtime_coverage.is_none()
49 && report.coverage_intelligence.is_none()
50 && !has_score
51 {
52 if !quiet {
53 eprintln!(
54 "{}",
55 format!(
56 "\u{2713} No functions exceed complexity thresholds ({:.2}s)",
57 elapsed.as_secs_f64()
58 )
59 .green()
60 .bold()
61 );
62 eprintln!(
63 "{}",
64 format!(
65 " {} functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {:.1})",
66 report.summary.functions_analyzed,
67 report.summary.max_cyclomatic_threshold,
68 report.summary.max_cognitive_threshold,
69 report.summary.max_crap_threshold,
70 )
71 .dimmed()
72 );
73 }
74 return;
75 }
76
77 let has_findings = !report.findings.is_empty()
78 || report.coverage_gaps.as_ref().is_some_and(|gaps| {
79 gaps.summary.untested_files > 0 || gaps.summary.untested_exports > 0
80 })
81 || report
82 .runtime_coverage
83 .as_ref()
84 .is_some_and(|coverage| !coverage.findings.is_empty());
85 print_explain_tip_if_tty(show_explain_tip && has_findings, quiet);
86
87 let lines = build_health_human_lines_with_explain(report, root, explain, skip_score_and_trend);
88 for line in lines {
89 outln!("{line}");
90 }
91
92 if !quiet {
93 let s = &report.summary;
94 let mut parts = Vec::new();
95 parts.push(format!("{} above threshold", s.functions_above_threshold));
96 parts.push(format!("{} analyzed", s.functions_analyzed));
97 if let Some(avg) = s.average_maintainability {
98 let label = if avg >= 85.0 {
99 "good"
100 } else if avg >= 65.0 {
101 "moderate"
102 } else {
103 "low"
104 };
105 parts.push(format!("maintainability {avg:.1} ({label})"));
106 }
107 if let Some(ref production) = report.runtime_coverage {
108 parts.push(format!(
109 "{} unhit in production",
110 production.summary.functions_unhit
111 ));
112 }
113 eprintln!(
114 "{}",
115 format!(
116 "\u{2717} {} ({:.2}s)",
117 parts.join(" \u{00b7} "),
118 elapsed.as_secs_f64()
119 )
120 .red()
121 .bold()
122 );
123 if s.average_maintainability.is_some_and(|mi| mi < 85.0) {
124 eprintln!(
125 "{}",
126 " Maintainability scale: good \u{2265}85, moderate \u{2265}65, low <65 (0\u{2013}100)"
127 .dimmed()
128 );
129 }
130 }
131}
132
133#[cfg(test)]
136fn build_health_human_lines(
137 report: &crate::health_types::HealthReport,
138 root: &Path,
139) -> Vec<String> {
140 build_health_human_lines_with_explain(report, root, false, false)
141}
142
143fn build_health_human_lines_with_explain(
144 report: &crate::health_types::HealthReport,
145 root: &Path,
146 explain: bool,
147 skip_score_and_trend: bool,
148) -> Vec<String> {
149 let mut lines = Vec::new();
150 if !skip_score_and_trend {
151 render_health_score(&mut lines, report);
152 render_health_trend(&mut lines, report);
153 }
154 render_runtime_coverage(&mut lines, report, root);
155 render_coverage_intelligence(&mut lines, report, root);
156 render_vital_signs(&mut lines, report);
157 render_risk_profiles(&mut lines, report);
158 render_large_functions(&mut lines, report, root);
159 render_findings(&mut lines, report, root);
160 render_coverage_gaps(&mut lines, report, root);
161 render_file_scores(&mut lines, report, root);
162 render_hotspots(&mut lines, report, root);
163 render_refactoring_targets(&mut lines, report, root);
164 if explain {
165 inject_explain_blocks(lines)
166 } else {
167 lines
168 }
169}
170
171fn render_coverage_intelligence(
172 lines: &mut Vec<String>,
173 report: &crate::health_types::HealthReport,
174 root: &Path,
175) {
176 let Some(ref intelligence) = report.coverage_intelligence else {
177 return;
178 };
179
180 lines.push(String::new());
181 lines.push("Coverage intelligence".bold().to_string());
182 lines.push(
183 format!(" Verdict: {}", intelligence.verdict)
184 .bold()
185 .to_string(),
186 );
187 if intelligence.findings.is_empty() {
188 if intelligence.summary.skipped_ambiguous_matches > 0 {
189 let match_word = if intelligence.summary.skipped_ambiguous_matches == 1 {
190 "match"
191 } else {
192 "matches"
193 };
194 lines.push(format!(
195 " No actionable findings; skipped {} ambiguous evidence {match_word}.",
196 intelligence.summary.skipped_ambiguous_matches
197 ));
198 }
199 return;
200 }
201 for finding in intelligence.findings.iter().take(MAX_FLAT_ITEMS) {
202 let relative = relative_path(&finding.path, root);
203 let identity = finding
204 .identity
205 .as_deref()
206 .map_or(String::new(), |name| format!(" {name}"));
207 let signals = finding
208 .signals
209 .iter()
210 .map(ToString::to_string)
211 .collect::<Vec<_>>()
212 .join(", ");
213 let action = finding
214 .actions
215 .first()
216 .map_or("Review this finding", |action| action.description.as_str());
217 lines.push(format!(
218 " {}:{}{} {} [{}]",
219 format_path(&relative.display().to_string()),
220 finding.line,
221 identity,
222 finding.verdict,
223 signals,
224 ));
225 lines.push(format!(" {action}"));
226 }
227}
228
229fn inject_explain_blocks(lines: Vec<String>) -> Vec<String> {
230 let mut out = Vec::with_capacity(lines.len());
231 for line in lines {
232 let explain = health_explain_for_header(&line);
233 out.push(line);
234 if let Some(text) = explain {
235 out.push(format!(" {}", format!("Description: {text}").dimmed()));
236 }
237 }
238 out
239}
240
241fn health_explain_for_header(line: &str) -> Option<String> {
242 if line.contains("Runtime coverage:") {
243 return rule_full("fallow/runtime-coverage");
244 }
245 if line.contains("Health score:") {
246 return Some(
247 "The 0-100 project health grade combines dead code, complexity, maintainability, duplication, dependency, hotspot, and coverage signals when available."
248 .to_string(),
249 );
250 }
251 if line.contains("Metrics:") {
252 return Some(
253 "Vital signs summarize the analyzed project before truncation: dead-code percentages, maintainability index, hotspot count, circular dependencies, unused dependencies, and duplication where available."
254 .to_string(),
255 );
256 }
257 if line.contains("Large functions (") {
258 return rule_full("fallow/high-cyclomatic-complexity");
259 }
260 if line.contains("High complexity functions (") {
261 return rule_full("fallow/high-complexity");
262 }
263 if line.contains("Coverage gaps (") {
264 return Some(
265 "Coverage gaps identify runtime-reachable files or exports with no static path from discovered test entry points."
266 .to_string(),
267 );
268 }
269 if line.contains("Hotspots (") {
270 return Some(
271 "Hotspots combine recent churn with complexity so frequently changed risky files surface before quieter debt."
272 .to_string(),
273 );
274 }
275 if line.contains("Refactoring targets (") {
276 return rule_full("fallow/refactoring-target");
277 }
278 None
279}
280
281fn rule_full(id: &str) -> Option<String> {
282 crate::explain::rule_by_id(id).map(|rule| rule.full.to_string())
283}
284
285pub(in crate::report) fn format_window(seconds: u64) -> String {
291 if seconds < 60 {
292 return format!("{seconds} s");
293 }
294 let minutes = seconds / 60;
295 if minutes < 120 {
296 return format!("{minutes} min");
297 }
298 let hours = minutes / 60;
299 if hours < 48 {
300 format!("{hours} h")
301 } else {
302 format!("{} d", hours / 24)
303 }
304}
305
306pub fn render_health_score(lines: &mut Vec<String>, report: &crate::health_types::HealthReport) {
307 let Some(ref hs) = report.health_score else {
308 return;
309 };
310
311 let score_str = format!("{:.0}", hs.score);
312 let grade_str = hs.grade;
313 let score_colored = if hs.score >= 85.0 {
314 format!("{score_str} {grade_str}")
315 .green()
316 .bold()
317 .to_string()
318 } else if hs.score >= 70.0 {
319 format!("{score_str} {grade_str}")
320 .yellow()
321 .bold()
322 .to_string()
323 } else if hs.score >= 55.0 {
324 format!("{score_str} {grade_str}").yellow().to_string()
325 } else {
326 format!("{score_str} {grade_str}").red().bold().to_string()
327 };
328 lines.push(format!(
329 "{} {} {}",
330 "\u{25cf}".cyan(),
331 "Health score:".cyan().bold(),
332 score_colored,
333 ));
334
335 let p = &hs.penalties;
336 let mut penalties: Vec<(&str, f64)> = Vec::new();
337 if let Some(df) = p.dead_files {
338 penalties.push(("dead files", df));
339 }
340 if let Some(de) = p.dead_exports {
341 penalties.push(("dead exports", de));
342 }
343 penalties.push(("complexity", p.complexity));
344 penalties.push(("p90", p.p90_complexity));
345 if let Some(mi) = p.maintainability {
346 penalties.push(("maintainability", mi));
347 }
348 if let Some(hp) = p.hotspots {
349 penalties.push(("hotspots", hp));
350 }
351 if let Some(ud) = p.unused_deps {
352 penalties.push(("unused deps", ud));
353 }
354 if let Some(cd) = p.circular_deps {
355 penalties.push(("circular deps", cd));
356 }
357 if let Some(us) = p.unit_size {
358 penalties.push(("unit size", us));
359 }
360 if let Some(cp) = p.coupling {
361 penalties.push(("coupling", cp));
362 }
363 if let Some(dp) = p.duplication {
364 penalties.push(("duplication", dp));
365 }
366 penalties.retain(|&(_, v)| v > 0.0);
367 penalties.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
368
369 if !penalties.is_empty() {
370 let parts: Vec<String> = penalties
371 .iter()
372 .enumerate()
373 .map(|(i, &(label, val))| {
374 let text = format!("{label} -{val:.1}");
375 if i == 0 {
376 text.yellow().to_string()
377 } else {
378 text.dimmed().to_string()
379 }
380 })
381 .collect();
382 lines.push(format!(
383 " {} {}",
384 "Deductions:".dimmed(),
385 parts.join(&format!(" {} ", "\u{00b7}".dimmed()))
386 ));
387 }
388 let mut na_parts = Vec::new();
389 if p.dead_files.is_none() {
390 na_parts.push("dead code");
391 }
392 if p.maintainability.is_none() {
393 na_parts.push("maintainability");
394 }
395 if p.hotspots.is_none() {
396 na_parts.push("hotspots");
397 }
398 if !na_parts.is_empty() {
399 lines.push(format!(
400 " {}",
401 format!(
402 "N/A: {} (enable the corresponding analysis flags)",
403 na_parts.join(", ")
404 )
405 .dimmed()
406 ));
407 }
408 if p.duplication.is_some_and(|dp| dp >= 5.0) {
409 lines.push(format!(
410 " {}",
411 "Tip: add \"dist\" or \"__generated__\" to health.ignore in your config to exclude from duplication analysis"
412 .dimmed()
413 ));
414 }
415 lines.push(String::new());
416}
417
418fn fmt_trend_val(v: f64, unit: &str) -> String {
420 if unit == "%" {
421 format!("{v:.1}%")
422 } else if (v - v.round()).abs() < 0.05 {
423 format!("{v:.0}")
424 } else {
425 format!("{v:.1}")
426 }
427}
428
429fn fmt_trend_delta(v: f64, unit: &str) -> String {
431 if unit == "%" {
432 format!("{v:+.1}%")
433 } else if (v - v.round()).abs() < 0.05 {
434 format!("{v:+.0}")
435 } else {
436 format!("{v:+.1}")
437 }
438}
439
440pub fn render_health_trend(lines: &mut Vec<String>, report: &crate::health_types::HealthReport) {
441 let Some(ref trend) = report.health_trend else {
442 return;
443 };
444
445 use crate::health_types::TrendDirection;
446
447 let date = trend
448 .compared_to
449 .timestamp
450 .get(..10)
451 .unwrap_or(&trend.compared_to.timestamp);
452 let sha_str = trend
453 .compared_to
454 .git_sha
455 .as_deref()
456 .map_or(String::new(), |sha| format!(" \u{00b7} {sha}"));
457 let direction_label = format!(
458 "{} {}",
459 trend.overall_direction.arrow(),
460 trend.overall_direction.label()
461 );
462 let direction_colored = match trend.overall_direction {
463 TrendDirection::Improving => direction_label.green().bold().to_string(),
464 TrendDirection::Declining => direction_label.red().bold().to_string(),
465 TrendDirection::Stable => direction_label.dimmed().to_string(),
466 };
467 lines.push(format!(
468 "{} {} {} {}",
469 "\u{25cf}".cyan(),
470 "Trend:".cyan().bold(),
471 direction_colored,
472 format!("(vs {date}{sha_str})").dimmed(),
473 ));
474
475 if let (Some(prev_model), Some(cur_model)) = (
476 &trend.compared_to.coverage_model,
477 &report.summary.coverage_model,
478 ) && prev_model != cur_model
479 {
480 let prev_str = serde_json::to_string(prev_model).unwrap_or_default();
481 let cur_str = serde_json::to_string(cur_model).unwrap_or_default();
482 lines.push(format!(
483 " {}",
484 format!(
485 "note: CRAP model changed ({} \u{2192} {}); score delta may reflect model change, not code change",
486 prev_str.trim_matches('"'),
487 cur_str.trim_matches('"'),
488 )
489 .yellow()
490 ));
491 }
492
493 if let Some(prev_version) = trend.compared_to.snapshot_schema_version
494 && prev_version < crate::health_types::SNAPSHOT_SCHEMA_VERSION
495 {
496 lines.push(format!(
497 " {}",
498 format!(
499 "note: snapshot schema updated to v{} (added total LOC vital sign); score comparison still valid",
500 crate::health_types::SNAPSHOT_SCHEMA_VERSION
501 )
502 .yellow()
503 ));
504 }
505
506 let all_stable = trend
507 .metrics
508 .iter()
509 .all(|m| m.direction == TrendDirection::Stable);
510 if all_stable {
511 lines.push(format!(
512 " {}",
513 format!("All {} metrics unchanged", trend.metrics.len()).dimmed()
514 ));
515 lines.push(String::new());
516 return;
517 }
518
519 for m in &trend.metrics {
520 let label = format!("{:<18}", m.label);
521 let prev_str = fmt_trend_val(m.previous, m.unit);
522 let cur_str = fmt_trend_val(m.current, m.unit);
523 let delta_str = fmt_trend_delta(m.delta, m.unit);
524
525 let direction_str = match m.direction {
526 TrendDirection::Improving => format!("{} {}", m.direction.arrow(), m.direction.label())
527 .green()
528 .to_string(),
529 TrendDirection::Declining => format!("{} {}", m.direction.arrow(), m.direction.label())
530 .red()
531 .to_string(),
532 TrendDirection::Stable => format!("{} {}", m.direction.arrow(), m.direction.label())
533 .dimmed()
534 .to_string(),
535 };
536
537 let values = format!("{prev_str:>8} {cur_str:<8}");
538 lines.push(format!(
539 " {label} {values} {delta_str:<10} {direction_str}"
540 ));
541 }
542
543 lines.push(String::new());
544}
545
546fn render_vital_signs(lines: &mut Vec<String>, report: &crate::health_types::HealthReport) {
547 if report.health_trend.is_some() {
548 return;
549 }
550 let Some(ref vs) = report.vital_signs else {
551 return;
552 };
553
554 let mut parts = Vec::new();
555 if vs.total_loc > 0 {
556 parts.push(format!("{} LOC", thousands(vs.total_loc as usize)));
557 }
558 if let Some(dfp) = vs.dead_file_pct {
559 parts.push(format!("dead files {dfp:.1}%"));
560 }
561 if let Some(dep) = vs.dead_export_pct {
562 parts.push(format!("dead exports {dep:.1}%"));
563 }
564 parts.push(format!("avg cyclomatic {:.1}", vs.avg_cyclomatic));
565 parts.push(format!("p90 cyclomatic {}", vs.p90_cyclomatic));
566 if let Some(mi) = vs.maintainability_avg {
567 let label = if mi >= 85.0 {
568 "good"
569 } else if mi >= 65.0 {
570 "moderate"
571 } else {
572 "low"
573 };
574 parts.push(format!("maintainability {mi:.1} ({label})"));
575 }
576 if let Some(hc) = vs.hotspot_count {
577 let since_suffix = report
578 .hotspot_summary
579 .as_ref()
580 .map(|s| format!(" (since {})", s.since))
581 .unwrap_or_default();
582 parts.push(format!(
583 "{hc} churn hotspot{}{since_suffix}",
584 plural(hc as usize)
585 ));
586 }
587 if let Some(cd) = vs.circular_dep_count
588 && cd > 0
589 {
590 parts.push(format!(
591 "{cd} circular {}",
592 if cd == 1 { "dep" } else { "deps" }
593 ));
594 }
595 if let Some(ud) = vs.unused_dep_count
596 && ud > 0
597 {
598 parts.push(format!(
599 "{ud} unused {}",
600 if ud == 1 { "dep" } else { "deps" }
601 ));
602 }
603 if let Some(dp) = vs.duplication_pct {
604 parts.push(format!("duplication {dp:.1}%"));
605 }
606 lines.push(format!(
607 "{} {} {}",
608 "\u{25a0}".dimmed(),
609 "Metrics:".dimmed(),
610 parts.join(" \u{00b7} ").dimmed()
611 ));
612 lines.push(String::new());
613}
614
615fn render_risk_profiles(lines: &mut Vec<String>, report: &crate::health_types::HealthReport) {
616 let Some(ref vs) = report.vital_signs else {
617 return;
618 };
619
620 let format_profile = |profile: &crate::health_types::RiskProfile| -> String {
621 format!(
622 "{:.0}% low \u{00b7} {:.0}% medium \u{00b7} {:.0}% high \u{00b7} {:.0}% very high",
623 profile.low_risk, profile.medium_risk, profile.high_risk, profile.very_high_risk
624 )
625 };
626
627 let before = lines.len();
628
629 if let Some(ref profile) = vs.unit_size_profile
630 && profile.very_high_risk >= 3.0
631 {
632 lines.push(format!(
633 " {} {} {}",
634 "Function size:".dimmed(),
635 format_profile(profile).dimmed(),
636 "(1-15 / 16-30 / 31-60 / >60 LOC)".dimmed()
637 ));
638 }
639
640 if let Some(ref profile) = vs.unit_interfacing_profile
641 && (profile.very_high_risk > 0.0 || profile.high_risk > 1.0)
642 {
643 lines.push(format!(
644 " {} {} {}",
645 "Parameters:".dimmed(),
646 format_profile(profile).dimmed(),
647 "(0-2 / 3-4 / 5-6 / >=7 params)".dimmed()
648 ));
649 }
650
651 if lines.len() > before {
652 lines.push(String::new());
653 }
654}
655
656fn render_large_functions(
657 lines: &mut Vec<String>,
658 report: &crate::health_types::HealthReport,
659 root: &Path,
660) {
661 if report.large_functions.is_empty() {
662 return;
663 }
664
665 let total = report.large_functions.len();
666 let shown = total.min(MAX_FLAT_ITEMS);
667 lines.push(format!(
668 "{} {}",
669 "\u{25cf}".red(),
670 if shown < total {
671 format!("Large functions ({shown} shown, {total} total)")
672 } else {
673 format!("Large functions ({total})")
674 }
675 .red()
676 .bold()
677 ));
678
679 let mut last_file = String::new();
680 for entry in report.large_functions.iter().take(MAX_FLAT_ITEMS) {
681 let file_str = relative_path(&entry.path, root).display().to_string();
682 if file_str != last_file {
683 lines.push(format!(" {}", format_path(&file_str)));
684 last_file = file_str;
685 }
686 lines.push(format!(
687 " {} {} {} lines",
688 format!(":{}", entry.line).dimmed(),
689 entry.name.bold(),
690 format!("{:>3}", entry.line_count).red().bold(),
691 ));
692 }
693 lines.push(format!(
694 " {}",
695 format!("Functions exceeding 60 lines of code (very high risk): {DOCS_HEALTH}#unit-size")
696 .dimmed()
697 ));
698 if shown < total {
699 lines.push(format!(
700 " {}",
701 format!("use --top {total} to see all").dimmed()
702 ));
703 }
704 lines.push(String::new());
705}
706
707fn append_suppression_hints(lines: &mut Vec<String>, report: &crate::health_types::HealthReport) {
717 let has_html_template = report.findings.iter().any(|finding| {
718 finding.name == "<template>"
719 && finding
720 .path
721 .extension()
722 .and_then(|ext| ext.to_str())
723 .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
724 });
725 let has_inline_template = report.findings.iter().any(|finding| {
726 finding.name == "<template>"
727 && finding
728 .path
729 .extension()
730 .and_then(|ext| ext.to_str())
731 .is_none_or(|ext| !ext.eq_ignore_ascii_case("html"))
732 });
733 let has_component_rollup = report
734 .findings
735 .iter()
736 .any(|finding| finding.name == "<component>");
737 let has_function_finding = report
738 .findings
739 .iter()
740 .any(|finding| finding.name != "<template>" && finding.name != "<component>");
741 if has_html_template {
742 lines.push(format!(
743 " {}",
744 "To suppress HTML templates: <!-- fallow-ignore-file complexity -->".dimmed()
745 ));
746 }
747 if has_inline_template {
748 lines.push(format!(
749 " {}",
750 "To suppress inline templates: // fallow-ignore-next-line complexity (above @Component)"
751 .dimmed()
752 ));
753 }
754 if has_component_rollup {
755 lines.push(format!(
756 " {}",
757 "To suppress a <component> rollup: suppress the worst class method (// fallow-ignore-next-line complexity above it hides both)"
758 .dimmed()
759 ));
760 }
761 if has_function_finding && report.findings.len() >= 3 {
762 lines.push(format!(
763 " {}",
764 "To suppress: // fallow-ignore-next-line complexity".dimmed()
765 ));
766 }
767}
768
769fn render_component_rollup_breakdown(
782 finding: &crate::health_types::ComplexityViolation,
783 root: &Path,
784) -> Option<String> {
785 let rollup = finding.component_rollup.as_ref()?;
786 let template_display = crate::report::format_display_path(&rollup.template_path, root);
787 Some(format!(
788 " {}",
789 format!(
790 "rolled up: {}cyc {}cog on `{}.{}` + {}cyc {}cog on {}",
791 rollup.class_cyclomatic,
792 rollup.class_cognitive,
793 rollup.component,
794 rollup.class_worst_function,
795 rollup.template_cyclomatic,
796 rollup.template_cognitive,
797 template_display,
798 )
799 .dimmed(),
800 ))
801}
802
803fn render_findings(
804 lines: &mut Vec<String>,
805 report: &crate::health_types::HealthReport,
806 root: &Path,
807) {
808 if report.findings.is_empty() {
809 return;
810 }
811
812 lines.push(format!(
813 "{} {}",
814 "\u{25cf}".red(),
815 if report.findings.len() < report.summary.functions_above_threshold {
816 format!(
817 "High complexity functions ({} shown, {} total)",
818 report.findings.len(),
819 report.summary.functions_above_threshold
820 )
821 } else {
822 format!(
823 "High complexity functions ({})",
824 report.summary.functions_above_threshold
825 )
826 }
827 .red()
828 .bold()
829 ));
830 if let Some(note) = crap_coverage_note(report) {
831 lines.push(format!(" {}", note.dimmed()));
832 }
833
834 let mut last_file = String::new();
835 for finding in &report.findings {
836 let file_str = crate::report::format_display_path(&finding.path, root);
837 if file_str != last_file {
838 lines.push(format!(" {}", format_path(&file_str)));
839 last_file = file_str;
840 }
841
842 let cyc_val = format!("{:>3}", finding.cyclomatic);
843 let cog_val = format!("{:>3}", finding.cognitive);
844
845 let cyc_colored = if finding.cyclomatic > report.summary.max_cyclomatic_threshold {
846 cyc_val.red().bold().to_string()
847 } else {
848 cyc_val.dimmed().to_string()
849 };
850 let cog_colored = if finding.cognitive > report.summary.max_cognitive_threshold {
851 cog_val.red().bold().to_string()
852 } else {
853 cog_val.dimmed().to_string()
854 };
855
856 let severity_tag = match finding.severity {
857 crate::health_types::FindingSeverity::Critical => {
858 format!(" {}", "CRITICAL".red().bold())
859 }
860 crate::health_types::FindingSeverity::High => {
861 format!(" {}", "HIGH".yellow().bold())
862 }
863 crate::health_types::FindingSeverity::Moderate => String::new(),
864 };
865 let generated_tag = if is_likely_generated(&finding.name, finding.cyclomatic) {
866 format!(" {}", "(generated)".dimmed())
867 } else {
868 String::new()
869 };
870 lines.push(format!(
871 " {} {}{}{}",
872 format!(":{}", finding.line).dimmed(),
873 finding.name.bold(),
874 severity_tag,
875 generated_tag,
876 ));
877 lines.push(format!(
878 " {} cyclomatic {} cognitive {} lines",
879 cyc_colored,
880 cog_colored,
881 format!("{:>3}", finding.line_count).dimmed(),
882 ));
883 if let Some(line) = render_component_rollup_breakdown(finding, root) {
884 lines.push(line);
885 }
886 if let Some(crap) = finding.crap {
887 let crap_colored = format!("{crap:>5.1}").red().bold().to_string();
888 let coverage_suffix = if let Some(pct) = finding.coverage_pct {
889 format!(" ({pct:.0}% tested)")
890 } else if matches!(
891 finding.coverage_source,
892 Some(crate::health_types::CoverageSource::EstimatedComponentInherited)
893 ) && let Some(ref owner) = finding.inherited_from
894 {
895 let owner_display = crate::report::format_display_path(owner, root);
896 format!(" (inherited from {owner_display})")
897 } else {
898 String::new()
899 };
900 lines.push(format!(
901 " {crap_colored} CRAP{}",
902 coverage_suffix.dimmed(),
903 ));
904 }
905 }
906 lines.push(format!(
907 " {}",
908 format!(
909 "Functions exceeding cyclomatic, cognitive, or CRAP thresholds ({DOCS_HEALTH}#complexity-metrics)"
910 )
911 .dimmed()
912 ));
913 append_suppression_hints(lines, report);
914 if report.findings.len() < report.summary.functions_above_threshold {
915 let total = report.summary.functions_above_threshold;
916 lines.push(format!(
917 " {}",
918 format!("use --top {total} to see all").dimmed()
919 ));
920 }
921 lines.push(String::new());
922}
923
924fn crap_coverage_note(report: &crate::health_types::HealthReport) -> Option<String> {
925 if !report.findings.iter().any(|finding| finding.crap.is_some()) {
926 return None;
927 }
928
929 let istanbul_counts = (
930 report.summary.istanbul_matched,
931 report.summary.istanbul_total,
932 );
933 let has_istanbul_counts = matches!(istanbul_counts, (Some(_), Some(total)) if total > 0);
934
935 if matches!(
936 report.summary.coverage_model,
937 Some(crate::health_types::CoverageModel::Istanbul)
938 ) || has_istanbul_counts
939 {
940 let match_info = match (
941 report.summary.istanbul_matched,
942 report.summary.istanbul_total,
943 ) {
944 (Some(matched), Some(total)) if total > 0 && matched < total => {
945 return Some(format!(
946 "CRAP scores use Istanbul coverage where matched ({matched}/{total} functions); unmatched functions are estimated from export references."
947 ));
948 }
949 (Some(matched), Some(total)) if total > 0 => {
950 format!(" ({matched}/{total} functions matched)")
951 }
952 _ => String::new(),
953 };
954 return Some(format!(
955 "CRAP scores use Istanbul coverage data{match_info}."
956 ));
957 }
958
959 Some(
960 "CRAP scores are estimated from export references; run `fallow health --coverage <coverage-final.json>` for exact scores."
961 .to_string(),
962 )
963}
964
965fn is_likely_generated(name: &str, cyclomatic: u16) -> bool {
967 if name.starts_with("validate")
968 && name.len() > 8
969 && name[8..].chars().all(|c| c.is_ascii_digit())
970 {
971 return true;
972 }
973 if cyclomatic > 200 && (name == "module.exports" || name == "default" || name == "<anonymous>")
974 {
975 return true;
976 }
977 false
978}
979
980fn render_file_scores(
981 lines: &mut Vec<String>,
982 report: &crate::health_types::HealthReport,
983 root: &Path,
984) {
985 if report.file_scores.is_empty() {
986 return;
987 }
988
989 lines.push(format!(
990 "{} {} {}",
991 "\u{25cf}".cyan(),
992 format!("File health scores ({} files)", report.file_scores.len())
993 .cyan()
994 .bold(),
995 "\u{b7} sorted by triage concern".dimmed(),
996 ));
997 lines.push(String::new());
998
999 let shown_scores = report.file_scores.len().min(MAX_FLAT_ITEMS);
1000 for score in &report.file_scores[..shown_scores] {
1001 let file_str = relative_path(&score.path, root).display().to_string();
1002 let mi = score.maintainability_index;
1003
1004 let mi_str = format!("{mi:>5.1}");
1005 let mi_colored = if mi >= 80.0 {
1006 mi_str.green().to_string()
1007 } else if mi >= 50.0 {
1008 mi_str.yellow().to_string()
1009 } else {
1010 mi_str.red().bold().to_string()
1011 };
1012
1013 let (dir, filename) = split_dir_filename(&file_str);
1014
1015 let concern = file_score_concern_axis(score);
1016 let label = concern.label();
1017 let concern_colored = match concern {
1018 FileScoreConcern::Risk => {
1019 if score.crap_max >= 30.0 {
1020 label.red().bold().to_string()
1021 } else if score.crap_max >= 15.0 {
1022 label.yellow().to_string()
1023 } else {
1024 label.dimmed().to_string()
1025 }
1026 }
1027 FileScoreConcern::Structural => {
1028 if mi < 50.0 {
1029 label.red().bold().to_string()
1030 } else if mi < 80.0 {
1031 label.yellow().to_string()
1032 } else {
1033 label.dimmed().to_string()
1034 }
1035 }
1036 };
1037
1038 const CONCERN_TAG_COLUMN: usize = 48;
1039 let pad = CONCERN_TAG_COLUMN
1040 .saturating_sub(file_str.chars().count())
1041 .max(2);
1042 lines.push(format!(
1043 " {} {}{}{}{}",
1044 mi_colored,
1045 dir.dimmed(),
1046 filename,
1047 " ".repeat(pad),
1048 concern_colored,
1049 ));
1050
1051 let risk_suffix = if score.crap_max > 0.0 {
1052 let risk_str = if score.crap_max > 999.0 {
1053 ">999".to_string()
1054 } else {
1055 format!("{:.1}", score.crap_max)
1056 };
1057 let risk_colored = if score.crap_max >= 30.0 {
1058 risk_str.red().bold().to_string()
1059 } else if score.crap_max >= 15.0 {
1060 risk_str.yellow().to_string()
1061 } else {
1062 risk_str.dimmed().to_string()
1063 };
1064 format!(" {risk_colored} risk")
1065 } else {
1066 String::new()
1067 };
1068 lines.push(format!(
1069 " {} LOC {} fan-in {} fan-out {} dead {} density{}",
1070 format!("{:>6}", score.lines).dimmed(),
1071 format!("{:>3}", score.fan_in).dimmed(),
1072 format!("{:>3}", score.fan_out).dimmed(),
1073 format!("{:>3.0}%", score.dead_code_ratio * 100.0).dimmed(),
1074 format!("{:.2}", score.complexity_density).dimmed(),
1075 risk_suffix,
1076 ));
1077
1078 lines.push(String::new());
1079 }
1080 if report.file_scores.len() > MAX_FLAT_ITEMS {
1081 lines.push(format!(
1082 " {}",
1083 format!(
1084 "... and {} more files (--format json for full list)",
1085 report.file_scores.len() - MAX_FLAT_ITEMS
1086 )
1087 .dimmed()
1088 ));
1089 lines.push(String::new());
1090 }
1091 let crap_note = if matches!(
1092 report.summary.coverage_model,
1093 Some(crate::health_types::CoverageModel::Istanbul)
1094 ) {
1095 let match_info = match (
1096 report.summary.istanbul_matched,
1097 report.summary.istanbul_total,
1098 ) {
1099 (Some(m), Some(t)) if t > 0 => format!(" ({m}/{t} functions matched)"),
1100 _ => String::new(),
1101 };
1102 format!("CRAP from Istanbul coverage data{match_info}.")
1103 } else {
1104 "CRAP estimated from export references (85% direct, 40% indirect, 0% untested). Run `fallow health --coverage <coverage-final.json>` for exact scores.".to_string()
1105 };
1106 lines.push(format!(
1107 " {}",
1108 format!("Sorted by triage concern: the larger of low-MI concern and CRAP risk. The risk / structure tag marks which one placed each file. MI reflects complexity, coupling, and dead code; risk reflects untested complexity (CRAP) and can diverge from MI. Risk: low <15, moderate 15-30, high >=30. {crap_note} {DOCS_HEALTH}#file-health-scores").dimmed()
1109 ));
1110 lines.push(String::new());
1111}
1112
1113fn render_coverage_gaps(
1114 lines: &mut Vec<String>,
1115 report: &crate::health_types::HealthReport,
1116 root: &Path,
1117) {
1118 let Some(ref gaps) = report.coverage_gaps else {
1119 return;
1120 };
1121
1122 lines.push(format!(
1123 "{} {}",
1124 "\u{25cf}".yellow(),
1125 format!(
1126 "Coverage gaps ({} untested {}, {} untested {}, {:.1}% file coverage)",
1127 gaps.summary.untested_files,
1128 if gaps.summary.untested_files == 1 {
1129 "file"
1130 } else {
1131 "files"
1132 },
1133 gaps.summary.untested_exports,
1134 if gaps.summary.untested_exports == 1 {
1135 "export"
1136 } else {
1137 "exports"
1138 },
1139 gaps.summary.file_coverage_pct,
1140 )
1141 .yellow()
1142 .bold()
1143 ));
1144 lines.push(String::new());
1145
1146 if !gaps.files.is_empty() {
1147 let shown_files = gaps.files.len().min(MAX_FLAT_ITEMS);
1148 lines.push(format!(" {}", "Files".dimmed()));
1149 for item in &gaps.files[..shown_files] {
1150 let file_str = relative_path(&item.file.path, root).display().to_string();
1151 let (dir, filename) = split_dir_filename(&file_str);
1152 lines.push(format!(" {}{}", dir.dimmed(), filename));
1153 }
1154 if gaps.files.len() > MAX_FLAT_ITEMS {
1155 lines.push(format!(
1156 " {}",
1157 format!(
1158 "... and {} more files (--format json for full list)",
1159 gaps.files.len() - MAX_FLAT_ITEMS
1160 )
1161 .dimmed()
1162 ));
1163 }
1164 lines.push(String::new());
1165 }
1166
1167 if !gaps.exports.is_empty() {
1168 lines.push(format!(" {}", "Exports".dimmed()));
1169
1170 let mut by_file: Vec<(
1171 &std::path::Path,
1172 Vec<&crate::health_types::UntestedExportFinding>,
1173 )> = Vec::new();
1174 for item in &gaps.exports {
1175 if let Some(entry) = by_file
1176 .last_mut()
1177 .filter(|(p, _)| *p == item.export.path.as_path())
1178 {
1179 entry.1.push(item);
1180 } else {
1181 by_file.push((item.export.path.as_path(), vec![item]));
1182 }
1183 }
1184
1185 let mut shown = 0;
1186 for (file_path, exports) in &by_file {
1187 if shown >= MAX_FLAT_ITEMS {
1188 break;
1189 }
1190 let file_str = relative_path(file_path, root).display().to_string();
1191 if exports.len() > 10 {
1192 lines.push(format!(
1193 " {} ({} untested re-exports)",
1194 file_str.dimmed(),
1195 exports.len(),
1196 ));
1197 shown += 1;
1198 } else {
1199 for item in exports {
1200 if shown >= MAX_FLAT_ITEMS {
1201 break;
1202 }
1203 lines.push(format!(
1204 " {}:{} `{}`",
1205 file_str.dimmed(),
1206 item.export.line,
1207 item.export.export_name,
1208 ));
1209 shown += 1;
1210 }
1211 }
1212 }
1213 let total_exports = gaps.exports.len();
1214 if total_exports > shown {
1215 lines.push(format!(
1216 " {}",
1217 format!(
1218 "... and {} more exports (--format json for full list)",
1219 total_exports - shown
1220 )
1221 .dimmed()
1222 ));
1223 }
1224 lines.push(String::new());
1225 }
1226
1227 lines.push(format!(
1228 " {}",
1229 format!(
1230 "Static test dependency gaps (not line-level coverage): {DOCS_HEALTH}#coverage-gaps"
1231 )
1232 .dimmed()
1233 ));
1234 lines.push(String::new());
1235}
1236
1237pub(in crate::report) fn print_health_summary(
1239 report: &crate::health_types::HealthReport,
1240 elapsed: Duration,
1241 quiet: bool,
1242 heading: bool,
1243) {
1244 let s = &report.summary;
1245
1246 if heading {
1247 outln!("{}", "Health Summary".bold());
1248 outln!();
1249 }
1250 outln!(" {:>6} Functions analyzed", s.functions_analyzed);
1251 outln!(" {:>6} Above threshold", s.functions_above_threshold);
1252 if let Some(mi) = s.average_maintainability {
1253 let label = if mi >= 85.0 {
1254 "good"
1255 } else if mi >= 65.0 {
1256 "moderate"
1257 } else {
1258 "low"
1259 };
1260 outln!(" {mi:>5.1} Average maintainability ({label})");
1261 }
1262 if let Some(ref score) = report.health_score {
1263 outln!(" {:>5.0} {} Health score", score.score, score.grade);
1264 }
1265 if let Some(ref gaps) = report.coverage_gaps {
1266 outln!(
1267 " {:>6} Untested {} ({:.1}% file coverage)",
1268 gaps.summary.untested_files,
1269 if gaps.summary.untested_files == 1 {
1270 "file"
1271 } else {
1272 "files"
1273 },
1274 gaps.summary.file_coverage_pct,
1275 );
1276 outln!(
1277 " {:>6} Untested {}",
1278 gaps.summary.untested_exports,
1279 if gaps.summary.untested_exports == 1 {
1280 "export"
1281 } else {
1282 "exports"
1283 },
1284 );
1285 }
1286 if let Some(ref production) = report.runtime_coverage {
1287 outln!(
1288 " {:>6} Unhit in production",
1289 production.summary.functions_unhit,
1290 );
1291 outln!(
1292 " {:>6} Untracked by V8 (lazy-parsed / worker / dynamic)",
1293 production.summary.functions_untracked,
1294 );
1295 }
1296
1297 if !quiet {
1298 eprintln!(
1299 "{}",
1300 format!(
1301 "\u{2713} {} functions analyzed ({:.2}s)",
1302 s.functions_analyzed,
1303 elapsed.as_secs_f64()
1304 )
1305 .green()
1306 .bold()
1307 );
1308 }
1309}
1310
1311pub(in crate::report) fn print_health_grouping(
1332 grouping: &crate::health_types::HealthGrouping,
1333 _root: &Path,
1334 quiet: bool,
1335) {
1336 if grouping.groups.is_empty() {
1337 return;
1338 }
1339 if !quiet {
1340 eprintln!();
1341 }
1342 outln!(
1343 "{} {}",
1344 "\u{25cf}".cyan(),
1345 format!("Per-{} health", grouping.mode).cyan().bold()
1346 );
1347 let key_width = grouping
1348 .groups
1349 .iter()
1350 .map(|g| g.key.len())
1351 .max()
1352 .unwrap_or(0)
1353 .max(8);
1354 let any_score = grouping.groups.iter().any(|g| g.health_score.is_some());
1355 let any_vitals = grouping.groups.iter().any(|g| g.vital_signs.is_some());
1356
1357 let mut ordered: Vec<&crate::health_types::HealthGroup> = grouping.groups.iter().collect();
1358 if any_score {
1359 ordered.sort_by(|a, b| {
1360 let a_score = a.health_score.as_ref().map_or(f64::INFINITY, |hs| hs.score);
1361 let b_score = b.health_score.as_ref().map_or(f64::INFINITY, |hs| hs.score);
1362 a_score
1363 .partial_cmp(&b_score)
1364 .unwrap_or(std::cmp::Ordering::Equal)
1365 });
1366 }
1367
1368 let mut header = format!(" {:<width$}", "", width = key_width);
1369 if any_score {
1370 let _ = write!(header, " {:>9} grade", "score");
1371 }
1372 let _ = write!(header, " {:>5}", "files");
1373 let _ = write!(header, " {:>3}", "hot");
1374 if any_vitals {
1375 let _ = write!(header, " {:>3}", "p90");
1376 }
1377 outln!("{}", header.dimmed());
1378
1379 let mut has_root_bucket = false;
1380 for group in ordered {
1381 if group.key == "(root)" {
1382 has_root_bucket = true;
1383 }
1384 let mut row = format!(" {:<width$}", group.key, width = key_width);
1385 if any_score {
1386 if let Some(ref hs) = group.health_score {
1387 let grade_colored = colorize_grade(hs.grade);
1388 let _ = write!(row, " {:>9.1} {}", hs.score, grade_colored);
1389 } else {
1390 row.push_str(" ");
1391 }
1392 }
1393 let _ = write!(row, " {:>5}", group.files_analyzed);
1394 let _ = write!(row, " {:>3}", group.hotspots.len());
1395 if any_vitals {
1396 if let Some(ref vs) = group.vital_signs {
1397 let _ = write!(row, " {:>3}", vs.p90_cyclomatic);
1398 } else {
1399 row.push_str(" ");
1400 }
1401 }
1402 outln!("{row}");
1403 }
1404 if !quiet {
1405 if has_root_bucket {
1406 eprintln!(
1407 " {}",
1408 "(root) = files outside any workspace package".dimmed()
1409 );
1410 }
1411 eprintln!(
1412 " {}",
1413 "per-group summary only; --format json includes per-group findings, file scores, and hotspots"
1414 .dimmed()
1415 );
1416 }
1417}
1418
1419fn colorize_grade(grade: &str) -> String {
1421 match grade {
1422 "A" | "B" => grade.green().to_string(),
1423 "C" => grade.yellow().to_string(),
1424 _ => grade.red().to_string(),
1425 }
1426}
1427
1428#[cfg(test)]
1429mod tests {
1430 use super::super::plain;
1431 use super::*;
1432 use std::path::PathBuf;
1433
1434 #[test]
1435 fn health_empty_findings_produces_no_header() {
1436 let root = PathBuf::from("/project");
1437 let report = crate::health_types::HealthReport {
1438 summary: crate::health_types::HealthSummary {
1439 files_analyzed: 10,
1440 functions_analyzed: 50,
1441 ..Default::default()
1442 },
1443 ..Default::default()
1444 };
1445 let lines = build_health_human_lines(&report, &root);
1446 let text = plain(&lines);
1447 assert!(!text.contains("High complexity functions"));
1448 }
1449
1450 #[test]
1451 fn health_findings_show_function_details() {
1452 let root = PathBuf::from("/project");
1453 let report = crate::health_types::HealthReport {
1454 findings: vec![
1455 crate::health_types::ComplexityViolation {
1456 path: root.join("src/parser.ts"),
1457 name: "parseExpression".to_string(),
1458 line: 42,
1459 col: 0,
1460 cyclomatic: 25,
1461 cognitive: 30,
1462 line_count: 80,
1463 param_count: 0,
1464 exceeded: crate::health_types::ExceededThreshold::Both,
1465 severity: crate::health_types::FindingSeverity::High,
1466 crap: None,
1467 coverage_pct: None,
1468 coverage_tier: None,
1469 coverage_source: None,
1470 inherited_from: None,
1471 component_rollup: None,
1472 contributions: Vec::new(),
1473 }
1474 .into(),
1475 ],
1476 summary: crate::health_types::HealthSummary {
1477 files_analyzed: 10,
1478 functions_analyzed: 50,
1479 functions_above_threshold: 1,
1480 ..Default::default()
1481 },
1482 ..Default::default()
1483 };
1484 let lines = build_health_human_lines(&report, &root);
1485 let text = plain(&lines);
1486 assert!(text.contains("High complexity functions (1)"));
1487 assert!(text.contains("src/parser.ts"));
1488 assert!(text.contains(":42"));
1489 assert!(text.contains("parseExpression"));
1490 assert!(text.contains("25 cyclomatic"));
1491 assert!(text.contains("30 cognitive"));
1492 assert!(text.contains("80 lines"));
1493 }
1494
1495 #[test]
1496 fn health_shown_vs_total_when_truncated() {
1497 let root = PathBuf::from("/project");
1498 let report = crate::health_types::HealthReport {
1499 findings: vec![
1500 crate::health_types::ComplexityViolation {
1501 path: root.join("src/a.ts"),
1502 name: "fn1".to_string(),
1503 line: 1,
1504 col: 0,
1505 cyclomatic: 25,
1506 cognitive: 20,
1507 line_count: 50,
1508 param_count: 0,
1509 exceeded: crate::health_types::ExceededThreshold::Both,
1510 severity: crate::health_types::FindingSeverity::High,
1511 crap: None,
1512 coverage_pct: None,
1513 coverage_tier: None,
1514 coverage_source: None,
1515 inherited_from: None,
1516 component_rollup: None,
1517 contributions: Vec::new(),
1518 }
1519 .into(),
1520 ],
1521 summary: crate::health_types::HealthSummary {
1522 files_analyzed: 100,
1523 functions_analyzed: 500,
1524 functions_above_threshold: 10,
1525 ..Default::default()
1526 },
1527 ..Default::default()
1528 };
1529 let lines = build_health_human_lines(&report, &root);
1530 let text = plain(&lines);
1531 assert!(text.contains("1 shown, 10 total"));
1532 }
1533
1534 #[test]
1535 fn health_findings_explain_estimated_crap_scores() {
1536 let root = PathBuf::from("/project");
1537 let report = crate::health_types::HealthReport {
1538 findings: vec![
1539 crate::health_types::ComplexityViolation {
1540 path: root.join("src/risky.ts"),
1541 name: "risky".to_string(),
1542 line: 7,
1543 col: 0,
1544 cyclomatic: 25,
1545 cognitive: 20,
1546 line_count: 80,
1547 param_count: 0,
1548 exceeded: crate::health_types::ExceededThreshold::Crap,
1549 severity: crate::health_types::FindingSeverity::High,
1550 crap: Some(650.0),
1551 coverage_pct: None,
1552 coverage_tier: Some(crate::health_types::CoverageTier::None),
1553 coverage_source: Some(crate::health_types::CoverageSource::Estimated),
1554 inherited_from: None,
1555 component_rollup: None,
1556 contributions: Vec::new(),
1557 }
1558 .into(),
1559 ],
1560 summary: crate::health_types::HealthSummary {
1561 files_analyzed: 1,
1562 functions_analyzed: 1,
1563 functions_above_threshold: 1,
1564 coverage_model: Some(crate::health_types::CoverageModel::StaticEstimated),
1565 coverage_source_consistency: None,
1566 ..Default::default()
1567 },
1568 ..Default::default()
1569 };
1570 let text = plain(&build_health_human_lines(&report, &root));
1571 assert!(text.contains("CRAP scores are estimated from export references"));
1572 assert!(text.contains("fallow health --coverage <coverage-final.json>"));
1573 }
1574
1575 #[test]
1576 fn health_findings_explain_mixed_istanbul_crap_scores() {
1577 let root = PathBuf::from("/project");
1578 let report = crate::health_types::HealthReport {
1579 findings: vec![
1580 crate::health_types::ComplexityViolation {
1581 path: root.join("src/risky.ts"),
1582 name: "risky".to_string(),
1583 line: 7,
1584 col: 0,
1585 cyclomatic: 25,
1586 cognitive: 20,
1587 line_count: 80,
1588 param_count: 0,
1589 exceeded: crate::health_types::ExceededThreshold::Crap,
1590 severity: crate::health_types::FindingSeverity::High,
1591 crap: Some(45.0),
1592 coverage_pct: Some(40.0),
1593 coverage_tier: Some(crate::health_types::CoverageTier::Partial),
1594 coverage_source: Some(crate::health_types::CoverageSource::Istanbul),
1595 inherited_from: None,
1596 component_rollup: None,
1597 contributions: Vec::new(),
1598 }
1599 .into(),
1600 ],
1601 summary: crate::health_types::HealthSummary {
1602 files_analyzed: 1,
1603 functions_analyzed: 2,
1604 functions_above_threshold: 1,
1605 coverage_model: Some(crate::health_types::CoverageModel::Istanbul),
1606 coverage_source_consistency: None,
1607 istanbul_matched: Some(1),
1608 istanbul_total: Some(2),
1609 ..Default::default()
1610 },
1611 ..Default::default()
1612 };
1613 let text = plain(&build_health_human_lines(&report, &root));
1614 assert!(
1615 text.contains(
1616 "CRAP scores use Istanbul coverage where matched (1/2 functions); unmatched functions are estimated"
1617 ),
1618 "mixed Istanbul note missing from output: {text}"
1619 );
1620 }
1621
1622 #[test]
1623 fn health_findings_explain_istanbul_counts_without_summary_model() {
1624 let root = PathBuf::from("/project");
1625 let report = crate::health_types::HealthReport {
1626 findings: vec![
1627 crate::health_types::ComplexityViolation {
1628 path: root.join("src/risky.ts"),
1629 name: "risky".to_string(),
1630 line: 7,
1631 col: 0,
1632 cyclomatic: 25,
1633 cognitive: 20,
1634 line_count: 80,
1635 param_count: 0,
1636 exceeded: crate::health_types::ExceededThreshold::Crap,
1637 severity: crate::health_types::FindingSeverity::High,
1638 crap: Some(45.0),
1639 coverage_pct: None,
1640 coverage_tier: Some(crate::health_types::CoverageTier::None),
1641 coverage_source: Some(crate::health_types::CoverageSource::Estimated),
1642 inherited_from: None,
1643 component_rollup: None,
1644 contributions: Vec::new(),
1645 }
1646 .into(),
1647 ],
1648 summary: crate::health_types::HealthSummary {
1649 files_analyzed: 1,
1650 functions_analyzed: 2,
1651 functions_above_threshold: 1,
1652 coverage_model: None,
1653 coverage_source_consistency: None,
1654 istanbul_matched: Some(1),
1655 istanbul_total: Some(2),
1656 ..Default::default()
1657 },
1658 ..Default::default()
1659 };
1660 let text = plain(&build_health_human_lines(&report, &root));
1661 assert!(
1662 text.contains(
1663 "CRAP scores use Istanbul coverage where matched (1/2 functions); unmatched functions are estimated"
1664 ),
1665 "Istanbul counts should drive the note even when coverage_model is omitted: {text}"
1666 );
1667 }
1668
1669 #[test]
1670 fn health_findings_grouped_by_file() {
1671 let root = PathBuf::from("/project");
1672 let report = crate::health_types::HealthReport {
1673 findings: vec![
1674 crate::health_types::ComplexityViolation {
1675 path: root.join("src/parser.ts"),
1676 name: "fn1".to_string(),
1677 line: 10,
1678 col: 0,
1679 cyclomatic: 25,
1680 cognitive: 20,
1681 line_count: 40,
1682 param_count: 0,
1683 exceeded: crate::health_types::ExceededThreshold::Both,
1684 severity: crate::health_types::FindingSeverity::High,
1685 crap: None,
1686 coverage_pct: None,
1687 coverage_tier: None,
1688 coverage_source: None,
1689 inherited_from: None,
1690 component_rollup: None,
1691 contributions: Vec::new(),
1692 }
1693 .into(),
1694 crate::health_types::ComplexityViolation {
1695 path: root.join("src/parser.ts"),
1696 name: "fn2".to_string(),
1697 line: 60,
1698 col: 0,
1699 cyclomatic: 22,
1700 cognitive: 18,
1701 line_count: 30,
1702 param_count: 0,
1703 exceeded: crate::health_types::ExceededThreshold::Both,
1704 severity: crate::health_types::FindingSeverity::High,
1705 crap: None,
1706 coverage_pct: None,
1707 coverage_tier: None,
1708 coverage_source: None,
1709 inherited_from: None,
1710 component_rollup: None,
1711 contributions: Vec::new(),
1712 }
1713 .into(),
1714 ],
1715 summary: crate::health_types::HealthSummary {
1716 files_analyzed: 10,
1717 functions_analyzed: 50,
1718 functions_above_threshold: 2,
1719 ..Default::default()
1720 },
1721 ..Default::default()
1722 };
1723 let lines = build_health_human_lines(&report, &root);
1724 let text = plain(&lines);
1725 let count = text.matches("src/parser.ts").count();
1726 assert_eq!(count, 1, "File header should appear once for grouped items");
1727 }
1728
1729 fn empty_report() -> crate::health_types::HealthReport {
1730 crate::health_types::HealthReport {
1731 summary: crate::health_types::HealthSummary {
1732 files_analyzed: 10,
1733 functions_analyzed: 50,
1734 ..Default::default()
1735 },
1736 ..Default::default()
1737 }
1738 }
1739
1740 #[test]
1741 fn health_runtime_coverage_renders_section() {
1742 let root = PathBuf::from("/project");
1743 let mut report = empty_report();
1744 report.runtime_coverage = Some(crate::health_types::RuntimeCoverageReport {
1745 schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
1746 verdict: crate::health_types::RuntimeCoverageReportVerdict::ColdCodeDetected,
1747 signals: Vec::new(),
1748 summary: crate::health_types::RuntimeCoverageSummary {
1749 data_source: crate::health_types::RuntimeCoverageDataSource::Local,
1750 last_received_at: None,
1751 functions_tracked: 4,
1752 functions_hit: 2,
1753 functions_unhit: 1,
1754 functions_untracked: 1,
1755 coverage_percent: 50.0,
1756 trace_count: 2_847_291,
1757 period_days: 30,
1758 deployments_seen: 14,
1759 capture_quality: None,
1760 },
1761 findings: vec![crate::health_types::RuntimeCoverageFinding {
1762 id: "fallow:prod:deadbeef".to_owned(),
1763 stable_id: None,
1764 path: root.join("src/cold.ts"),
1765 function: "coldPath".to_owned(),
1766 line: 14,
1767 verdict: crate::health_types::RuntimeCoverageVerdict::ReviewRequired,
1768 invocations: Some(0),
1769 confidence: crate::health_types::RuntimeCoverageConfidence::Medium,
1770 evidence: crate::health_types::RuntimeCoverageEvidence {
1771 static_status: "used".to_owned(),
1772 test_coverage: "not_covered".to_owned(),
1773 v8_tracking: "tracked".to_owned(),
1774 untracked_reason: None,
1775 observation_days: 30,
1776 deployments_observed: 14,
1777 },
1778 actions: vec![],
1779 source_hash: None,
1780 }],
1781 hot_paths: vec![crate::health_types::RuntimeCoverageHotPath {
1782 id: "fallow:hot:cafebabe".to_owned(),
1783 stable_id: None,
1784 path: root.join("src/hot.ts"),
1785 function: "hotPath".to_owned(),
1786 line: 3,
1787 end_line: 9,
1788 invocations: 250,
1789 percentile: 99,
1790 actions: vec![],
1791 }],
1792 blast_radius: vec![],
1793 importance: vec![],
1794 watermark: Some(crate::health_types::RuntimeCoverageWatermark::LicenseExpiredGrace),
1795 warnings: vec![],
1796 });
1797
1798 let text = plain(&build_health_human_lines(&report, &root));
1799 assert!(text.contains("Runtime coverage: cold code detected"));
1800 assert!(text.contains("src/cold.ts:14 coldPath [0 invocations, review required]"));
1801 assert!(text.contains("license expired grace active"));
1802 assert!(text.contains("hot paths:"));
1803 assert!(text.contains("src/hot.ts:3 hotPath (250 invocations, p99)"));
1804 assert!(!text.contains("short capture:"));
1805 assert!(!text.contains("start a trial"));
1806 }
1807
1808 #[test]
1809 fn health_coverage_intelligence_renders_findings_and_ambiguity_summary() {
1810 use crate::health_types::{
1811 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
1812 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
1813 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
1814 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
1815 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
1816 };
1817
1818 let root = PathBuf::from("/project");
1819 let mut report = empty_report();
1820 report.coverage_intelligence = Some(CoverageIntelligenceReport {
1821 schema_version: CoverageIntelligenceSchemaVersion::V1,
1822 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1823 summary: CoverageIntelligenceSummary {
1824 findings: 1,
1825 high_confidence_deletes: 1,
1826 ..Default::default()
1827 },
1828 findings: vec![CoverageIntelligenceFinding {
1829 id: "fallow:coverage-intel:abc123".to_owned(),
1830 path: root.join("src/dead.ts"),
1831 identity: Some("deadPath".to_owned()),
1832 line: 9,
1833 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1834 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
1835 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
1836 confidence: CoverageIntelligenceConfidence::High,
1837 related_ids: vec![],
1838 evidence: CoverageIntelligenceEvidence {
1839 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
1840 ..Default::default()
1841 },
1842 actions: vec![CoverageIntelligenceAction {
1843 kind: "delete-after-confirming-owner".to_owned(),
1844 description: "Confirm ownership before deleting".to_owned(),
1845 auto_fixable: false,
1846 }],
1847 }],
1848 });
1849
1850 let text = plain(&build_health_human_lines(&report, &root));
1851 assert!(text.contains("Coverage intelligence"));
1852 assert!(text.contains("src/dead.ts:9 deadPath high-confidence-delete"));
1853 assert!(text.contains("Confirm ownership before deleting"));
1854
1855 report.coverage_intelligence = Some(CoverageIntelligenceReport {
1856 schema_version: CoverageIntelligenceSchemaVersion::V1,
1857 verdict: CoverageIntelligenceVerdict::Clean,
1858 summary: CoverageIntelligenceSummary {
1859 skipped_ambiguous_matches: 2,
1860 ..Default::default()
1861 },
1862 findings: vec![],
1863 });
1864 let text = plain(&build_health_human_lines(&report, &root));
1865 assert!(text.contains("skipped 2 ambiguous evidence matches"));
1866 }
1867
1868 fn runtime_coverage_report_with_quality(
1869 quality: Option<crate::health_types::RuntimeCoverageCaptureQuality>,
1870 ) -> crate::health_types::RuntimeCoverageReport {
1871 crate::health_types::RuntimeCoverageReport {
1872 schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
1873 verdict: crate::health_types::RuntimeCoverageReportVerdict::Clean,
1874 signals: Vec::new(),
1875 summary: crate::health_types::RuntimeCoverageSummary {
1876 data_source: crate::health_types::RuntimeCoverageDataSource::Local,
1877 last_received_at: None,
1878 functions_tracked: 10,
1879 functions_hit: 7,
1880 functions_unhit: 0,
1881 functions_untracked: 3,
1882 coverage_percent: 70.0,
1883 trace_count: 1_000,
1884 period_days: 1,
1885 deployments_seen: 1,
1886 capture_quality: quality,
1887 },
1888 findings: vec![],
1889 hot_paths: vec![],
1890 blast_radius: vec![],
1891 importance: vec![],
1892 watermark: None,
1893 warnings: vec![],
1894 }
1895 }
1896
1897 #[test]
1898 fn health_runtime_coverage_short_capture_shows_warning_and_prompt() {
1899 let root = PathBuf::from("/project");
1900 let mut report = empty_report();
1901 report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
1902 crate::health_types::RuntimeCoverageCaptureQuality {
1903 window_seconds: 720, instances_observed: 1,
1905 lazy_parse_warning: true,
1906 untracked_ratio_percent: 42.5,
1907 },
1908 )));
1909 let text = plain(&build_health_human_lines(&report, &root));
1910 assert!(
1911 text.contains(
1912 "note: short capture (12 min from 1 instance); 42.5% of functions untracked, lazy-parsed scripts may not appear."
1913 ),
1914 "warning banner missing or malformed in:\n{text}"
1915 );
1916 assert!(
1917 text.contains("extend the capture or switch to continuous monitoring"),
1918 "warning follow-up line missing in:\n{text}"
1919 );
1920 assert!(
1921 text.contains("captured 12 min from 1 instance."),
1922 "upgrade prompt header missing in:\n{text}"
1923 );
1924 assert!(
1925 text.contains("continuous monitoring over 30 days evaluates more paths"),
1926 "upgrade prompt body missing in:\n{text}"
1927 );
1928 assert!(
1929 text.contains("fallow license activate --trial --email you@company.com"),
1930 "trial CTA command missing in:\n{text}"
1931 );
1932 }
1933
1934 #[test]
1935 fn health_runtime_coverage_long_capture_shows_neither_warning_nor_prompt() {
1936 let root = PathBuf::from("/project");
1937 let mut report = empty_report();
1938 report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
1939 crate::health_types::RuntimeCoverageCaptureQuality {
1940 window_seconds: 7 * 24 * 3600, instances_observed: 4,
1942 lazy_parse_warning: false,
1943 untracked_ratio_percent: 3.1,
1944 },
1945 )));
1946 let text = plain(&build_health_human_lines(&report, &root));
1947 assert!(
1948 !text.contains("short capture"),
1949 "long capture should not emit short-capture warning:\n{text}"
1950 );
1951 assert!(
1952 !text.contains("start a trial"),
1953 "long capture should not emit trial CTA:\n{text}"
1954 );
1955 }
1956
1957 #[test]
1958 fn format_window_labels() {
1959 assert_eq!(super::format_window(30), "30 s");
1960 assert_eq!(super::format_window(60), "1 min");
1961 assert_eq!(super::format_window(720), "12 min");
1962 assert_eq!(super::format_window(3600 * 3), "3 h");
1963 assert_eq!(super::format_window(3600 * 24 * 3), "3 d");
1964 }
1965
1966 #[test]
1967 fn health_coverage_gaps_render_section() {
1968 use crate::health_types::*;
1969
1970 let root = PathBuf::from("/project");
1971 let mut report = empty_report();
1972 report.coverage_gaps = Some(CoverageGaps {
1973 summary: CoverageGapSummary {
1974 runtime_files: 1,
1975 covered_files: 0,
1976 file_coverage_pct: 0.0,
1977 untested_files: 1,
1978 untested_exports: 1,
1979 },
1980 files: vec![UntestedFileFinding::with_actions(
1981 UntestedFile {
1982 path: root.join("src/app.ts"),
1983 value_export_count: 2,
1984 },
1985 &root,
1986 )],
1987 exports: vec![UntestedExportFinding::with_actions(
1988 UntestedExport {
1989 path: root.join("src/app.ts"),
1990 export_name: "loader".into(),
1991 line: 12,
1992 col: 4,
1993 },
1994 &root,
1995 )],
1996 });
1997
1998 let text = plain(&build_health_human_lines(&report, &root));
1999 assert!(
2000 text.contains("Coverage gaps (1 untested file, 1 untested export, 0.0% file coverage)")
2001 );
2002 assert!(text.contains("src/app.ts"));
2003 assert!(text.contains("loader"));
2004 }
2005
2006 #[test]
2007 fn fmt_trend_val_percentage() {
2008 assert_eq!(fmt_trend_val(15.5, "%"), "15.5%");
2009 assert_eq!(fmt_trend_val(0.0, "%"), "0.0%");
2010 }
2011
2012 #[test]
2013 fn fmt_trend_val_integer_when_round() {
2014 assert_eq!(fmt_trend_val(72.0, ""), "72");
2015 assert_eq!(fmt_trend_val(5.0, "pts"), "5");
2016 }
2017
2018 #[test]
2019 fn fmt_trend_val_decimal_when_fractional() {
2020 assert_eq!(fmt_trend_val(4.7, ""), "4.7");
2021 assert_eq!(fmt_trend_val(1.3, "pts"), "1.3");
2022 }
2023
2024 #[test]
2025 fn fmt_trend_delta_percentage() {
2026 assert_eq!(fmt_trend_delta(2.5, "%"), "+2.5%");
2027 assert_eq!(fmt_trend_delta(-1.3, "%"), "-1.3%");
2028 }
2029
2030 #[test]
2031 fn fmt_trend_delta_integer_when_round() {
2032 assert_eq!(fmt_trend_delta(5.0, ""), "+5");
2033 assert_eq!(fmt_trend_delta(-3.0, "pts"), "-3");
2034 }
2035
2036 #[test]
2037 fn fmt_trend_delta_decimal_when_fractional() {
2038 assert_eq!(fmt_trend_delta(4.9, ""), "+4.9");
2039 assert_eq!(fmt_trend_delta(-0.7, "pts"), "-0.7");
2040 }
2041
2042 #[test]
2043 fn health_score_grade_a_display() {
2044 let root = PathBuf::from("/project");
2045 let mut report = empty_report();
2046 report.health_score = Some(crate::health_types::HealthScore {
2047 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2048 score: 92.0,
2049 grade: "A",
2050 penalties: crate::health_types::HealthScorePenalties {
2051 dead_files: Some(3.0),
2052 dead_exports: Some(2.0),
2053 complexity: 1.5,
2054 p90_complexity: 1.5,
2055 maintainability: Some(0.0),
2056 hotspots: Some(0.0),
2057 unused_deps: Some(0.0),
2058 circular_deps: Some(0.0),
2059 unit_size: None,
2060 coupling: None,
2061 duplication: None,
2062 },
2063 });
2064 let lines = build_health_human_lines(&report, &root);
2065 let text = plain(&lines);
2066 assert!(text.contains("Health score:"));
2067 assert!(text.contains("92 A"));
2068 assert!(text.contains("dead files -3.0"));
2069 assert!(text.contains("dead exports -2.0"));
2070 assert!(text.contains("complexity -1.5"));
2071 assert!(text.contains("p90 -1.5"));
2072 }
2073
2074 #[test]
2075 fn health_score_grade_b_display() {
2076 let root = PathBuf::from("/project");
2077 let mut report = empty_report();
2078 report.health_score = Some(crate::health_types::HealthScore {
2079 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2080 score: 76.0,
2081 grade: "B",
2082 penalties: crate::health_types::HealthScorePenalties {
2083 dead_files: Some(5.0),
2084 dead_exports: Some(6.0),
2085 complexity: 3.0,
2086 p90_complexity: 2.0,
2087 maintainability: Some(4.0),
2088 hotspots: Some(2.0),
2089 unused_deps: Some(1.0),
2090 circular_deps: Some(1.0),
2091 unit_size: None,
2092 coupling: None,
2093 duplication: None,
2094 },
2095 });
2096 let lines = build_health_human_lines(&report, &root);
2097 let text = plain(&lines);
2098 assert!(text.contains("76 B"));
2099 assert!(text.contains("dead exports -6.0"));
2100 assert!(text.contains("maintainability -4.0"));
2101 assert!(text.contains("hotspots -2.0"));
2102 assert!(text.contains("unused deps -1.0"));
2103 assert!(text.contains("circular deps -1.0"));
2104 }
2105
2106 #[test]
2107 fn health_score_grade_c_display() {
2108 let root = PathBuf::from("/project");
2109 let mut report = empty_report();
2110 report.health_score = Some(crate::health_types::HealthScore {
2111 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2112 score: 60.0,
2113 grade: "C",
2114 penalties: crate::health_types::HealthScorePenalties {
2115 dead_files: Some(10.0),
2116 dead_exports: Some(10.0),
2117 complexity: 10.0,
2118 p90_complexity: 5.0,
2119 maintainability: Some(5.0),
2120 hotspots: None,
2121 unused_deps: None,
2122 circular_deps: None,
2123 unit_size: None,
2124 coupling: None,
2125 duplication: None,
2126 },
2127 });
2128 let lines = build_health_human_lines(&report, &root);
2129 let text = plain(&lines);
2130 assert!(text.contains("60 C"));
2131 }
2132
2133 #[test]
2134 fn health_score_grade_f_display() {
2135 let root = PathBuf::from("/project");
2136 let mut report = empty_report();
2137 report.health_score = Some(crate::health_types::HealthScore {
2138 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2139 score: 30.0,
2140 grade: "F",
2141 penalties: crate::health_types::HealthScorePenalties {
2142 dead_files: Some(15.0),
2143 dead_exports: Some(15.0),
2144 complexity: 20.0,
2145 p90_complexity: 10.0,
2146 maintainability: Some(10.0),
2147 hotspots: None,
2148 unused_deps: None,
2149 circular_deps: None,
2150 unit_size: None,
2151 coupling: None,
2152 duplication: None,
2153 },
2154 });
2155 let lines = build_health_human_lines(&report, &root);
2156 let text = plain(&lines);
2157 assert!(text.contains("30 F"));
2158 }
2159
2160 #[test]
2161 fn health_score_na_components_shown() {
2162 let root = PathBuf::from("/project");
2163 let mut report = empty_report();
2164 report.health_score = Some(crate::health_types::HealthScore {
2165 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2166 score: 90.0,
2167 grade: "A",
2168 penalties: crate::health_types::HealthScorePenalties {
2169 dead_files: None,
2170 dead_exports: None,
2171 complexity: 0.0,
2172 p90_complexity: 0.0,
2173 maintainability: None,
2174 hotspots: None,
2175 unused_deps: None,
2176 circular_deps: None,
2177 unit_size: None,
2178 coupling: None,
2179 duplication: None,
2180 },
2181 });
2182 let lines = build_health_human_lines(&report, &root);
2183 let text = plain(&lines);
2184 assert!(text.contains("N/A: dead code, maintainability, hotspots"));
2185 assert!(text.contains("enable the corresponding analysis flags"));
2186 }
2187
2188 #[test]
2189 fn health_score_no_na_when_all_present() {
2190 let root = PathBuf::from("/project");
2191 let mut report = empty_report();
2192 report.health_score = Some(crate::health_types::HealthScore {
2193 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2194 score: 85.0,
2195 grade: "A",
2196 penalties: crate::health_types::HealthScorePenalties {
2197 dead_files: Some(0.0),
2198 dead_exports: Some(0.0),
2199 complexity: 0.0,
2200 p90_complexity: 0.0,
2201 maintainability: Some(0.0),
2202 hotspots: Some(0.0),
2203 unused_deps: Some(0.0),
2204 circular_deps: Some(0.0),
2205 unit_size: None,
2206 coupling: None,
2207 duplication: None,
2208 },
2209 });
2210 let lines = build_health_human_lines(&report, &root);
2211 let text = plain(&lines);
2212 assert!(!text.contains("N/A:"));
2213 }
2214
2215 #[test]
2216 fn health_score_zero_penalties_suppressed() {
2217 let root = PathBuf::from("/project");
2218 let mut report = empty_report();
2219 report.health_score = Some(crate::health_types::HealthScore {
2220 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2221 score: 100.0,
2222 grade: "A",
2223 penalties: crate::health_types::HealthScorePenalties {
2224 dead_files: Some(0.0),
2225 dead_exports: Some(0.0),
2226 complexity: 0.0,
2227 p90_complexity: 0.0,
2228 maintainability: Some(0.0),
2229 hotspots: Some(0.0),
2230 unused_deps: Some(0.0),
2231 circular_deps: Some(0.0),
2232 unit_size: None,
2233 coupling: None,
2234 duplication: None,
2235 },
2236 });
2237 let lines = build_health_human_lines(&report, &root);
2238 let text = plain(&lines);
2239 assert!(!text.contains("dead files"));
2240 assert!(!text.contains("complexity -"));
2241 }
2242
2243 #[test]
2244 fn health_trend_improving_display() {
2245 let root = PathBuf::from("/project");
2246 let mut report = empty_report();
2247 report.health_trend = Some(crate::health_types::HealthTrend {
2248 compared_to: crate::health_types::TrendPoint {
2249 timestamp: "2026-03-25T14:30:00Z".into(),
2250 git_sha: Some("abc1234".into()),
2251 score: Some(72.0),
2252 grade: Some("B".into()),
2253 coverage_model: None,
2254 snapshot_schema_version: None,
2255 },
2256 metrics: vec![
2257 crate::health_types::TrendMetric {
2258 name: "score",
2259 label: "Health Score",
2260 previous: 72.0,
2261 current: 85.0,
2262 delta: 13.0,
2263 direction: crate::health_types::TrendDirection::Improving,
2264 unit: "",
2265 previous_count: None,
2266 current_count: None,
2267 },
2268 crate::health_types::TrendMetric {
2269 name: "dead_file_pct",
2270 label: "Dead Files",
2271 previous: 10.0,
2272 current: 5.0,
2273 delta: -5.0,
2274 direction: crate::health_types::TrendDirection::Improving,
2275 unit: "%",
2276 previous_count: None,
2277 current_count: None,
2278 },
2279 ],
2280 snapshots_loaded: 2,
2281 overall_direction: crate::health_types::TrendDirection::Improving,
2282 });
2283 let lines = build_health_human_lines(&report, &root);
2284 let text = plain(&lines);
2285 assert!(text.contains("Trend:"));
2286 assert!(text.contains("improving"));
2287 assert!(text.contains("vs 2026-03-25"));
2288 assert!(text.contains("abc1234"));
2289 assert!(text.contains("Health Score"));
2290 assert!(text.contains("+13"));
2291 assert!(text.contains("Dead Files"));
2292 assert!(text.contains("-5.0%"));
2293 }
2294
2295 #[test]
2296 fn health_trend_declining_display() {
2297 let root = PathBuf::from("/project");
2298 let mut report = empty_report();
2299 report.health_trend = Some(crate::health_types::HealthTrend {
2300 compared_to: crate::health_types::TrendPoint {
2301 timestamp: "2026-03-20T10:00:00Z".into(),
2302 git_sha: None,
2303 score: None,
2304 grade: None,
2305 coverage_model: None,
2306 snapshot_schema_version: None,
2307 },
2308 metrics: vec![crate::health_types::TrendMetric {
2309 name: "unused_deps",
2310 label: "Unused Deps",
2311 previous: 5.0,
2312 current: 10.0,
2313 delta: 5.0,
2314 direction: crate::health_types::TrendDirection::Declining,
2315 unit: "",
2316 previous_count: None,
2317 current_count: None,
2318 }],
2319 snapshots_loaded: 1,
2320 overall_direction: crate::health_types::TrendDirection::Declining,
2321 });
2322 let lines = build_health_human_lines(&report, &root);
2323 let text = plain(&lines);
2324 assert!(text.contains("declining"));
2325 assert!(text.contains("Unused Deps"));
2326 }
2327
2328 #[test]
2329 fn health_trend_all_stable_collapsed() {
2330 let root = PathBuf::from("/project");
2331 let mut report = empty_report();
2332 report.health_trend = Some(crate::health_types::HealthTrend {
2333 compared_to: crate::health_types::TrendPoint {
2334 timestamp: "2026-03-25T14:30:00Z".into(),
2335 git_sha: Some("def5678".into()),
2336 score: Some(80.0),
2337 grade: Some("B".into()),
2338 coverage_model: None,
2339 snapshot_schema_version: None,
2340 },
2341 metrics: vec![
2342 crate::health_types::TrendMetric {
2343 name: "score",
2344 label: "Health Score",
2345 previous: 80.0,
2346 current: 80.0,
2347 delta: 0.0,
2348 direction: crate::health_types::TrendDirection::Stable,
2349 unit: "",
2350 previous_count: None,
2351 current_count: None,
2352 },
2353 crate::health_types::TrendMetric {
2354 name: "avg_cyclomatic",
2355 label: "Avg Cyclomatic",
2356 previous: 2.0,
2357 current: 2.0,
2358 delta: 0.0,
2359 direction: crate::health_types::TrendDirection::Stable,
2360 unit: "",
2361 previous_count: None,
2362 current_count: None,
2363 },
2364 ],
2365 snapshots_loaded: 3,
2366 overall_direction: crate::health_types::TrendDirection::Stable,
2367 });
2368 let lines = build_health_human_lines(&report, &root);
2369 let text = plain(&lines);
2370 assert!(text.contains("stable"));
2371 assert!(text.contains("All 2 metrics unchanged"));
2372 assert!(!text.contains("Health Score"));
2373 }
2374
2375 #[test]
2376 fn health_trend_without_sha() {
2377 let root = PathBuf::from("/project");
2378 let mut report = empty_report();
2379 report.health_trend = Some(crate::health_types::HealthTrend {
2380 compared_to: crate::health_types::TrendPoint {
2381 timestamp: "2026-03-20T10:00:00Z".into(),
2382 git_sha: None,
2383 score: None,
2384 grade: None,
2385 coverage_model: None,
2386 snapshot_schema_version: None,
2387 },
2388 metrics: vec![crate::health_types::TrendMetric {
2389 name: "score",
2390 label: "Health Score",
2391 previous: 80.0,
2392 current: 82.0,
2393 delta: 2.0,
2394 direction: crate::health_types::TrendDirection::Improving,
2395 unit: "",
2396 previous_count: None,
2397 current_count: None,
2398 }],
2399 snapshots_loaded: 1,
2400 overall_direction: crate::health_types::TrendDirection::Improving,
2401 });
2402 let lines = build_health_human_lines(&report, &root);
2403 let text = plain(&lines);
2404 assert!(text.contains("vs 2026-03-20"));
2405 assert!(!text.contains("\u{00b7}"));
2406 }
2407
2408 #[test]
2409 fn vital_signs_shown_without_trend() {
2410 let root = PathBuf::from("/project");
2411 let mut report = empty_report();
2412 report.vital_signs = Some(crate::health_types::VitalSigns {
2413 dead_file_pct: Some(3.2),
2414 dead_export_pct: Some(8.1),
2415 avg_cyclomatic: 4.7,
2416 p90_cyclomatic: 12,
2417 duplication_pct: None,
2418 hotspot_count: Some(2),
2419 maintainability_avg: Some(72.4),
2420 unused_dep_count: Some(3),
2421 circular_dep_count: Some(1),
2422 counts: None,
2423 unit_size_profile: None,
2424 unit_interfacing_profile: None,
2425 p95_fan_in: None,
2426 coupling_high_pct: None,
2427 total_loc: 42_381,
2428 ..Default::default()
2429 });
2430 report.hotspot_summary = Some(crate::health_types::HotspotSummary {
2431 since: "6 months".to_string(),
2432 min_commits: 3,
2433 files_analyzed: 50,
2434 files_excluded: 20,
2435 shallow_clone: false,
2436 });
2437 let lines = build_health_human_lines(&report, &root);
2438 let text = plain(&lines);
2439 assert!(text.contains("42,381 LOC"));
2440 assert!(text.contains("dead files 3.2%"));
2441 assert!(text.contains("dead exports 8.1%"));
2442 assert!(text.contains("avg cyclomatic 4.7"));
2443 assert!(text.contains("p90 cyclomatic 12"));
2444 assert!(text.contains("maintainability 72.4"));
2445 assert!(text.contains("2 churn hotspots (since 6 months)"));
2446 assert!(text.contains("3 unused deps"));
2447 assert!(text.contains("1 circular dep"));
2448 }
2449
2450 #[test]
2451 fn vital_signs_zero_hotspots_still_show_window() {
2452 let root = PathBuf::from("/project");
2453 let mut report = empty_report();
2454 report.vital_signs = Some(crate::health_types::VitalSigns {
2455 avg_cyclomatic: 2.0,
2456 p90_cyclomatic: 5,
2457 hotspot_count: Some(0),
2458 total_loc: 1_000,
2459 ..Default::default()
2460 });
2461 report.hotspot_summary = Some(crate::health_types::HotspotSummary {
2462 since: "90 days".to_string(),
2463 min_commits: 3,
2464 files_analyzed: 10,
2465 files_excluded: 0,
2466 shallow_clone: false,
2467 });
2468 let lines = build_health_human_lines(&report, &root);
2469 let text = plain(&lines);
2470 assert!(text.contains("0 churn hotspots (since 90 days)"));
2471 assert!(!text.contains("Hotspots ("));
2472 }
2473
2474 #[test]
2475 fn vital_signs_hotspot_count_without_summary_omits_window() {
2476 let root = PathBuf::from("/project");
2477 let mut report = empty_report();
2478 report.vital_signs = Some(crate::health_types::VitalSigns {
2479 avg_cyclomatic: 2.0,
2480 p90_cyclomatic: 5,
2481 hotspot_count: Some(1),
2482 total_loc: 1_000,
2483 ..Default::default()
2484 });
2485 report.hotspot_summary = None;
2486 let lines = build_health_human_lines(&report, &root);
2487 let text = plain(&lines);
2488 assert!(text.contains("1 churn hotspot"));
2489 assert!(!text.contains("(since"));
2490 }
2491
2492 #[test]
2493 fn vital_signs_suppressed_when_trend_active() {
2494 let root = PathBuf::from("/project");
2495 let mut report = empty_report();
2496 report.vital_signs = Some(crate::health_types::VitalSigns {
2497 dead_file_pct: Some(3.2),
2498 dead_export_pct: Some(8.1),
2499 avg_cyclomatic: 4.7,
2500 p90_cyclomatic: 12,
2501 duplication_pct: None,
2502 hotspot_count: Some(2),
2503 maintainability_avg: Some(72.4),
2504 unused_dep_count: None,
2505 circular_dep_count: None,
2506 counts: None,
2507 unit_size_profile: None,
2508 unit_interfacing_profile: None,
2509 p95_fan_in: None,
2510 coupling_high_pct: None,
2511 total_loc: 0,
2512 ..Default::default()
2513 });
2514 report.health_trend = Some(crate::health_types::HealthTrend {
2515 compared_to: crate::health_types::TrendPoint {
2516 timestamp: "2026-03-25T14:30:00Z".into(),
2517 git_sha: None,
2518 score: None,
2519 grade: None,
2520 coverage_model: None,
2521 snapshot_schema_version: None,
2522 },
2523 metrics: vec![],
2524 snapshots_loaded: 1,
2525 overall_direction: crate::health_types::TrendDirection::Stable,
2526 });
2527 let lines = build_health_human_lines(&report, &root);
2528 let text = plain(&lines);
2529 assert!(!text.contains("dead files"));
2530 assert!(!text.contains("avg cyclomatic"));
2531 }
2532
2533 #[test]
2534 fn vital_signs_optional_fields_omitted_when_none() {
2535 let root = PathBuf::from("/project");
2536 let mut report = empty_report();
2537 report.vital_signs = Some(crate::health_types::VitalSigns {
2538 dead_file_pct: None,
2539 dead_export_pct: None,
2540 avg_cyclomatic: 2.0,
2541 p90_cyclomatic: 5,
2542 duplication_pct: None,
2543 hotspot_count: None,
2544 maintainability_avg: None,
2545 unused_dep_count: None,
2546 circular_dep_count: None,
2547 counts: None,
2548 unit_size_profile: None,
2549 unit_interfacing_profile: None,
2550 p95_fan_in: None,
2551 coupling_high_pct: None,
2552 total_loc: 0,
2553 ..Default::default()
2554 });
2555 let lines = build_health_human_lines(&report, &root);
2556 let text = plain(&lines);
2557 assert!(!text.contains("dead files"));
2558 assert!(!text.contains("dead exports"));
2559 assert!(!text.contains("maintainability "));
2560 assert!(!text.contains("hotspot"));
2561 assert!(text.contains("avg cyclomatic 2.0"));
2562 assert!(text.contains("p90 cyclomatic 5"));
2563 }
2564
2565 #[test]
2566 fn vital_signs_zero_counts_suppressed() {
2567 let root = PathBuf::from("/project");
2568 let mut report = empty_report();
2569 report.vital_signs = Some(crate::health_types::VitalSigns {
2570 dead_file_pct: None,
2571 dead_export_pct: None,
2572 avg_cyclomatic: 1.0,
2573 p90_cyclomatic: 2,
2574 duplication_pct: None,
2575 hotspot_count: None,
2576 maintainability_avg: None,
2577 unused_dep_count: Some(0),
2578 circular_dep_count: Some(0),
2579 counts: None,
2580 unit_size_profile: None,
2581 unit_interfacing_profile: None,
2582 p95_fan_in: None,
2583 coupling_high_pct: None,
2584 total_loc: 0,
2585 ..Default::default()
2586 });
2587 let lines = build_health_human_lines(&report, &root);
2588 let text = plain(&lines);
2589 assert!(!text.contains("unused dep"));
2590 assert!(!text.contains("circular dep"));
2591 }
2592
2593 #[test]
2594 fn vital_signs_plural_vs_singular() {
2595 let root = PathBuf::from("/project");
2596 let mut report = empty_report();
2597 report.vital_signs = Some(crate::health_types::VitalSigns {
2598 dead_file_pct: None,
2599 dead_export_pct: None,
2600 avg_cyclomatic: 1.0,
2601 p90_cyclomatic: 2,
2602 duplication_pct: None,
2603 hotspot_count: Some(1),
2604 maintainability_avg: None,
2605 unused_dep_count: Some(1),
2606 circular_dep_count: Some(2),
2607 counts: None,
2608 unit_size_profile: None,
2609 unit_interfacing_profile: None,
2610 p95_fan_in: None,
2611 coupling_high_pct: None,
2612 total_loc: 0,
2613 ..Default::default()
2614 });
2615 let lines = build_health_human_lines(&report, &root);
2616 let text = plain(&lines);
2617 assert!(text.contains("1 churn hotspot"));
2618 assert!(!text.contains("1 churn hotspots"));
2619 assert!(text.contains("1 unused dep"));
2620 assert!(!text.contains("1 unused deps"));
2621 assert!(text.contains("2 circular deps"));
2622 }
2623
2624 #[test]
2625 fn file_scores_single_entry() {
2626 let root = PathBuf::from("/project");
2627 let mut report = empty_report();
2628 report.file_scores = vec![crate::health_types::FileHealthScore {
2629 path: root.join("src/utils.ts"),
2630 fan_in: 5,
2631 fan_out: 3,
2632 dead_code_ratio: 0.15,
2633 complexity_density: 0.42,
2634 maintainability_index: 85.3,
2635 total_cyclomatic: 12,
2636 total_cognitive: 8,
2637 function_count: 4,
2638 lines: 200,
2639 crap_max: 0.0,
2640 crap_above_threshold: 0,
2641 }];
2642 let lines = build_health_human_lines(&report, &root);
2643 let text = plain(&lines);
2644 assert!(text.contains("File health scores (1 files)"));
2645 assert!(text.contains("85.3"));
2646 assert!(text.contains("src/utils.ts"));
2647 assert!(text.contains("200 LOC"));
2648 assert!(text.contains("5 fan-in"));
2649 assert!(text.contains("3 fan-out"));
2650 assert!(text.contains("15% dead"));
2651 assert!(text.contains("0.42 density"));
2652 }
2653
2654 #[test]
2655 fn file_scores_concern_tag_marks_risk_vs_structure() {
2656 let root = PathBuf::from("/project");
2657 let mut report = empty_report();
2658 report.file_scores = vec![
2659 crate::health_types::FileHealthScore {
2660 path: root.join("src/risky.ts"),
2661 fan_in: 0,
2662 fan_out: 0,
2663 dead_code_ratio: 0.0,
2664 complexity_density: 0.2,
2665 maintainability_index: 85.0,
2666 total_cyclomatic: 10,
2667 total_cognitive: 8,
2668 function_count: 1,
2669 lines: 100,
2670 crap_max: 552.0,
2671 crap_above_threshold: 1,
2672 },
2673 crate::health_types::FileHealthScore {
2674 path: root.join("src/messy.ts"),
2675 fan_in: 0,
2676 fan_out: 0,
2677 dead_code_ratio: 0.0,
2678 complexity_density: 0.3,
2679 maintainability_index: 30.0,
2680 total_cyclomatic: 5,
2681 total_cognitive: 3,
2682 function_count: 1,
2683 lines: 100,
2684 crap_max: 2.0,
2685 crap_above_threshold: 0,
2686 },
2687 ];
2688 let text = plain(&build_health_human_lines(&report, &root));
2689 let risky_line = text
2690 .lines()
2691 .find(|l| l.contains("risky.ts"))
2692 .expect("risky path line");
2693 assert!(
2694 risky_line.trim_end().ends_with("risk"),
2695 "expected risk tag, got: {risky_line:?}"
2696 );
2697 let messy_line = text
2698 .lines()
2699 .find(|l| l.contains("messy.ts"))
2700 .expect("messy path line");
2701 assert!(
2702 messy_line.trim_end().ends_with("structure"),
2703 "expected structure tag, got: {messy_line:?}"
2704 );
2705 }
2706
2707 #[test]
2708 fn file_scores_mi_color_thresholds() {
2709 let root = PathBuf::from("/project");
2710 let mut report = empty_report();
2711 report.file_scores = vec![
2712 crate::health_types::FileHealthScore {
2713 path: root.join("src/good.ts"),
2714 fan_in: 1,
2715 fan_out: 1,
2716 dead_code_ratio: 0.0,
2717 complexity_density: 0.1,
2718 maintainability_index: 90.0, total_cyclomatic: 2,
2720 total_cognitive: 1,
2721 function_count: 1,
2722 lines: 50,
2723 crap_max: 0.0,
2724 crap_above_threshold: 0,
2725 },
2726 crate::health_types::FileHealthScore {
2727 path: root.join("src/okay.ts"),
2728 fan_in: 2,
2729 fan_out: 3,
2730 dead_code_ratio: 0.1,
2731 complexity_density: 0.3,
2732 maintainability_index: 65.0, total_cyclomatic: 8,
2734 total_cognitive: 5,
2735 function_count: 3,
2736 lines: 100,
2737 crap_max: 0.0,
2738 crap_above_threshold: 0,
2739 },
2740 crate::health_types::FileHealthScore {
2741 path: root.join("src/bad.ts"),
2742 fan_in: 8,
2743 fan_out: 12,
2744 dead_code_ratio: 0.5,
2745 complexity_density: 0.9,
2746 maintainability_index: 30.0, total_cyclomatic: 40,
2748 total_cognitive: 30,
2749 function_count: 10,
2750 lines: 500,
2751 crap_max: 0.0,
2752 crap_above_threshold: 0,
2753 },
2754 ];
2755 let lines = build_health_human_lines(&report, &root);
2756 let text = plain(&lines);
2757 assert!(text.contains("File health scores (3 files)"));
2758 assert!(text.contains("90.0"));
2759 assert!(text.contains("65.0"));
2760 assert!(text.contains("30.0"));
2761 }
2762
2763 #[test]
2764 fn file_scores_truncation_above_max_flat_items() {
2765 let root = PathBuf::from("/project");
2766 let mut report = empty_report();
2767 for i in 0..12 {
2768 report
2769 .file_scores
2770 .push(crate::health_types::FileHealthScore {
2771 path: root.join(format!("src/file{i}.ts")),
2772 fan_in: 1,
2773 fan_out: 1,
2774 dead_code_ratio: 0.0,
2775 complexity_density: 0.1,
2776 maintainability_index: 80.0,
2777 total_cyclomatic: 2,
2778 total_cognitive: 1,
2779 function_count: 1,
2780 lines: 50,
2781 crap_max: 0.0,
2782 crap_above_threshold: 0,
2783 });
2784 }
2785 let lines = build_health_human_lines(&report, &root);
2786 let text = plain(&lines);
2787 assert!(text.contains("File health scores (12 files)"));
2788 assert!(text.contains("... and 2 more files"));
2789 assert!(text.contains("file0.ts"));
2790 assert!(text.contains("file9.ts"));
2791 assert!(!text.contains("file10.ts"));
2792 assert!(!text.contains("file11.ts"));
2793 }
2794
2795 #[test]
2796 fn file_scores_docs_link() {
2797 let root = PathBuf::from("/project");
2798 let mut report = empty_report();
2799 report.file_scores = vec![crate::health_types::FileHealthScore {
2800 path: root.join("src/a.ts"),
2801 fan_in: 1,
2802 fan_out: 1,
2803 dead_code_ratio: 0.0,
2804 complexity_density: 0.1,
2805 maintainability_index: 80.0,
2806 total_cyclomatic: 2,
2807 total_cognitive: 1,
2808 function_count: 1,
2809 lines: 50,
2810 crap_max: 0.0,
2811 crap_above_threshold: 0,
2812 }];
2813 let lines = build_health_human_lines(&report, &root);
2814 let text = plain(&lines);
2815 assert!(text.contains("docs.fallow.tools/explanations/health#file-health-scores"));
2816 }
2817
2818 #[test]
2819 fn hotspots_accelerating_trend() {
2820 let root = PathBuf::from("/project");
2821 let mut report = empty_report();
2822 report.hotspots = vec![
2823 crate::health_types::HotspotEntry {
2824 path: root.join("src/core.ts"),
2825 score: 75.0,
2826 commits: 42,
2827 weighted_commits: 30.0,
2828 lines_added: 500,
2829 lines_deleted: 200,
2830 complexity_density: 0.85,
2831 fan_in: 10,
2832 trend: fallow_core::churn::ChurnTrend::Accelerating,
2833 ownership: None,
2834 is_test_path: false,
2835 }
2836 .into(),
2837 ];
2838 let lines = build_health_human_lines(&report, &root);
2839 let text = plain(&lines);
2840 assert!(text.contains("Hotspots (1 files)"));
2841 assert!(text.contains("75.0"));
2842 assert!(text.contains("src/core.ts"));
2843 assert!(text.contains("42 commits"));
2844 assert!(text.contains("700 churn"));
2845 assert!(text.contains("0.85 density"));
2846 assert!(text.contains("10 fan-in"));
2847 assert!(text.contains("accelerating"));
2848 }
2849
2850 #[test]
2851 fn hotspots_cooling_trend() {
2852 let root = PathBuf::from("/project");
2853 let mut report = empty_report();
2854 report.hotspots = vec![
2855 crate::health_types::HotspotEntry {
2856 path: root.join("src/old.ts"),
2857 score: 20.0,
2858 commits: 5,
2859 weighted_commits: 2.0,
2860 lines_added: 50,
2861 lines_deleted: 30,
2862 complexity_density: 0.3,
2863 fan_in: 2,
2864 trend: fallow_core::churn::ChurnTrend::Cooling,
2865 ownership: None,
2866 is_test_path: false,
2867 }
2868 .into(),
2869 ];
2870 let lines = build_health_human_lines(&report, &root);
2871 let text = plain(&lines);
2872 assert!(text.contains("20.0"));
2873 assert!(text.contains("cooling"));
2874 }
2875
2876 #[test]
2877 fn hotspots_stable_trend() {
2878 let root = PathBuf::from("/project");
2879 let mut report = empty_report();
2880 report.hotspots = vec![
2881 crate::health_types::HotspotEntry {
2882 path: root.join("src/mid.ts"),
2883 score: 45.0,
2884 commits: 15,
2885 weighted_commits: 10.0,
2886 lines_added: 200,
2887 lines_deleted: 100,
2888 complexity_density: 0.5,
2889 fan_in: 5,
2890 trend: fallow_core::churn::ChurnTrend::Stable,
2891 ownership: None,
2892 is_test_path: false,
2893 }
2894 .into(),
2895 ];
2896 let lines = build_health_human_lines(&report, &root);
2897 let text = plain(&lines);
2898 assert!(text.contains("45.0"));
2899 assert!(text.contains("stable"));
2900 }
2901
2902 #[test]
2903 fn hotspots_with_summary_and_since() {
2904 let root = PathBuf::from("/project");
2905 let mut report = empty_report();
2906 report.hotspots = vec![
2907 crate::health_types::HotspotEntry {
2908 path: root.join("src/a.ts"),
2909 score: 50.0,
2910 commits: 10,
2911 weighted_commits: 8.0,
2912 lines_added: 100,
2913 lines_deleted: 50,
2914 complexity_density: 0.4,
2915 fan_in: 3,
2916 trend: fallow_core::churn::ChurnTrend::Stable,
2917 ownership: None,
2918 is_test_path: false,
2919 }
2920 .into(),
2921 ];
2922 report.hotspot_summary = Some(crate::health_types::HotspotSummary {
2923 since: "6 months".to_string(),
2924 min_commits: 3,
2925 files_analyzed: 50,
2926 files_excluded: 20,
2927 shallow_clone: false,
2928 });
2929 let lines = build_health_human_lines(&report, &root);
2930 let text = plain(&lines);
2931 assert!(text.contains("Hotspots (1 files, since 6 months)"));
2932 assert!(text.contains("20 files excluded (< 3 commits)"));
2933 }
2934
2935 #[test]
2936 fn hotspots_summary_no_exclusions() {
2937 let root = PathBuf::from("/project");
2938 let mut report = empty_report();
2939 report.hotspots = vec![
2940 crate::health_types::HotspotEntry {
2941 path: root.join("src/a.ts"),
2942 score: 50.0,
2943 commits: 10,
2944 weighted_commits: 8.0,
2945 lines_added: 100,
2946 lines_deleted: 50,
2947 complexity_density: 0.4,
2948 fan_in: 3,
2949 trend: fallow_core::churn::ChurnTrend::Stable,
2950 ownership: None,
2951 is_test_path: false,
2952 }
2953 .into(),
2954 ];
2955 report.hotspot_summary = Some(crate::health_types::HotspotSummary {
2956 since: "3 months".to_string(),
2957 min_commits: 2,
2958 files_analyzed: 50,
2959 files_excluded: 0,
2960 shallow_clone: false,
2961 });
2962 let lines = build_health_human_lines(&report, &root);
2963 let text = plain(&lines);
2964 assert!(!text.contains("files excluded"));
2965 }
2966
2967 #[test]
2968 fn hotspots_docs_link() {
2969 let root = PathBuf::from("/project");
2970 let mut report = empty_report();
2971 report.hotspots = vec![
2972 crate::health_types::HotspotEntry {
2973 path: root.join("src/a.ts"),
2974 score: 50.0,
2975 commits: 10,
2976 weighted_commits: 8.0,
2977 lines_added: 100,
2978 lines_deleted: 50,
2979 complexity_density: 0.4,
2980 fan_in: 3,
2981 trend: fallow_core::churn::ChurnTrend::Stable,
2982 ownership: None,
2983 is_test_path: false,
2984 }
2985 .into(),
2986 ];
2987 let lines = build_health_human_lines(&report, &root);
2988 let text = plain(&lines);
2989 assert!(text.contains("docs.fallow.tools/explanations/health#hotspot-metrics"));
2990 }
2991
2992 #[test]
2993 fn refactoring_targets_single_low_effort() {
2994 let root = PathBuf::from("/project");
2995 let mut report = empty_report();
2996 report.targets = vec![
2997 crate::health_types::RefactoringTarget {
2998 path: root.join("src/legacy.ts"),
2999 priority: 65.0,
3000 efficiency: 65.0,
3001 recommendation: "Extract complex logic into helper functions".to_string(),
3002 category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3003 effort: crate::health_types::EffortEstimate::Low,
3004 confidence: crate::health_types::Confidence::High,
3005 factors: vec![],
3006 evidence: None,
3007 }
3008 .into(),
3009 ];
3010 let lines = build_health_human_lines(&report, &root);
3011 let text = plain(&lines);
3012 assert!(text.contains("Refactoring targets (1)"));
3013 assert!(text.contains("1 low effort"));
3014 assert!(text.contains("65.0"));
3015 assert!(text.contains("pri:65.0"));
3016 assert!(text.contains("src/legacy.ts"));
3017 assert!(text.contains("complexity"));
3018 assert!(text.contains("effort:low"));
3019 assert!(text.contains("confidence:high"));
3020 assert!(text.contains("Extract complex logic into helper functions"));
3021 }
3022
3023 #[test]
3024 fn refactoring_targets_mixed_effort() {
3025 let root = PathBuf::from("/project");
3026 let mut report = empty_report();
3027 report.targets = vec![
3028 crate::health_types::RefactoringTarget {
3029 path: root.join("src/a.ts"),
3030 priority: 80.0,
3031 efficiency: 80.0,
3032 recommendation: "Remove dead exports".to_string(),
3033 category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3034 effort: crate::health_types::EffortEstimate::Low,
3035 confidence: crate::health_types::Confidence::High,
3036 factors: vec![],
3037 evidence: None,
3038 }
3039 .into(),
3040 crate::health_types::RefactoringTarget {
3041 path: root.join("src/b.ts"),
3042 priority: 60.0,
3043 efficiency: 30.0,
3044 recommendation: "Split into smaller modules".to_string(),
3045 category: crate::health_types::RecommendationCategory::SplitHighImpact,
3046 effort: crate::health_types::EffortEstimate::Medium,
3047 confidence: crate::health_types::Confidence::Medium,
3048 factors: vec![],
3049 evidence: None,
3050 }
3051 .into(),
3052 crate::health_types::RefactoringTarget {
3053 path: root.join("src/c.ts"),
3054 priority: 50.0,
3055 efficiency: 16.7,
3056 recommendation: "Break circular dependency".to_string(),
3057 category: crate::health_types::RecommendationCategory::BreakCircularDependency,
3058 effort: crate::health_types::EffortEstimate::High,
3059 confidence: crate::health_types::Confidence::Low,
3060 factors: vec![],
3061 evidence: None,
3062 }
3063 .into(),
3064 ];
3065 let lines = build_health_human_lines(&report, &root);
3066 let text = plain(&lines);
3067 assert!(text.contains("Refactoring targets (3)"));
3068 assert!(text.contains("1 low effort"));
3069 assert!(text.contains("1 medium"));
3070 assert!(text.contains("1 high"));
3071 assert!(text.contains("effort:low"));
3072 assert!(text.contains("effort:medium"));
3073 assert!(text.contains("effort:high"));
3074 assert!(text.contains("confidence:high"));
3075 assert!(text.contains("confidence:medium"));
3076 assert!(text.contains("confidence:low"));
3077 }
3078
3079 #[test]
3080 fn refactoring_targets_truncation_above_max_flat_items() {
3081 let root = PathBuf::from("/project");
3082 let mut report = empty_report();
3083 for i in 0..12 {
3084 report.targets.push(
3085 crate::health_types::RefactoringTarget {
3086 path: root.join(format!("src/target{i}.ts")),
3087 priority: 50.0,
3088 efficiency: 25.0,
3089 recommendation: format!("Fix target {i}"),
3090 category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3091 effort: crate::health_types::EffortEstimate::Medium,
3092 confidence: crate::health_types::Confidence::Medium,
3093 factors: vec![],
3094 evidence: None,
3095 }
3096 .into(),
3097 );
3098 }
3099 let lines = build_health_human_lines(&report, &root);
3100 let text = plain(&lines);
3101 assert!(text.contains("Refactoring targets (12)"));
3102 assert!(text.contains("... and 2 more targets"));
3103 assert!(text.contains("target0.ts"));
3104 assert!(text.contains("target9.ts"));
3105 assert!(!text.contains("target10.ts"));
3106 }
3107
3108 #[test]
3109 fn refactoring_targets_docs_link() {
3110 let root = PathBuf::from("/project");
3111 let mut report = empty_report();
3112 report.targets = vec![
3113 crate::health_types::RefactoringTarget {
3114 path: root.join("src/a.ts"),
3115 priority: 50.0,
3116 efficiency: 50.0,
3117 recommendation: "Fix it".to_string(),
3118 category: crate::health_types::RecommendationCategory::ExtractDependencies,
3119 effort: crate::health_types::EffortEstimate::Low,
3120 confidence: crate::health_types::Confidence::High,
3121 factors: vec![],
3122 evidence: None,
3123 }
3124 .into(),
3125 ];
3126 let lines = build_health_human_lines(&report, &root);
3127 let text = plain(&lines);
3128 assert!(text.contains("docs.fallow.tools/explanations/health#refactoring-targets"));
3129 }
3130
3131 #[test]
3132 fn refactoring_targets_all_categories() {
3133 let root = PathBuf::from("/project");
3134 let mut report = empty_report();
3135 let categories = [
3136 (
3137 crate::health_types::RecommendationCategory::UrgentChurnComplexity,
3138 "churn+complexity",
3139 ),
3140 (
3141 crate::health_types::RecommendationCategory::BreakCircularDependency,
3142 "circular dependency",
3143 ),
3144 (
3145 crate::health_types::RecommendationCategory::SplitHighImpact,
3146 "high impact",
3147 ),
3148 (
3149 crate::health_types::RecommendationCategory::RemoveDeadCode,
3150 "dead code",
3151 ),
3152 (
3153 crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3154 "complexity",
3155 ),
3156 (
3157 crate::health_types::RecommendationCategory::ExtractDependencies,
3158 "coupling",
3159 ),
3160 (
3161 crate::health_types::RecommendationCategory::AddTestCoverage,
3162 "untested risk",
3163 ),
3164 ];
3165 for (i, (cat, _label)) in categories.iter().enumerate() {
3166 report.targets.push(
3167 crate::health_types::RefactoringTarget {
3168 path: root.join(format!("src/cat{i}.ts")),
3169 priority: 50.0,
3170 efficiency: 50.0,
3171 recommendation: format!("Fix cat{i}"),
3172 category: cat.clone(),
3173 effort: crate::health_types::EffortEstimate::Low,
3174 confidence: crate::health_types::Confidence::High,
3175 factors: vec![],
3176 evidence: None,
3177 }
3178 .into(),
3179 );
3180 }
3181 let lines = build_health_human_lines(&report, &root);
3182 let text = plain(&lines);
3183 for (_cat, label) in &categories {
3184 assert!(
3185 text.contains(label),
3186 "Expected category label '{label}' in output"
3187 );
3188 }
3189 }
3190
3191 #[test]
3192 fn refactoring_targets_efficiency_color_thresholds() {
3193 let root = PathBuf::from("/project");
3194 let mut report = empty_report();
3195 report.targets = vec![
3196 crate::health_types::RefactoringTarget {
3197 path: root.join("src/high.ts"),
3198 priority: 50.0,
3199 efficiency: 50.0, recommendation: "High eff".to_string(),
3201 category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3202 effort: crate::health_types::EffortEstimate::Low,
3203 confidence: crate::health_types::Confidence::High,
3204 factors: vec![],
3205 evidence: None,
3206 }
3207 .into(),
3208 crate::health_types::RefactoringTarget {
3209 path: root.join("src/mid.ts"),
3210 priority: 50.0,
3211 efficiency: 25.0, recommendation: "Mid eff".to_string(),
3213 category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3214 effort: crate::health_types::EffortEstimate::Medium,
3215 confidence: crate::health_types::Confidence::Medium,
3216 factors: vec![],
3217 evidence: None,
3218 }
3219 .into(),
3220 crate::health_types::RefactoringTarget {
3221 path: root.join("src/low.ts"),
3222 priority: 50.0,
3223 efficiency: 10.0, recommendation: "Low eff".to_string(),
3225 category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3226 effort: crate::health_types::EffortEstimate::High,
3227 confidence: crate::health_types::Confidence::Low,
3228 factors: vec![],
3229 evidence: None,
3230 }
3231 .into(),
3232 ];
3233 let lines = build_health_human_lines(&report, &root);
3234 let text = plain(&lines);
3235 assert!(text.contains("50.0"));
3236 assert!(text.contains("25.0"));
3237 assert!(text.contains("10.0"));
3238 }
3239
3240 #[test]
3241 fn all_sections_combined() {
3242 let root = PathBuf::from("/project");
3243 let mut report = empty_report();
3244 report.summary.functions_above_threshold = 1;
3245 report.findings = vec![
3246 crate::health_types::ComplexityViolation {
3247 path: root.join("src/complex.ts"),
3248 name: "bigFn".to_string(),
3249 line: 10,
3250 col: 0,
3251 cyclomatic: 25,
3252 cognitive: 20,
3253 line_count: 80,
3254 param_count: 0,
3255 exceeded: crate::health_types::ExceededThreshold::Both,
3256 severity: crate::health_types::FindingSeverity::Moderate,
3257 crap: None,
3258 coverage_pct: None,
3259 coverage_tier: None,
3260 coverage_source: None,
3261 inherited_from: None,
3262 component_rollup: None,
3263 contributions: Vec::new(),
3264 }
3265 .into(),
3266 ];
3267 report.health_score = Some(crate::health_types::HealthScore {
3268 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
3269 score: 75.0,
3270 grade: "B",
3271 penalties: crate::health_types::HealthScorePenalties {
3272 dead_files: Some(5.0),
3273 dead_exports: Some(5.0),
3274 complexity: 5.0,
3275 p90_complexity: 2.0,
3276 maintainability: Some(3.0),
3277 hotspots: Some(2.0),
3278 unused_deps: Some(2.0),
3279 circular_deps: Some(1.0),
3280 unit_size: None,
3281 coupling: None,
3282 duplication: None,
3283 },
3284 });
3285 report.file_scores = vec![crate::health_types::FileHealthScore {
3286 path: root.join("src/complex.ts"),
3287 fan_in: 5,
3288 fan_out: 3,
3289 dead_code_ratio: 0.1,
3290 complexity_density: 0.5,
3291 maintainability_index: 60.0,
3292 total_cyclomatic: 15,
3293 total_cognitive: 10,
3294 function_count: 3,
3295 lines: 200,
3296 crap_max: 0.0,
3297 crap_above_threshold: 0,
3298 }];
3299 report.hotspots = vec![
3300 crate::health_types::HotspotEntry {
3301 path: root.join("src/complex.ts"),
3302 score: 65.0,
3303 commits: 20,
3304 weighted_commits: 15.0,
3305 lines_added: 300,
3306 lines_deleted: 100,
3307 complexity_density: 0.5,
3308 fan_in: 5,
3309 trend: fallow_core::churn::ChurnTrend::Accelerating,
3310 ownership: None,
3311 is_test_path: false,
3312 }
3313 .into(),
3314 ];
3315 report.targets = vec![
3316 crate::health_types::RefactoringTarget {
3317 path: root.join("src/complex.ts"),
3318 priority: 70.0,
3319 efficiency: 70.0,
3320 recommendation: "Extract complex functions".to_string(),
3321 category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3322 effort: crate::health_types::EffortEstimate::Low,
3323 confidence: crate::health_types::Confidence::High,
3324 factors: vec![],
3325 evidence: None,
3326 }
3327 .into(),
3328 ];
3329 let lines = build_health_human_lines(&report, &root);
3330 let text = plain(&lines);
3331 assert!(text.contains("Health score:"));
3332 assert!(text.contains("High complexity functions"));
3333 assert!(text.contains("File health scores"));
3334 assert!(text.contains("Hotspots"));
3335 assert!(text.contains("Refactoring targets"));
3336 }
3337
3338 #[test]
3339 fn completely_empty_report_produces_no_lines() {
3340 let root = PathBuf::from("/project");
3341 let report = empty_report();
3342 let lines = build_health_human_lines(&report, &root);
3343 assert!(lines.is_empty());
3344 }
3345
3346 #[test]
3347 fn finding_only_cyclomatic_exceeds() {
3348 let root = PathBuf::from("/project");
3349 let mut report = empty_report();
3350 report.summary.functions_above_threshold = 1;
3351 report.findings = vec![
3352 crate::health_types::ComplexityViolation {
3353 path: root.join("src/a.ts"),
3354 name: "fn1".to_string(),
3355 line: 1,
3356 col: 0,
3357 cyclomatic: 25, cognitive: 10, line_count: 50,
3360 param_count: 0,
3361 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
3362 severity: crate::health_types::FindingSeverity::Moderate,
3363 crap: None,
3364 coverage_pct: None,
3365 coverage_tier: None,
3366 coverage_source: None,
3367 inherited_from: None,
3368 component_rollup: None,
3369 contributions: Vec::new(),
3370 }
3371 .into(),
3372 ];
3373 let lines = build_health_human_lines(&report, &root);
3374 let text = plain(&lines);
3375 assert!(text.contains("25 cyclomatic"));
3376 assert!(text.contains("10 cognitive"));
3377 }
3378
3379 #[test]
3380 fn finding_only_cognitive_exceeds() {
3381 let root = PathBuf::from("/project");
3382 let mut report = empty_report();
3383 report.summary.functions_above_threshold = 1;
3384 report.findings = vec![
3385 crate::health_types::ComplexityViolation {
3386 path: root.join("src/a.ts"),
3387 name: "fn1".to_string(),
3388 line: 1,
3389 col: 0,
3390 cyclomatic: 10, cognitive: 25, line_count: 50,
3393 param_count: 0,
3394 exceeded: crate::health_types::ExceededThreshold::Cognitive,
3395 severity: crate::health_types::FindingSeverity::High,
3396 crap: None,
3397 coverage_pct: None,
3398 coverage_tier: None,
3399 coverage_source: None,
3400 inherited_from: None,
3401 component_rollup: None,
3402 contributions: Vec::new(),
3403 }
3404 .into(),
3405 ];
3406 let lines = build_health_human_lines(&report, &root);
3407 let text = plain(&lines);
3408 assert!(text.contains("10 cyclomatic"));
3409 assert!(text.contains("25 cognitive"));
3410 }
3411
3412 #[test]
3413 fn findings_across_multiple_files() {
3414 let root = PathBuf::from("/project");
3415 let mut report = empty_report();
3416 report.summary.functions_above_threshold = 2;
3417 report.findings = vec![
3418 crate::health_types::ComplexityViolation {
3419 path: root.join("src/a.ts"),
3420 name: "fn1".to_string(),
3421 line: 1,
3422 col: 0,
3423 cyclomatic: 25,
3424 cognitive: 20,
3425 line_count: 50,
3426 param_count: 0,
3427 exceeded: crate::health_types::ExceededThreshold::Both,
3428 severity: crate::health_types::FindingSeverity::Moderate,
3429 crap: None,
3430 coverage_pct: None,
3431 coverage_tier: None,
3432 coverage_source: None,
3433 inherited_from: None,
3434 component_rollup: None,
3435 contributions: Vec::new(),
3436 }
3437 .into(),
3438 crate::health_types::ComplexityViolation {
3439 path: root.join("src/b.ts"),
3440 name: "fn2".to_string(),
3441 line: 5,
3442 col: 0,
3443 cyclomatic: 22,
3444 cognitive: 18,
3445 line_count: 40,
3446 param_count: 0,
3447 exceeded: crate::health_types::ExceededThreshold::Both,
3448 severity: crate::health_types::FindingSeverity::Moderate,
3449 crap: None,
3450 coverage_pct: None,
3451 coverage_tier: None,
3452 coverage_source: None,
3453 inherited_from: None,
3454 component_rollup: None,
3455 contributions: Vec::new(),
3456 }
3457 .into(),
3458 ];
3459 let lines = build_health_human_lines(&report, &root);
3460 let text = plain(&lines);
3461 assert!(text.contains("src/a.ts"));
3462 assert!(text.contains("src/b.ts"));
3463 }
3464
3465 #[test]
3466 fn findings_docs_link() {
3467 let root = PathBuf::from("/project");
3468 let mut report = empty_report();
3469 report.summary.functions_above_threshold = 1;
3470 report.findings = vec![
3471 crate::health_types::ComplexityViolation {
3472 path: root.join("src/a.ts"),
3473 name: "fn1".to_string(),
3474 line: 1,
3475 col: 0,
3476 cyclomatic: 25,
3477 cognitive: 20,
3478 line_count: 50,
3479 param_count: 0,
3480 exceeded: crate::health_types::ExceededThreshold::Both,
3481 severity: crate::health_types::FindingSeverity::Moderate,
3482 crap: None,
3483 coverage_pct: None,
3484 coverage_tier: None,
3485 coverage_source: None,
3486 inherited_from: None,
3487 component_rollup: None,
3488 contributions: Vec::new(),
3489 }
3490 .into(),
3491 ];
3492 let lines = build_health_human_lines(&report, &root);
3493 let text = plain(&lines);
3494 assert!(text.contains("docs.fallow.tools/explanations/health#complexity-metrics"));
3495 }
3496
3497 #[test]
3498 fn hotspot_score_high_medium_low() {
3499 let root = PathBuf::from("/project");
3500 let mut report = empty_report();
3501 report.hotspots = vec![
3502 crate::health_types::HotspotEntry {
3503 path: root.join("src/high.ts"),
3504 score: 80.0, commits: 30,
3506 weighted_commits: 25.0,
3507 lines_added: 400,
3508 lines_deleted: 200,
3509 complexity_density: 0.9,
3510 fan_in: 8,
3511 trend: fallow_core::churn::ChurnTrend::Accelerating,
3512 ownership: None,
3513 is_test_path: false,
3514 }
3515 .into(),
3516 crate::health_types::HotspotEntry {
3517 path: root.join("src/medium.ts"),
3518 score: 45.0, commits: 15,
3520 weighted_commits: 10.0,
3521 lines_added: 200,
3522 lines_deleted: 100,
3523 complexity_density: 0.5,
3524 fan_in: 4,
3525 trend: fallow_core::churn::ChurnTrend::Stable,
3526 ownership: None,
3527 is_test_path: false,
3528 }
3529 .into(),
3530 crate::health_types::HotspotEntry {
3531 path: root.join("src/low.ts"),
3532 score: 15.0, commits: 5,
3534 weighted_commits: 3.0,
3535 lines_added: 50,
3536 lines_deleted: 20,
3537 complexity_density: 0.2,
3538 fan_in: 1,
3539 trend: fallow_core::churn::ChurnTrend::Cooling,
3540 ownership: None,
3541 is_test_path: false,
3542 }
3543 .into(),
3544 ];
3545 let lines = build_health_human_lines(&report, &root);
3546 let text = plain(&lines);
3547 assert!(text.contains("80.0"));
3548 assert!(text.contains("45.0"));
3549 assert!(text.contains("15.0"));
3550 assert!(text.contains("Hotspots (3 files)"));
3551 }
3552
3553 #[test]
3554 fn rollup_breakdown_renders_workspace_relative_template_path() {
3555 let root = PathBuf::from("/project");
3556 let template =
3557 root.join("apps/admin/src/app/payments/payment-list/payment-list.component.html");
3558 let finding = crate::health_types::ComplexityViolation {
3559 path: root.join("apps/admin/src/app/payments/payment-list/payment-list.component.ts"),
3560 name: "<component>".to_string(),
3561 line: 1,
3562 col: 0,
3563 cyclomatic: 25,
3564 cognitive: 28,
3565 line_count: 0,
3566 param_count: 0,
3567 exceeded: crate::health_types::ExceededThreshold::Both,
3568 severity: crate::health_types::FindingSeverity::High,
3569 crap: None,
3570 coverage_pct: None,
3571 coverage_tier: None,
3572 coverage_source: None,
3573 inherited_from: None,
3574 component_rollup: Some(crate::health_types::ComponentRollup {
3575 component: "PaymentListComponent".to_string(),
3576 class_worst_function: "ngOnInit".to_string(),
3577 class_cyclomatic: 12,
3578 class_cognitive: 16,
3579 template_path: template,
3580 template_cyclomatic: 13,
3581 template_cognitive: 12,
3582 }),
3583 contributions: Vec::new(),
3584 };
3585 let line = render_component_rollup_breakdown(&finding, &root)
3586 .expect("rollup payload should render a breakdown line");
3587 assert!(
3588 line.contains("apps/admin/src/app/payments/payment-list/payment-list.component.html"),
3589 "breakdown must include workspace-relative template path: {line}"
3590 );
3591 assert!(
3592 !line.contains(" payment-list.component.html"),
3593 "bare basename token must not be the rendered template: {line}"
3594 );
3595 }
3596
3597 #[test]
3598 fn inherited_from_renders_workspace_relative_owner_path() {
3599 let root = PathBuf::from("/project");
3600 let owner = root.join("apps/admin/src/app/auth/permissions/permissions.component.ts");
3601 let template_path =
3602 root.join("apps/admin/src/app/auth/permissions/permissions.component.html");
3603 let report = crate::health_types::HealthReport {
3604 findings: vec![
3605 crate::health_types::ComplexityViolation {
3606 path: template_path,
3607 name: "<template>".to_string(),
3608 line: 1,
3609 col: 0,
3610 cyclomatic: 12,
3611 cognitive: 14,
3612 line_count: 0,
3613 param_count: 0,
3614 exceeded: crate::health_types::ExceededThreshold::Both,
3615 severity: crate::health_types::FindingSeverity::High,
3616 crap: Some(45.0),
3617 coverage_pct: None,
3618 coverage_tier: Some(crate::health_types::CoverageTier::Partial),
3619 coverage_source: Some(
3620 crate::health_types::CoverageSource::EstimatedComponentInherited,
3621 ),
3622 inherited_from: Some(owner),
3623 component_rollup: None,
3624 contributions: Vec::new(),
3625 }
3626 .into(),
3627 ],
3628 summary: crate::health_types::HealthSummary {
3629 files_analyzed: 1,
3630 functions_analyzed: 1,
3631 functions_above_threshold: 1,
3632 ..Default::default()
3633 },
3634 ..Default::default()
3635 };
3636 let lines = build_health_human_lines(&report, &root);
3637 let text = plain(&lines);
3638 assert!(
3639 text.contains(
3640 "(inherited from apps/admin/src/app/auth/permissions/permissions.component.ts)"
3641 ),
3642 "inherited-from suffix must use workspace-relative path: {text}"
3643 );
3644 assert!(
3645 !text.contains("(inherited from permissions.component.ts)"),
3646 "bare basename suffix must not be rendered: {text}"
3647 );
3648 }
3649}