koala-drift 1.0.4

Wiki ↔ code drift detector.
Documentation
//! Plain-text rendering for `koala-core drift` output. The CLI is a
//! thin wrapper over `format_report`; tests can exercise the rendering
//! without spawning a subprocess.

use crate::check::{Finding, Severity};
use crate::registry::Registry;

const HINT_INDENT: &str = "        ▶ hint: ";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
    /// Human-friendly multi-line text.
    Text,
    /// `::error file=PATH,line=N::message` for GitHub Actions annotations.
    GithubAnnotations,
}

/// Render every finding as a GitHub Actions annotation. One line per
/// finding: `::error file=PATH,line=N::[check_id] kind — claim`.
/// Advisory findings use `::warning` so PRs don't turn red on them.
pub fn format_github_annotations(findings: &[Finding]) -> String {
    let mut out = String::new();
    for f in findings {
        let level = match f.severity {
            Severity::Hard => "error",
            Severity::Advisory => "warning",
        };
        out.push_str(&format!(
            "::{level} file={file},line={line}::[{check}] {kind}{claim}\n",
            file = f.file.display(),
            line = f.line.max(1),
            check = f.check_id,
            kind = f.kind.short(),
            claim = collapse_newlines(&truncate(&f.claim, 200)),
        ));
    }
    out
}

fn collapse_newlines(s: &str) -> String {
    s.replace(['\n', '\r'], " ").trim().to_string()
}

pub fn format_report(reg: &Registry, findings: &[Finding], explain: bool) -> String {
    let mut out = String::new();
    if findings.is_empty() {
        out.push_str(&format!(
            "drift: no findings ({} check(s) ran)\n",
            reg.len()
        ));
        return out;
    }

    let mut last_file: Option<&std::path::Path> = None;
    let mut hard = 0usize;
    let mut advisory = 0usize;
    for f in findings {
        match f.severity {
            Severity::Hard => hard += 1,
            Severity::Advisory => advisory += 1,
        }
        if last_file != Some(f.file.as_path()) {
            out.push('\n');
            out.push_str(&format!("{} {}\n", f.severity.label(), f.file.display()));
            last_file = Some(f.file.as_path());
        }
        out.push_str(&format!(
            "    line {}: [{}] {}\n",
            f.line,
            f.check_id,
            f.kind.short()
        ));
        if explain {
            let intent = reg
                .checks()
                .find(|c| c.id() == f.check_id)
                .map(|c| c.intent())
                .unwrap_or("");
            out.push_str(&format!("        intent: {intent}\n"));
            out.push_str(&format!("        claim : {}\n", f.claim));
        } else {
            out.push_str(&format!("        claim : {}\n", truncate(&f.claim, 120)));
        }
        if let Some(hint) = &f.fix_hint {
            out.push_str(&format!("{HINT_INDENT}{hint}\n"));
        }
    }
    out.push_str(&format!(
        "\nsummary: {hard} hard, {advisory} advisory ({} check(s) ran)\n",
        reg.len()
    ));
    out
}

fn truncate(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        return s.to_string();
    }
    let cut: String = s.chars().take(max).collect();
    format!("{cut}")
}