use std::path::Path;
use colored::Colorize;
use super::{plural, relative_path, split_dir_filename};
const DOCS_HEALTH: &str = "https://docs.fallow.tools/explanations/health";
fn render_ownership_summary(report: &crate::health_types::HealthReport) -> Option<String> {
if report.hotspots.len() < 2 {
return None;
}
let with_ownership: Vec<&crate::health_types::OwnershipMetrics> = report
.hotspots
.iter()
.filter_map(|h| h.ownership.as_ref())
.collect();
if with_ownership.is_empty() {
return None;
}
let total = with_ownership.len();
let bus1_count = with_ownership.iter().filter(|o| o.bus_factor == 1).count();
let mut tally: rustc_hash::FxHashMap<String, u32> = rustc_hash::FxHashMap::default();
for o in &with_ownership {
*tally
.entry(o.top_contributor.identifier.clone())
.or_insert(0) += 1;
}
let mut ranked: Vec<(String, u32)> = tally.into_iter().collect();
ranked.sort_by_key(|b| std::cmp::Reverse(b.1));
let top_authors: Vec<String> = ranked
.iter()
.take(3)
.map(|(id, n)| format!("{id} ({n})"))
.collect();
let mut segments: Vec<String> = Vec::new();
if bus1_count > 0 {
let label = if bus1_count == total {
format!("all {total} hotspots depend on a single recent contributor")
} else {
format!("{bus1_count}/{total} hotspots depend on a single recent contributor")
};
segments.push(label.red().bold().to_string());
}
if !top_authors.is_empty() {
segments.push(
format!("top authors: {}", top_authors.join(", "))
.dimmed()
.to_string(),
);
}
if segments.is_empty() {
None
} else {
Some(segments.join(" · "))
}
}
fn handle_matches_owner(identifier: &str, declared_owner: &str) -> bool {
let owner_handle = declared_owner.trim_start_matches('@');
if owner_handle.is_empty() || identifier.is_empty() {
return false;
}
let id_handle = identifier.split('@').next().unwrap_or(identifier);
let id_handle = id_handle.split('+').next_back().unwrap_or(id_handle);
id_handle.eq_ignore_ascii_case(owner_handle)
}
fn render_ownership_line(
ownership: &crate::health_types::OwnershipMetrics,
trend: fallow_core::churn::ChurnTrend,
) -> String {
let mut parts: Vec<String> = Vec::new();
let top_share = ownership.top_contributor.share;
let is_accelerating = matches!(trend, fallow_core::churn::ChurnTrend::Accelerating);
let is_extreme = top_share >= 0.9 || (ownership.bus_factor == 1 && is_accelerating);
let bus_str = if top_share >= 0.9999 {
format!("bus={} (sole author)", ownership.bus_factor)
} else if ownership.bus_factor <= 1 && is_extreme {
format!("bus={} (at risk)", ownership.bus_factor)
} else {
format!("bus={}", ownership.bus_factor)
};
let bus_colored = if is_extreme {
bus_str.red().bold().to_string()
} else if ownership.bus_factor <= 1 {
bus_str.yellow().to_string()
} else {
bus_str.dimmed().to_string()
};
parts.push(bus_colored);
let top = &ownership.top_contributor;
let collapsed = ownership
.declared_owner
.as_deref()
.filter(|owner| handle_matches_owner(&top.identifier, owner));
if let Some(owner) = collapsed {
parts.push(
format!(
"owned by {} ({:.0}%, declared {})",
top.identifier,
top.share * 100.0,
owner,
)
.dimmed()
.to_string(),
);
} else {
parts.push(
format!("top={} ({:.0}%)", top.identifier, top.share * 100.0)
.dimmed()
.to_string(),
);
if let Some(owner) = &ownership.declared_owner {
parts.push(format!("owner={owner}").dimmed().to_string());
}
}
if ownership.unowned == Some(true) {
parts.push("unowned".red().to_string());
}
if ownership.ownership_state == crate::health_types::OwnershipState::DeclaredInactive {
parts.push("declared owner inactive".yellow().to_string());
}
if ownership.drift {
parts.push("drift".yellow().to_string());
}
parts.join(" ")
}
pub(super) fn render_hotspots(
lines: &mut Vec<String>,
report: &crate::health_types::HealthReport,
root: &Path,
) {
if report.hotspots.is_empty() {
return;
}
let header = report.hotspot_summary.as_ref().map_or_else(
|| format!("Hotspots ({} files)", report.hotspots.len()),
|summary| {
format!(
"Hotspots ({} files, since {})",
report.hotspots.len(),
summary.since,
)
},
);
lines.push(format!("{} {}", "\u{25cf}".red(), header.red().bold()));
lines.push(String::new());
if let Some(summary_line) = render_ownership_summary(report) {
lines.push(format!(" {summary_line}"));
lines.push(String::new());
}
for entry in &report.hotspots {
let file_str = relative_path(&entry.path, root).display().to_string();
let score_str = format!("{:>5.1}", entry.score);
let score_colored = if entry.score >= 70.0 {
score_str.red().bold().to_string()
} else if entry.score >= 30.0 {
score_str.yellow().to_string()
} else {
score_str.green().to_string()
};
let (trend_symbol, trend_colored) = match entry.trend {
fallow_core::churn::ChurnTrend::Accelerating => {
("\u{25b2}", "\u{25b2} accelerating".red().to_string())
}
fallow_core::churn::ChurnTrend::Cooling => {
("\u{25bc}", "\u{25bc} cooling".green().to_string())
}
fallow_core::churn::ChurnTrend::Stable => {
("\u{2500}", "\u{2500} stable".dimmed().to_string())
}
};
let (dir, filename) = split_dir_filename(&file_str);
let test_tag = if entry.is_test_path {
format!(" {}", "[test]".dimmed())
} else {
String::new()
};
lines.push(format!(
" {} {} {}{}{}",
score_colored,
match entry.trend {
fallow_core::churn::ChurnTrend::Accelerating => trend_symbol.red().to_string(),
fallow_core::churn::ChurnTrend::Cooling => trend_symbol.green().to_string(),
fallow_core::churn::ChurnTrend::Stable => trend_symbol.dimmed().to_string(),
},
dir.dimmed(),
filename,
test_tag,
));
lines.push(format!(
" {} commits {} churn {} density {} fan-in {}",
format!("{:>3}", entry.commits).dimmed(),
format!("{:>5}", entry.lines_added + entry.lines_deleted).dimmed(),
format!("{:.2}", entry.complexity_density).dimmed(),
format!("{:>2}", entry.fan_in).dimmed(),
trend_colored,
));
if let Some(ownership) = &entry.ownership {
lines.push(format!(
" {}",
render_ownership_line(ownership, entry.trend)
));
}
lines.push(String::new());
}
if let Some(ref summary) = report.hotspot_summary
&& summary.files_excluded > 0
{
lines.push(format!(
" {}",
format!(
"{} file{} excluded (< {} commits)",
summary.files_excluded,
plural(summary.files_excluded),
summary.min_commits,
)
.dimmed()
));
lines.push(String::new());
}
let any_ownership = report.hotspots.iter().any(|h| h.ownership.is_some());
let no_codeowners_anywhere = report
.hotspots
.iter()
.filter_map(|h| h.ownership.as_ref())
.all(|o| o.unowned.is_none());
if any_ownership && no_codeowners_anywhere {
lines.push(format!(
" {}",
"No CODEOWNERS file discovered, ownership signals limited to change history.".dimmed()
));
}
lines.push(format!(
" {}",
format!("Files with high churn and high complexity: {DOCS_HEALTH}#hotspot-metrics")
.dimmed()
));
lines.push(String::new());
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use fallow_core::churn::ChurnTrend;
use super::super::plain;
use super::*;
use crate::health_types::{
ContributorEntry, ContributorIdentifierFormat, HealthReport, HotspotEntry, HotspotFinding,
HotspotSummary, OwnershipMetrics, OwnershipState,
};
fn contributor(identifier: &str, share: f64) -> ContributorEntry {
ContributorEntry {
identifier: identifier.to_owned(),
format: ContributorIdentifierFormat::Handle,
share,
stale_days: 0,
commits: 10,
}
}
fn ownership(
identifier: &str,
share: f64,
bus_factor: u32,
owner: Option<&str>,
state: OwnershipState,
) -> OwnershipMetrics {
OwnershipMetrics {
bus_factor,
contributor_count: 2,
top_contributor: contributor(identifier, share),
recent_contributors: vec![contributor(identifier, share)],
suggested_reviewers: Vec::new(),
declared_owner: owner.map(str::to_owned),
unowned: None,
ownership_state: state,
drift: matches!(state, OwnershipState::Drifting),
drift_reason: None,
}
}
fn hotspot(path: PathBuf, score: f64, trend: ChurnTrend) -> HotspotEntry {
HotspotEntry {
path,
score,
commits: 5,
weighted_commits: 5.0,
lines_added: 80,
lines_deleted: 20,
complexity_density: 0.42,
fan_in: 7,
trend,
ownership: None,
is_test_path: false,
}
}
#[test]
fn owner_matching_handles_email_plus_suffixes_and_empty_values() {
assert!(handle_matches_owner("alice@example.com", "@alice"));
assert!(handle_matches_owner("team+alice@example.com", "alice"));
assert!(handle_matches_owner("Alice", "@alice"));
assert!(!handle_matches_owner("", "@alice"));
assert!(!handle_matches_owner("alice@example.com", "@"));
assert!(!handle_matches_owner("bob@example.com", "@alice"));
}
#[test]
fn ownership_line_collapses_matching_owner_and_flags_risk() {
let matching = ownership(
"team+alice@example.com",
1.0,
1,
Some("@alice"),
OwnershipState::Active,
);
let line = plain(&[render_ownership_line(&matching, ChurnTrend::Accelerating)]);
assert!(line.contains("bus=1 (sole author)"));
assert!(line.contains("owned by team+alice@example.com (100%, declared @alice)"));
let mut drifting = ownership(
"bob@example.com",
0.75,
1,
Some("@alice"),
OwnershipState::Drifting,
);
drifting.unowned = Some(true);
let line = plain(&[render_ownership_line(&drifting, ChurnTrend::Stable)]);
assert!(line.contains("top=bob@example.com (75%)"));
assert!(line.contains("owner=@alice"));
assert!(line.contains("unowned"));
assert!(line.contains("drift"));
}
#[test]
fn hotspots_render_summary_ownership_and_exclusions() {
let root = PathBuf::from("/repo");
let mut first = hotspot(root.join("src/api.ts"), 75.0, ChurnTrend::Accelerating);
first.ownership = Some(ownership(
"alice@example.com",
0.91,
1,
None,
OwnershipState::Unowned,
));
let mut second = hotspot(root.join("tests/api.test.ts"), 20.0, ChurnTrend::Cooling);
second.is_test_path = true;
second.ownership = Some(ownership(
"alice@example.com",
0.70,
1,
None,
OwnershipState::DeclaredInactive,
));
let report = HealthReport {
hotspots: vec![HotspotFinding::from(first), HotspotFinding::from(second)],
hotspot_summary: Some(HotspotSummary {
since: "90d".to_owned(),
min_commits: 3,
files_analyzed: 10,
files_excluded: 1,
shallow_clone: false,
}),
..HealthReport::default()
};
let mut lines = Vec::new();
render_hotspots(&mut lines, &report, &root);
let text = plain(&lines);
assert!(text.contains("Hotspots (2 files, since 90d)"));
assert!(text.contains("all 2 hotspots depend on a single recent contributor"));
assert!(text.contains("top authors: alice@example.com (2)"));
assert!(text.contains("src/api.ts"));
assert!(text.contains("tests/api.test.ts [test]"));
assert!(text.contains("1 file excluded (< 3 commits)"));
assert!(text.contains("No CODEOWNERS file discovered"));
}
}