1use crate::output::OutputFormatter;
7use crate::rule::{LintWarning, Severity};
8
9pub 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 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 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 let level = match warning.severity {
52 Severity::Error => "error",
53 Severity::Warning => "warning",
54 Severity::Info => "notice",
55 };
56
57 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 let line = if warning.end_line != warning.line || warning.end_column != warning.column {
64 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 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 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 }
107
108 #[test]
109 fn test_github_formatter_new() {
110 let _formatter = GitHubFormatter::new();
111 }
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 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 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 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 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 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 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 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 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()), 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 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 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()), 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 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 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 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}