rumdl_lib/output/formatters/
github.rs

1//! GitHub Actions annotation format
2//!
3//! Outputs annotations in GitHub Actions workflow command format for PR annotations.
4//! See: <https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions>
5
6use crate::output::OutputFormatter;
7use crate::rule::{LintWarning, Severity};
8
9/// GitHub Actions formatter
10/// Outputs in the format: `::<level> file=<file>,line=<line>,col=<col>,endLine=<endLine>,endColumn=<endCol>,title=<rule>::<message>`
11pub struct GitHubFormatter;
12
13impl Default for GitHubFormatter {
14    fn default() -> Self {
15        Self
16    }
17}
18
19impl GitHubFormatter {
20    pub fn new() -> Self {
21        Self
22    }
23
24    /// Escape special characters according to GitHub Actions specification
25    /// Percent-encodes: %, \r, \n, :, ,
26    /// Used for property values (file, title, etc.)
27    fn escape_property(value: &str) -> String {
28        value
29            .replace('%', "%25")
30            .replace('\r', "%0D")
31            .replace('\n', "%0A")
32            .replace(':', "%3A")
33            .replace(',', "%2C")
34    }
35
36    /// Escape special characters in the message part
37    /// Percent-encodes: %, \r, \n
38    fn escape_message(value: &str) -> String {
39        value.replace('%', "%25").replace('\r', "%0D").replace('\n', "%0A")
40    }
41}
42
43impl OutputFormatter for GitHubFormatter {
44    fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
45        let mut output = String::new();
46
47        for warning in warnings {
48            let rule_name = warning.rule_name.unwrap_or("unknown");
49
50            // Map severity to GitHub Actions annotation level
51            let level = match warning.severity {
52                Severity::Error => "error",
53                Severity::Warning => "warning",
54            };
55
56            // Escape special characters in all properties
57            let escaped_file = Self::escape_property(file_path);
58            let escaped_rule = Self::escape_property(rule_name);
59            let escaped_message = Self::escape_message(&warning.message);
60
61            // GitHub Actions annotation format with optional end position
62            let line = if warning.end_line != warning.line || warning.end_column != warning.column {
63                // Include end position if different from start
64                format!(
65                    "::{} file={},line={},col={},endLine={},endColumn={},title={}::{}",
66                    level,
67                    escaped_file,
68                    warning.line,
69                    warning.column,
70                    warning.end_line,
71                    warning.end_column,
72                    escaped_rule,
73                    escaped_message
74                )
75            } else {
76                // Omit end position if same as start
77                format!(
78                    "::{} file={},line={},col={},title={}::{}",
79                    level, escaped_file, warning.line, warning.column, escaped_rule, escaped_message
80                )
81            };
82
83            output.push_str(&line);
84            output.push('\n');
85        }
86
87        // Remove trailing newline
88        if output.ends_with('\n') {
89            output.pop();
90        }
91
92        output
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::rule::{Fix, Severity};
100
101    #[test]
102    fn test_github_formatter_default() {
103        let _formatter = GitHubFormatter;
104        // No fields to test, just ensure it constructs
105    }
106
107    #[test]
108    fn test_github_formatter_new() {
109        let _formatter = GitHubFormatter::new();
110        // No fields to test, just ensure it constructs
111    }
112
113    #[test]
114    fn test_format_warnings_empty() {
115        let formatter = GitHubFormatter::new();
116        let warnings = vec![];
117        let output = formatter.format_warnings(&warnings, "test.md");
118        assert_eq!(output, "");
119    }
120
121    #[test]
122    fn test_format_single_warning() {
123        let formatter = GitHubFormatter::new();
124        let warnings = vec![LintWarning {
125            line: 10,
126            column: 5,
127            end_line: 10,
128            end_column: 15,
129            rule_name: Some("MD001"),
130            message: "Heading levels should only increment by one level at a time".to_string(),
131            severity: Severity::Warning,
132            fix: None,
133        }];
134
135        let output = formatter.format_warnings(&warnings, "README.md");
136        assert_eq!(
137            output,
138            "::warning file=README.md,line=10,col=5,endLine=10,endColumn=15,title=MD001::Heading levels should only increment by one level at a time"
139        );
140    }
141
142    #[test]
143    fn test_format_multiple_warnings() {
144        let formatter = GitHubFormatter::new();
145        let warnings = vec![
146            LintWarning {
147                line: 5,
148                column: 1,
149                end_line: 5,
150                end_column: 10,
151                rule_name: Some("MD001"),
152                message: "First warning".to_string(),
153                severity: Severity::Warning,
154                fix: None,
155            },
156            LintWarning {
157                line: 10,
158                column: 3,
159                end_line: 10,
160                end_column: 20,
161                rule_name: Some("MD013"),
162                message: "Second warning".to_string(),
163                severity: Severity::Error,
164                fix: None,
165            },
166        ];
167
168        let output = formatter.format_warnings(&warnings, "test.md");
169        let expected = "::warning file=test.md,line=5,col=1,endLine=5,endColumn=10,title=MD001::First warning\n::error file=test.md,line=10,col=3,endLine=10,endColumn=20,title=MD013::Second warning";
170        assert_eq!(output, expected);
171    }
172
173    #[test]
174    fn test_format_warning_with_fix() {
175        let formatter = GitHubFormatter::new();
176        let warnings = vec![LintWarning {
177            line: 15,
178            column: 1,
179            end_line: 15,
180            end_column: 10,
181            rule_name: Some("MD022"),
182            message: "Headings should be surrounded by blank lines".to_string(),
183            severity: Severity::Warning,
184            fix: Some(Fix {
185                range: 100..110,
186                replacement: "\n# Heading\n".to_string(),
187            }),
188        }];
189
190        let output = formatter.format_warnings(&warnings, "doc.md");
191        // GitHub format doesn't show fix indicator but includes end position
192        assert_eq!(
193            output,
194            "::warning file=doc.md,line=15,col=1,endLine=15,endColumn=10,title=MD022::Headings should be surrounded by blank lines"
195        );
196    }
197
198    #[test]
199    fn test_format_warning_unknown_rule() {
200        let formatter = GitHubFormatter::new();
201        let warnings = vec![LintWarning {
202            line: 1,
203            column: 1,
204            end_line: 1,
205            end_column: 5,
206            rule_name: None,
207            message: "Unknown rule warning".to_string(),
208            severity: Severity::Warning,
209            fix: None,
210        }];
211
212        let output = formatter.format_warnings(&warnings, "file.md");
213        assert_eq!(
214            output,
215            "::warning file=file.md,line=1,col=1,endLine=1,endColumn=5,title=unknown::Unknown rule warning"
216        );
217    }
218
219    #[test]
220    fn test_edge_cases() {
221        let formatter = GitHubFormatter::new();
222
223        // Test large line/column numbers
224        let warnings = vec![LintWarning {
225            line: 99999,
226            column: 12345,
227            end_line: 100000,
228            end_column: 12350,
229            rule_name: Some("MD999"),
230            message: "Edge case warning".to_string(),
231            severity: Severity::Error,
232            fix: None,
233        }];
234
235        let output = formatter.format_warnings(&warnings, "large.md");
236        assert_eq!(
237            output,
238            "::error file=large.md,line=99999,col=12345,endLine=100000,endColumn=12350,title=MD999::Edge case warning"
239        );
240    }
241
242    #[test]
243    fn test_special_characters_in_message() {
244        let formatter = GitHubFormatter::new();
245        let warnings = vec![LintWarning {
246            line: 1,
247            column: 1,
248            end_line: 1,
249            end_column: 5,
250            rule_name: Some("MD001"),
251            message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
252            severity: Severity::Warning,
253            fix: None,
254        }];
255
256        let output = formatter.format_warnings(&warnings, "test.md");
257        // Newline should be escaped as %0A
258        assert_eq!(
259            output,
260            "::warning file=test.md,line=1,col=1,endLine=1,endColumn=5,title=MD001::Warning with \"quotes\" and 'apostrophes' and %0A newline"
261        );
262    }
263
264    #[test]
265    fn test_percent_encoding() {
266        let formatter = GitHubFormatter::new();
267        let warnings = vec![LintWarning {
268            line: 1,
269            column: 1,
270            end_line: 1,
271            end_column: 1,
272            rule_name: Some("MD001"),
273            message: "100% complete\r\nNew line".to_string(),
274            severity: Severity::Warning,
275            fix: None,
276        }];
277
278        let output = formatter.format_warnings(&warnings, "test%.md");
279        // %, \r, and \n should be encoded
280        assert_eq!(
281            output,
282            "::warning file=test%25.md,line=1,col=1,title=MD001::100%25 complete%0D%0ANew line"
283        );
284    }
285
286    #[test]
287    fn test_special_characters_in_file_path() {
288        let formatter = GitHubFormatter::new();
289        let warnings = vec![LintWarning {
290            line: 1,
291            column: 1,
292            end_line: 1,
293            end_column: 5,
294            rule_name: Some("MD001"),
295            message: "Test".to_string(),
296            severity: Severity::Warning,
297            fix: None,
298        }];
299
300        let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
301        assert_eq!(
302            output,
303            "::warning file=path/with spaces/and-dashes.md,line=1,col=1,endLine=1,endColumn=5,title=MD001::Test"
304        );
305    }
306
307    #[test]
308    fn test_github_format_structure() {
309        let formatter = GitHubFormatter::new();
310        let warnings = vec![LintWarning {
311            line: 42,
312            column: 7,
313            end_line: 42,
314            end_column: 10,
315            rule_name: Some("MD010"),
316            message: "Hard tabs".to_string(),
317            severity: Severity::Warning,
318            fix: None,
319        }];
320
321        let output = formatter.format_warnings(&warnings, "test.md");
322
323        // Verify GitHub Actions annotation structure
324        assert!(output.starts_with("::warning "));
325        assert!(output.contains("file=test.md"));
326        assert!(output.contains("line=42"));
327        assert!(output.contains("col=7"));
328        assert!(output.contains("endLine=42"));
329        assert!(output.contains("endColumn=10"));
330        assert!(output.contains("title=MD010"));
331        assert!(output.ends_with("::Hard tabs"));
332    }
333
334    #[test]
335    fn test_severity_mapping() {
336        let formatter = GitHubFormatter::new();
337
338        // Test that severities are properly mapped
339        let warnings = vec![
340            LintWarning {
341                line: 1,
342                column: 1,
343                end_line: 1,
344                end_column: 5,
345                rule_name: Some("MD001"),
346                message: "Warning severity".to_string(),
347                severity: Severity::Warning,
348                fix: None,
349            },
350            LintWarning {
351                line: 2,
352                column: 1,
353                end_line: 2,
354                end_column: 5,
355                rule_name: Some("MD002"),
356                message: "Error severity".to_string(),
357                severity: Severity::Error,
358                fix: None,
359            },
360        ];
361
362        let output = formatter.format_warnings(&warnings, "test.md");
363        let lines: Vec<&str> = output.lines().collect();
364
365        // Warning should use ::warning, Error should use ::error
366        assert!(lines[0].starts_with("::warning "));
367        assert!(lines[1].starts_with("::error "));
368    }
369
370    #[test]
371    fn test_commas_in_parameters() {
372        let formatter = GitHubFormatter::new();
373
374        // Test that commas in the title and file path are properly escaped
375        let warnings = vec![LintWarning {
376            line: 1,
377            column: 1,
378            end_line: 1,
379            end_column: 5,
380            rule_name: Some("MD,001"), // Unlikely but test edge case
381            message: "Test message, with comma".to_string(),
382            severity: Severity::Warning,
383            fix: None,
384        }];
385
386        let output = formatter.format_warnings(&warnings, "file,with,commas.md");
387        // Commas in properties should be escaped as %2C
388        assert_eq!(
389            output,
390            "::warning file=file%2Cwith%2Ccommas.md,line=1,col=1,endLine=1,endColumn=5,title=MD%2C001::Test message, with comma"
391        );
392    }
393
394    #[test]
395    fn test_colons_in_parameters() {
396        let formatter = GitHubFormatter::new();
397
398        // Test that colons in file path and rule name are properly escaped
399        let warnings = vec![LintWarning {
400            line: 1,
401            column: 1,
402            end_line: 1,
403            end_column: 5,
404            rule_name: Some("MD:001"), // Unlikely but test edge case
405            message: "Test message: with colon".to_string(),
406            severity: Severity::Warning,
407            fix: None,
408        }];
409
410        let output = formatter.format_warnings(&warnings, "file:with:colons.md");
411        // Colons in properties should be escaped as %3A, but not in message
412        assert_eq!(
413            output,
414            "::warning file=file%3Awith%3Acolons.md,line=1,col=1,endLine=1,endColumn=5,title=MD%3A001::Test message: with colon"
415        );
416    }
417
418    #[test]
419    fn test_same_start_end_position() {
420        let formatter = GitHubFormatter::new();
421
422        // When start and end are the same, end position should be omitted
423        let warnings = vec![LintWarning {
424            line: 5,
425            column: 10,
426            end_line: 5,
427            end_column: 10,
428            rule_name: Some("MD001"),
429            message: "Single position warning".to_string(),
430            severity: Severity::Warning,
431            fix: None,
432        }];
433
434        let output = formatter.format_warnings(&warnings, "test.md");
435        // Should not include endLine and endColumn when they're the same as line and column
436        assert_eq!(
437            output,
438            "::warning file=test.md,line=5,col=10,title=MD001::Single position warning"
439        );
440    }
441
442    #[test]
443    fn test_error_severity() {
444        let formatter = GitHubFormatter::new();
445
446        let warnings = vec![LintWarning {
447            line: 1,
448            column: 1,
449            end_line: 1,
450            end_column: 5,
451            rule_name: Some("MD001"),
452            message: "Error level issue".to_string(),
453            severity: Severity::Error,
454            fix: None,
455        }];
456
457        let output = formatter.format_warnings(&warnings, "test.md");
458        assert_eq!(
459            output,
460            "::error file=test.md,line=1,col=1,endLine=1,endColumn=5,title=MD001::Error level issue"
461        );
462    }
463}