Skip to main content

koala_drift/
format.rs

1//! Plain-text rendering for `koala-core drift` output. The CLI is a
2//! thin wrapper over `format_report`; tests can exercise the rendering
3//! without spawning a subprocess.
4
5use crate::check::{Finding, Severity};
6use crate::registry::Registry;
7
8const HINT_INDENT: &str = "        ▶ hint: ";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum OutputFormat {
12    /// Human-friendly multi-line text.
13    Text,
14    /// `::error file=PATH,line=N::message` for GitHub Actions annotations.
15    GithubAnnotations,
16}
17
18/// Render every finding as a GitHub Actions annotation. One line per
19/// finding: `::error file=PATH,line=N::[check_id] kind — claim`.
20/// Advisory findings use `::warning` so PRs don't turn red on them.
21pub fn format_github_annotations(findings: &[Finding]) -> String {
22    let mut out = String::new();
23    for f in findings {
24        let level = match f.severity {
25            Severity::Hard => "error",
26            Severity::Advisory => "warning",
27        };
28        out.push_str(&format!(
29            "::{level} file={file},line={line}::[{check}] {kind} — {claim}\n",
30            file = f.file.display(),
31            line = f.line.max(1),
32            check = f.check_id,
33            kind = f.kind.short(),
34            claim = collapse_newlines(&truncate(&f.claim, 200)),
35        ));
36    }
37    out
38}
39
40fn collapse_newlines(s: &str) -> String {
41    s.replace(['\n', '\r'], " ").trim().to_string()
42}
43
44pub fn format_report(reg: &Registry, findings: &[Finding], explain: bool) -> String {
45    let mut out = String::new();
46    if findings.is_empty() {
47        out.push_str(&format!(
48            "drift: no findings ({} check(s) ran)\n",
49            reg.len()
50        ));
51        return out;
52    }
53
54    let mut last_file: Option<&std::path::Path> = None;
55    let mut hard = 0usize;
56    let mut advisory = 0usize;
57    for f in findings {
58        match f.severity {
59            Severity::Hard => hard += 1,
60            Severity::Advisory => advisory += 1,
61        }
62        if last_file != Some(f.file.as_path()) {
63            out.push('\n');
64            out.push_str(&format!("{} {}\n", f.severity.label(), f.file.display()));
65            last_file = Some(f.file.as_path());
66        }
67        out.push_str(&format!(
68            "    line {}: [{}] {}\n",
69            f.line,
70            f.check_id,
71            f.kind.short()
72        ));
73        if explain {
74            let intent = reg
75                .checks()
76                .find(|c| c.id() == f.check_id)
77                .map(|c| c.intent())
78                .unwrap_or("");
79            out.push_str(&format!("        intent: {intent}\n"));
80            out.push_str(&format!("        claim : {}\n", f.claim));
81        } else {
82            out.push_str(&format!("        claim : {}\n", truncate(&f.claim, 120)));
83        }
84        if let Some(hint) = &f.fix_hint {
85            out.push_str(&format!("{HINT_INDENT}{hint}\n"));
86        }
87    }
88    out.push_str(&format!(
89        "\nsummary: {hard} hard, {advisory} advisory ({} check(s) ran)\n",
90        reg.len()
91    ));
92    out
93}
94
95fn truncate(s: &str, max: usize) -> String {
96    if s.chars().count() <= max {
97        return s.to_string();
98    }
99    let cut: String = s.chars().take(max).collect();
100    format!("{cut}…")
101}