syncable_cli/analyzer/dclint/formatter/
mod.rs

1//! Output formatters for dclint results.
2//!
3//! Provides various output formats for lint results:
4//! - JSON - Machine-readable JSON output
5//! - Stylish - Colored terminal output (default)
6//! - Compact - Single line per issue
7//! - GitHub - GitHub Actions annotations
8//! - CodeClimate - CodeClimate format
9//! - JUnit - JUnit XML format
10
11pub mod github;
12pub mod json;
13pub mod stylish;
14
15use crate::analyzer::dclint::lint::LintResult;
16
17/// Output format for lint results.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum OutputFormat {
20    /// JSON format for machine processing
21    Json,
22    /// Stylish colored terminal output (default)
23    #[default]
24    Stylish,
25    /// Single line per issue
26    Compact,
27    /// GitHub Actions annotations
28    GitHub,
29    /// CodeClimate format
30    CodeClimate,
31    /// JUnit XML format
32    JUnit,
33}
34
35impl OutputFormat {
36    /// Parse from string (case-insensitive).
37    pub fn parse(s: &str) -> Option<Self> {
38        match s.to_lowercase().as_str() {
39            "json" => Some(Self::Json),
40            "stylish" => Some(Self::Stylish),
41            "compact" => Some(Self::Compact),
42            "github" | "github-actions" => Some(Self::GitHub),
43            "codeclimate" | "code-climate" => Some(Self::CodeClimate),
44            "junit" => Some(Self::JUnit),
45            _ => None,
46        }
47    }
48}
49
50/// Format lint results according to the specified format.
51pub fn format_results(results: &[LintResult], format: OutputFormat) -> String {
52    match format {
53        OutputFormat::Json => json::format(results),
54        OutputFormat::Stylish => stylish::format(results),
55        OutputFormat::Compact => format_compact(results),
56        OutputFormat::GitHub => github::format(results),
57        OutputFormat::CodeClimate => format_codeclimate(results),
58        OutputFormat::JUnit => format_junit(results),
59    }
60}
61
62/// Format a single result.
63pub fn format_result(result: &LintResult, format: OutputFormat) -> String {
64    format_results(std::slice::from_ref(result), format)
65}
66
67/// Format results as a string.
68pub fn format_result_to_string(result: &LintResult, format: OutputFormat) -> String {
69    format_result(result, format)
70}
71
72/// Compact format (one line per issue).
73fn format_compact(results: &[LintResult]) -> String {
74    let mut output = String::new();
75
76    for result in results {
77        for failure in &result.failures {
78            output.push_str(&format!(
79                "{}:{}:{}: {} [{}] {}\n",
80                result.file_path,
81                failure.line,
82                failure.column,
83                failure.severity,
84                failure.code,
85                failure.message
86            ));
87        }
88    }
89
90    output
91}
92
93/// CodeClimate format.
94fn format_codeclimate(results: &[LintResult]) -> String {
95    let mut issues = Vec::new();
96
97    for result in results {
98        for failure in &result.failures {
99            issues.push(serde_json::json!({
100                "type": "issue",
101                "check_name": failure.code.as_str(),
102                "description": failure.message,
103                "content": {
104                    "body": failure.message
105                },
106                "categories": [failure.category.as_str()],
107                "location": {
108                    "path": result.file_path,
109                    "lines": {
110                        "begin": failure.line,
111                        "end": failure.end_line.unwrap_or(failure.line)
112                    }
113                },
114                "severity": match failure.severity {
115                    crate::analyzer::dclint::types::Severity::Error => "critical",
116                    crate::analyzer::dclint::types::Severity::Warning => "major",
117                    crate::analyzer::dclint::types::Severity::Info => "minor",
118                    crate::analyzer::dclint::types::Severity::Style => "info",
119                },
120                "fingerprint": format!("{}-{}-{}", failure.code, result.file_path, failure.line)
121            }));
122        }
123    }
124
125    serde_json::to_string_pretty(&issues).unwrap_or_else(|_| "[]".to_string())
126}
127
128/// JUnit XML format.
129fn format_junit(results: &[LintResult]) -> String {
130    let mut output = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
131    output.push('\n');
132
133    let total_tests: usize = results.iter().map(|r| r.failures.len().max(1)).sum();
134    let total_failures: usize = results.iter().map(|r| r.failures.len()).sum();
135
136    output.push_str(&format!(
137        r#"<testsuite name="dclint" tests="{}" failures="{}">"#,
138        total_tests, total_failures
139    ));
140    output.push('\n');
141
142    for result in results {
143        if result.failures.is_empty() {
144            output.push_str(&format!(
145                r#"  <testcase name="{}" classname="dclint"/>"#,
146                escape_xml(&result.file_path)
147            ));
148            output.push('\n');
149        } else {
150            for failure in &result.failures {
151                output.push_str(&format!(
152                    r#"  <testcase name="{}:{}" classname="dclint.{}">"#,
153                    escape_xml(&result.file_path),
154                    failure.line,
155                    failure.code
156                ));
157                output.push('\n');
158                output.push_str(&format!(
159                    r#"    <failure message="{}" type="{}"/>"#,
160                    escape_xml(&failure.message),
161                    failure.severity
162                ));
163                output.push('\n');
164                output.push_str("  </testcase>\n");
165            }
166        }
167    }
168
169    output.push_str("</testsuite>\n");
170    output
171}
172
173/// Escape XML special characters.
174fn escape_xml(s: &str) -> String {
175    s.replace('&', "&amp;")
176        .replace('<', "&lt;")
177        .replace('>', "&gt;")
178        .replace('"', "&quot;")
179        .replace('\'', "&apos;")
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::analyzer::dclint::types::{CheckFailure, RuleCategory, Severity};
186
187    fn make_result() -> LintResult {
188        let mut result = LintResult::new("docker-compose.yml");
189        result.failures.push(CheckFailure::new(
190            "DCL001",
191            "no-build-and-image",
192            Severity::Error,
193            RuleCategory::BestPractice,
194            "Test message",
195            5,
196            1,
197        ));
198        result
199    }
200
201    #[test]
202    fn test_compact_format() {
203        let result = make_result();
204        let output = format_compact(&[result]);
205        assert!(output.contains("docker-compose.yml"));
206        assert!(output.contains("DCL001"));
207        assert!(output.contains("5:1"));
208    }
209
210    #[test]
211    fn test_junit_format() {
212        let result = make_result();
213        let output = format_junit(&[result]);
214        assert!(output.contains("<?xml"));
215        assert!(output.contains("testsuite"));
216        assert!(output.contains("DCL001"));
217    }
218
219    #[test]
220    fn test_output_format_from_str() {
221        assert_eq!(OutputFormat::parse("json"), Some(OutputFormat::Json));
222        assert_eq!(OutputFormat::parse("JSON"), Some(OutputFormat::Json));
223        assert_eq!(OutputFormat::parse("stylish"), Some(OutputFormat::Stylish));
224        assert_eq!(OutputFormat::parse("github"), Some(OutputFormat::GitHub));
225        assert_eq!(OutputFormat::parse("invalid"), None);
226    }
227}