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::new(100..110, "\n# Heading\n".to_string())),
186 }];
187
188 let output = formatter.format_warnings(&warnings, "doc.md");
189 assert_eq!(
191 output,
192 "::warning file=doc.md,line=15,col=1,endLine=15,endColumn=10,title=MD022::Headings should be surrounded by blank lines"
193 );
194 }
195
196 #[test]
197 fn test_format_warning_unknown_rule() {
198 let formatter = GitHubFormatter::new();
199 let warnings = vec![LintWarning {
200 line: 1,
201 column: 1,
202 end_line: 1,
203 end_column: 5,
204 rule_name: None,
205 message: "Unknown rule warning".to_string(),
206 severity: Severity::Warning,
207 fix: None,
208 }];
209
210 let output = formatter.format_warnings(&warnings, "file.md");
211 assert_eq!(
212 output,
213 "::warning file=file.md,line=1,col=1,endLine=1,endColumn=5,title=unknown::Unknown rule warning"
214 );
215 }
216
217 #[test]
218 fn test_edge_cases() {
219 let formatter = GitHubFormatter::new();
220
221 let warnings = vec![LintWarning {
223 line: 99999,
224 column: 12345,
225 end_line: 100000,
226 end_column: 12350,
227 rule_name: Some("MD999".to_string()),
228 message: "Edge case warning".to_string(),
229 severity: Severity::Error,
230 fix: None,
231 }];
232
233 let output = formatter.format_warnings(&warnings, "large.md");
234 assert_eq!(
235 output,
236 "::error file=large.md,line=99999,col=12345,endLine=100000,endColumn=12350,title=MD999::Edge case warning"
237 );
238 }
239
240 #[test]
241 fn test_special_characters_in_message() {
242 let formatter = GitHubFormatter::new();
243 let warnings = vec![LintWarning {
244 line: 1,
245 column: 1,
246 end_line: 1,
247 end_column: 5,
248 rule_name: Some("MD001".to_string()),
249 message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
250 severity: Severity::Warning,
251 fix: None,
252 }];
253
254 let output = formatter.format_warnings(&warnings, "test.md");
255 assert_eq!(
257 output,
258 "::warning file=test.md,line=1,col=1,endLine=1,endColumn=5,title=MD001::Warning with \"quotes\" and 'apostrophes' and %0A newline"
259 );
260 }
261
262 #[test]
263 fn test_percent_encoding() {
264 let formatter = GitHubFormatter::new();
265 let warnings = vec![LintWarning {
266 line: 1,
267 column: 1,
268 end_line: 1,
269 end_column: 1,
270 rule_name: Some("MD001".to_string()),
271 message: "100% complete\r\nNew line".to_string(),
272 severity: Severity::Warning,
273 fix: None,
274 }];
275
276 let output = formatter.format_warnings(&warnings, "test%.md");
277 assert_eq!(
279 output,
280 "::warning file=test%25.md,line=1,col=1,title=MD001::100%25 complete%0D%0ANew line"
281 );
282 }
283
284 #[test]
285 fn test_special_characters_in_file_path() {
286 let formatter = GitHubFormatter::new();
287 let warnings = vec![LintWarning {
288 line: 1,
289 column: 1,
290 end_line: 1,
291 end_column: 5,
292 rule_name: Some("MD001".to_string()),
293 message: "Test".to_string(),
294 severity: Severity::Warning,
295 fix: None,
296 }];
297
298 let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
299 assert_eq!(
300 output,
301 "::warning file=path/with spaces/and-dashes.md,line=1,col=1,endLine=1,endColumn=5,title=MD001::Test"
302 );
303 }
304
305 #[test]
306 fn test_github_format_structure() {
307 let formatter = GitHubFormatter::new();
308 let warnings = vec![LintWarning {
309 line: 42,
310 column: 7,
311 end_line: 42,
312 end_column: 10,
313 rule_name: Some("MD010".to_string()),
314 message: "Hard tabs".to_string(),
315 severity: Severity::Warning,
316 fix: None,
317 }];
318
319 let output = formatter.format_warnings(&warnings, "test.md");
320
321 assert!(output.starts_with("::warning "));
323 assert!(output.contains("file=test.md"));
324 assert!(output.contains("line=42"));
325 assert!(output.contains("col=7"));
326 assert!(output.contains("endLine=42"));
327 assert!(output.contains("endColumn=10"));
328 assert!(output.contains("title=MD010"));
329 assert!(output.ends_with("::Hard tabs"));
330 }
331
332 #[test]
333 fn test_severity_mapping() {
334 let formatter = GitHubFormatter::new();
335
336 let warnings = vec![
338 LintWarning {
339 line: 1,
340 column: 1,
341 end_line: 1,
342 end_column: 5,
343 rule_name: Some("MD001".to_string()),
344 message: "Warning severity".to_string(),
345 severity: Severity::Warning,
346 fix: None,
347 },
348 LintWarning {
349 line: 2,
350 column: 1,
351 end_line: 2,
352 end_column: 5,
353 rule_name: Some("MD002".to_string()),
354 message: "Error severity".to_string(),
355 severity: Severity::Error,
356 fix: None,
357 },
358 ];
359
360 let output = formatter.format_warnings(&warnings, "test.md");
361 let lines: Vec<&str> = output.lines().collect();
362
363 assert!(lines[0].starts_with("::warning "));
365 assert!(lines[1].starts_with("::error "));
366 }
367
368 #[test]
369 fn test_commas_in_parameters() {
370 let formatter = GitHubFormatter::new();
371
372 let warnings = vec![LintWarning {
374 line: 1,
375 column: 1,
376 end_line: 1,
377 end_column: 5,
378 rule_name: Some("MD,001".to_string()), message: "Test message, with comma".to_string(),
380 severity: Severity::Warning,
381 fix: None,
382 }];
383
384 let output = formatter.format_warnings(&warnings, "file,with,commas.md");
385 assert_eq!(
387 output,
388 "::warning file=file%2Cwith%2Ccommas.md,line=1,col=1,endLine=1,endColumn=5,title=MD%2C001::Test message, with comma"
389 );
390 }
391
392 #[test]
393 fn test_colons_in_parameters() {
394 let formatter = GitHubFormatter::new();
395
396 let warnings = vec![LintWarning {
398 line: 1,
399 column: 1,
400 end_line: 1,
401 end_column: 5,
402 rule_name: Some("MD:001".to_string()), message: "Test message: with colon".to_string(),
404 severity: Severity::Warning,
405 fix: None,
406 }];
407
408 let output = formatter.format_warnings(&warnings, "file:with:colons.md");
409 assert_eq!(
411 output,
412 "::warning file=file%3Awith%3Acolons.md,line=1,col=1,endLine=1,endColumn=5,title=MD%3A001::Test message: with colon"
413 );
414 }
415
416 #[test]
417 fn test_same_start_end_position() {
418 let formatter = GitHubFormatter::new();
419
420 let warnings = vec![LintWarning {
422 line: 5,
423 column: 10,
424 end_line: 5,
425 end_column: 10,
426 rule_name: Some("MD001".to_string()),
427 message: "Single position warning".to_string(),
428 severity: Severity::Warning,
429 fix: None,
430 }];
431
432 let output = formatter.format_warnings(&warnings, "test.md");
433 assert_eq!(
435 output,
436 "::warning file=test.md,line=5,col=10,title=MD001::Single position warning"
437 );
438 }
439
440 #[test]
441 fn test_error_severity() {
442 let formatter = GitHubFormatter::new();
443
444 let warnings = vec![LintWarning {
445 line: 1,
446 column: 1,
447 end_line: 1,
448 end_column: 5,
449 rule_name: Some("MD001".to_string()),
450 message: "Error level issue".to_string(),
451 severity: Severity::Error,
452 fix: None,
453 }];
454
455 let output = formatter.format_warnings(&warnings, "test.md");
456 assert_eq!(
457 output,
458 "::error file=test.md,line=1,col=1,endLine=1,endColumn=5,title=MD001::Error level issue"
459 );
460 }
461}