fallow-cli 2.91.0

CLI for fallow, Rust-native codebase intelligence for TypeScript and JavaScript
Documentation
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());
}