svccat 1.1.0

Detect drift between your declared service catalog and what actually lives in the repo.
Documentation
use crate::drift::{DriftItem, DriftKind, DriftReport, Severity};
use crate::manifest::Manifest;
use crate::ping::{PingResult, PingStatus};
use colored::Colorize;
use std::collections::HashSet;

pub fn render_check(report: &DriftReport, ping_results: &[PingResult]) {
    let errors = report.error_count();
    let warnings = report.warning_count();

    println!(
        "{}",
        format!(
            "svccat: {} declared, {} discovered  [{}]",
            report.declared, report.discovered, report.manifest
        )
        .bold()
    );
    println!();

    if report.drifts.is_empty() {
        println!("{}", "  OK  No drift detected".green().bold());
    } else {
        println!(
            "{}",
            format!(
                "  DRIFT DETECTED  ({} error{}, {} warning{})",
                errors,
                if errors == 1 { "" } else { "s" },
                warnings,
                if warnings == 1 { "" } else { "s" }
            )
            .yellow()
            .bold()
        );
        println!();

        for item in &report.drifts {
            print_drift_item(item);
        }

        println!();

        if errors > 0 {
            println!("{}", format!("  x  {} error(s)", errors).red().bold());
        }
        if warnings > 0 {
            println!("{}", format!("  !  {} warning(s)", warnings).yellow());
        }
    }

    if !ping_results.is_empty() {
        println!();
        render_ping_results(ping_results);
    }
}

/// Renders only the drift that is new or resolved compared to `old_report`.
/// Returns `(new_count, resolved_count)`.
pub fn render_since_diff(
    old_report: &DriftReport,
    new_report: &DriftReport,
    git_ref: &str,
) -> (usize, usize) {
    let old_keys: HashSet<String> = old_report.drifts.iter().map(drift_key).collect();
    let new_keys: HashSet<String> = new_report.drifts.iter().map(drift_key).collect();

    let added: Vec<&DriftItem> = new_report
        .drifts
        .iter()
        .filter(|d| !old_keys.contains(&drift_key(d)))
        .collect();
    let resolved: Vec<&DriftItem> = old_report
        .drifts
        .iter()
        .filter(|d| !new_keys.contains(&drift_key(d)))
        .collect();
    let unchanged = new_report.drifts.len().saturating_sub(added.len());

    println!(
        "{}",
        format!("svccat --since {}  [{}]", git_ref, new_report.manifest).bold()
    );
    println!();

    if added.is_empty() && resolved.is_empty() {
        println!(
            "{}",
            format!(
                "  OK  No change in drift since {}  ({} existing item{})",
                git_ref,
                unchanged,
                if unchanged == 1 { "" } else { "s" }
            )
            .green()
            .bold()
        );
        return (0, 0);
    }

    if !added.is_empty() {
        println!(
            "{}",
            format!("  NEW drift since {} ({}):", git_ref, added.len())
                .yellow()
                .bold()
        );
        println!();
        for item in &added {
            print_drift_item(item);
        }
        println!();
    }

    if !resolved.is_empty() {
        println!(
            "{}",
            format!("  RESOLVED since {} ({}):", git_ref, resolved.len())
                .green()
                .bold()
        );
        println!();
        for item in &resolved {
            print_resolved_drift_item(item);
        }
        println!();
    }

    if unchanged > 0 {
        println!(
            "  {} existing drift item{} unchanged",
            unchanged,
            if unchanged == 1 { "" } else { "s" }
        );
    }

    (added.len(), resolved.len())
}

pub fn render_ping_results(results: &[PingResult]) {
    println!("{}", "  PING RESULTS".bold());
    println!();
    for r in results {
        match &r.ping {
            PingStatus::Reachable { code } if *code < 400 => {
                println!(
                    "  {}  {}  {} ({})",
                    "".green().bold(),
                    "[PING]".green(),
                    r.service,
                    code
                );
            }
            PingStatus::Reachable { code } => {
                println!(
                    "  {}  {}  {}  {} ({})",
                    "!".yellow().bold(),
                    "[PING]".yellow(),
                    r.service,
                    r.url,
                    code
                );
            }
            PingStatus::Unreachable { reason } => {
                println!(
                    "  {}  {}  {}  {}{}",
                    "x".red().bold(),
                    "[PING]".red(),
                    r.service,
                    r.url,
                    reason
                );
            }
            PingStatus::Invalid { reason } => {
                println!(
                    "  {}  {}  {}  {} — invalid URL: {}",
                    "".yellow().bold(),
                    "[PING]".yellow(),
                    r.service,
                    r.url,
                    reason
                );
            }
        }
    }
}

fn print_drift_item(item: &DriftItem) {
    let icon = match item.severity {
        Severity::Error => "x".red().bold(),
        Severity::Warning => "!".yellow().bold(),
    };

    let kind_label = drift_kind_label(&item.kind);
    println!("  {}  {}  {}", icon, kind_label, item.message);
}

fn print_resolved_drift_item(item: &DriftItem) {
    let kind_label = drift_kind_label(&item.kind);
    println!("  {}  {}  {}", "".green().bold(), kind_label, item.message);
}

fn drift_kind_label(kind: &DriftKind) -> colored::ColoredString {
    match kind {
        DriftKind::DeclaredMissingFromRepo => "[MISSING]   ".red(),
        DriftKind::UndeclaredInRepo => "[UNDECLARED]".yellow(),
        DriftKind::MissingField => "[FIELD]     ".cyan(),
        DriftKind::MissingReferencedFile => "[REF]       ".yellow(),
        DriftKind::PolicyViolation => "[POLICY]    ".red(),
        DriftKind::DanglingDependency => "[DEPENDS]   ".red(),
        DriftKind::CircularDependency => "[CYCLE]     ".red(),
    }
}

/// Stable identity key for a drift item (used by --since diffing).
fn drift_key(item: &DriftItem) -> String {
    format!(
        "{:?}|{}|{}",
        item.kind,
        item.service,
        item.detail.as_deref().unwrap_or("")
    )
}

// ── Compact renderer ──────────────────────────────────────────────────────────

/// One-line-per-service summary. Shows a status icon + name + first drift kind
/// for every declared service, plus a footer count. Undeclared services get a
/// single `[UNDECLARED]` line. Ideal for large repos where the full terminal
/// view is too noisy.
pub fn render_compact(manifest: &Manifest, report: &DriftReport) {
    println!(
        "{}",
        format!(
            "svccat: {} declared, {} discovered  [{}]",
            report.declared, report.discovered, report.manifest
        )
        .bold()
    );
    println!();

    // Index drift items by service name for O(1) lookup.
    let mut drift_by_service: std::collections::HashMap<&str, Vec<&DriftItem>> =
        std::collections::HashMap::new();
    let mut undeclared: Vec<&DriftItem> = Vec::new();

    for item in &report.drifts {
        if item.kind == DriftKind::UndeclaredInRepo {
            undeclared.push(item);
        } else {
            drift_by_service
                .entry(item.service.as_str())
                .or_default()
                .push(item);
        }
    }

    let mut ok = 0usize;
    let mut errors = 0usize;
    let mut warnings = 0usize;

    for svc in &manifest.services {
        let drifts = drift_by_service.get(svc.name.as_str());
        match drifts {
            None => {
                println!("  {}  {}", "".green().bold(), svc.name);
                ok += 1;
            }
            Some(items) => {
                let worst = items
                    .iter()
                    .find(|d| d.severity == Severity::Error)
                    .or_else(|| items.first());
                if let Some(d) = worst {
                    let (icon, label) = match d.severity {
                        Severity::Error => {
                            errors += 1;
                            ("".red().bold(), drift_kind_label(&d.kind))
                        }
                        Severity::Warning => {
                            warnings += 1;
                            ("!".yellow().bold(), drift_kind_label(&d.kind))
                        }
                    };
                    let extra = if items.len() > 1 {
                        format!(" (+{})", items.len() - 1)
                    } else {
                        String::new()
                    };
                    println!("  {}  {}  {}{}", icon, label, svc.name, extra);
                }
            }
        }
    }

    for item in &undeclared {
        println!(
            "  {}  {}  {}",
            "!".yellow().bold(),
            drift_kind_label(&item.kind),
            item.service,
        );
        warnings += 1;
    }

    println!();
    if errors == 0 && warnings == 0 {
        println!(
            "{}",
            format!("  {} ok, 0 errors, 0 warnings", ok).green().bold()
        );
    } else {
        println!(
            "  {} ok  {}  {}",
            ok,
            format!("{} error(s)", errors).red().bold(),
            format!("{} warning(s)", warnings).yellow()
        );
    }
}