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