syncable_cli/analyzer/dclint/formatter/
github.rs

1//! GitHub Actions output formatter for dclint.
2//!
3//! Produces output in GitHub Actions workflow command format:
4//! ::error file={name},line={line},col={col}::{message}
5
6use crate::analyzer::dclint::lint::LintResult;
7use crate::analyzer::dclint::types::Severity;
8
9/// Format lint results for GitHub Actions.
10pub fn format(results: &[LintResult]) -> String {
11    let mut output = String::new();
12
13    for result in results {
14        // Parse errors
15        for err in &result.parse_errors {
16            output.push_str(&format!(
17                "::error file={}::Parse error: {}\n",
18                result.file_path,
19                escape_github(err)
20            ));
21        }
22
23        // Failures
24        for failure in &result.failures {
25            let level = match failure.severity {
26                Severity::Error => "error",
27                Severity::Warning => "warning",
28                Severity::Info | Severity::Style => "notice",
29            };
30
31            output.push_str(&format!(
32                "::{} file={},line={},col={},title={}::{}\n",
33                level,
34                result.file_path,
35                failure.line,
36                failure.column,
37                failure.code,
38                escape_github(&failure.message)
39            ));
40        }
41    }
42
43    output
44}
45
46/// Escape special characters for GitHub Actions.
47fn escape_github(s: &str) -> String {
48    s.replace('%', "%25")
49        .replace('\r', "%0D")
50        .replace('\n', "%0A")
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use crate::analyzer::dclint::types::{CheckFailure, RuleCategory};
57
58    #[test]
59    fn test_github_format() {
60        let mut result = LintResult::new("docker-compose.yml");
61        result.failures.push(CheckFailure::new(
62            "DCL001",
63            "no-build-and-image",
64            Severity::Error,
65            RuleCategory::BestPractice,
66            "Service has both build and image",
67            5,
68            1,
69        ));
70
71        let output = format(&[result]);
72        assert!(output.contains("::error"));
73        assert!(output.contains("file=docker-compose.yml"));
74        assert!(output.contains("line=5"));
75        assert!(output.contains("col=1"));
76        assert!(output.contains("title=DCL001"));
77    }
78
79    #[test]
80    fn test_github_format_warning() {
81        let mut result = LintResult::new("docker-compose.yml");
82        result.failures.push(CheckFailure::new(
83            "DCL006",
84            "no-version-field",
85            Severity::Warning,
86            RuleCategory::Style,
87            "Version field is deprecated",
88            1,
89            1,
90        ));
91
92        let output = format(&[result]);
93        assert!(output.contains("::warning"));
94    }
95
96    #[test]
97    fn test_github_format_info() {
98        let mut result = LintResult::new("docker-compose.yml");
99        result.failures.push(CheckFailure::new(
100            "DCL007",
101            "require-project-name",
102            Severity::Info,
103            RuleCategory::BestPractice,
104            "Consider adding name field",
105            1,
106            1,
107        ));
108
109        let output = format(&[result]);
110        assert!(output.contains("::notice"));
111    }
112
113    #[test]
114    fn test_escape_github() {
115        assert_eq!(escape_github("hello\nworld"), "hello%0Aworld");
116        assert_eq!(escape_github("100%"), "100%25");
117    }
118}