syncable_cli/analyzer/helmlint/formatter/
github.rs

1//! GitHub Actions formatter for helmlint results.
2//!
3//! Produces GitHub Actions workflow command annotations.
4//! See: https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions
5
6use crate::analyzer::helmlint::lint::LintResult;
7use crate::analyzer::helmlint::types::Severity;
8
9/// Format a lint result as GitHub Actions annotations.
10pub fn format(result: &LintResult) -> String {
11    let mut output = String::new();
12
13    // Output parse errors as errors
14    for error in &result.parse_errors {
15        output.push_str(&format!(
16            "::error file={},title=Parse Error::{}\n",
17            result.chart_path, error
18        ));
19    }
20
21    // Output failures as annotations
22    for failure in &result.failures {
23        let level = match failure.severity {
24            Severity::Error => "error",
25            Severity::Warning => "warning",
26            Severity::Info => "notice",
27            Severity::Style => "notice",
28            Severity::Ignore => continue, // Skip ignored
29        };
30
31        let file = failure.file.display().to_string();
32        let line = failure.line;
33        let title = &failure.code;
34        let message = escape_message(&failure.message);
35
36        // Format: ::level file=path,line=N,col=N,title=TITLE::MESSAGE
37        let annotation = match failure.column {
38            Some(col) => format!(
39                "::{}file={},line={},col={},title={}::{}\n",
40                level, file, line, col, title, message
41            ),
42            None => format!(
43                "::{}file={},line={},title={}::{}\n",
44                level, file, line, title, message
45            ),
46        };
47
48        output.push_str(&annotation);
49    }
50
51    // Summary annotation
52    if !result.failures.is_empty() || !result.parse_errors.is_empty() {
53        let total = result.failures.len() + result.parse_errors.len();
54        let summary = format!(
55            "Helmlint found {} {} ({} errors, {} warnings)",
56            total,
57            if total == 1 { "issue" } else { "issues" },
58            result.error_count + result.parse_errors.len(),
59            result.warning_count
60        );
61
62        if result.error_count > 0 || !result.parse_errors.is_empty() {
63            output.push_str(&format!("::error::{}\n", summary));
64        } else {
65            output.push_str(&format!("::warning::{}\n", summary));
66        }
67    }
68
69    output
70}
71
72/// Escape a message for GitHub Actions annotation format.
73/// GitHub Actions uses % encoding for special characters.
74fn escape_message(message: &str) -> String {
75    message
76        .replace('%', "%25")
77        .replace('\r', "%0D")
78        .replace('\n', "%0A")
79        .replace(':', "%3A")
80        .replace(',', "%2C")
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use crate::analyzer::helmlint::types::{CheckFailure, RuleCategory, Severity};
87
88    #[test]
89    fn test_github_format_empty() {
90        let result = LintResult::new("test-chart");
91        let output = format(&result);
92        assert!(output.is_empty());
93    }
94
95    #[test]
96    fn test_github_format_error() {
97        let mut result = LintResult::new("test-chart");
98        result.failures.push(CheckFailure::new(
99            "HL1001",
100            Severity::Error,
101            "Missing Chart.yaml",
102            "Chart.yaml",
103            1,
104            RuleCategory::Structure,
105        ));
106        result.error_count = 1;
107
108        let output = format(&result);
109        assert!(output.contains("::error"));
110        assert!(output.contains("file=Chart.yaml"));
111        assert!(output.contains("line=1"));
112        assert!(output.contains("title=HL1001"));
113    }
114
115    #[test]
116    fn test_github_format_warning() {
117        let mut result = LintResult::new("test-chart");
118        result.failures.push(CheckFailure::new(
119            "HL1006",
120            Severity::Warning,
121            "Missing description",
122            "Chart.yaml",
123            5,
124            RuleCategory::Structure,
125        ));
126        result.warning_count = 1;
127
128        let output = format(&result);
129        assert!(output.contains("::warning"));
130    }
131
132    #[test]
133    fn test_escape_message() {
134        assert_eq!(escape_message("hello:world"), "hello%3Aworld");
135        assert_eq!(escape_message("a,b"), "a%2Cb");
136        assert_eq!(escape_message("line1\nline2"), "line1%0Aline2");
137    }
138}