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 };
55
56 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 let line = if warning.end_line != warning.line || warning.end_column != warning.column {
63 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 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 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 }
106
107 #[test]
108 fn test_github_formatter_new() {
109 let _formatter = GitHubFormatter::new();
110 }
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".to_string()),
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".to_string()),
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".to_string()),
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".to_string()),
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 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 let warnings = vec![LintWarning {
225 line: 99999,
226 column: 12345,
227 end_line: 100000,
228 end_column: 12350,
229 rule_name: Some("MD999".to_string()),
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".to_string()),
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 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".to_string()),
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 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".to_string()),
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".to_string()),
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 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 let warnings = vec![
340 LintWarning {
341 line: 1,
342 column: 1,
343 end_line: 1,
344 end_column: 5,
345 rule_name: Some("MD001".to_string()),
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".to_string()),
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 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 let warnings = vec![LintWarning {
376 line: 1,
377 column: 1,
378 end_line: 1,
379 end_column: 5,
380 rule_name: Some("MD,001".to_string()), 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 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 let warnings = vec![LintWarning {
400 line: 1,
401 column: 1,
402 end_line: 1,
403 end_column: 5,
404 rule_name: Some("MD:001".to_string()), 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 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 let warnings = vec![LintWarning {
424 line: 5,
425 column: 10,
426 end_line: 5,
427 end_column: 10,
428 rule_name: Some("MD001".to_string()),
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 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".to_string()),
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}