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