1use crate::rule::LintWarning;
7use std::io::{self, Write};
8use std::str::FromStr;
9
10pub mod formatters;
11
12pub use formatters::*;
14
15pub trait OutputFormatter {
17 fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String;
19
20 fn format_warnings_with_content(&self, warnings: &[LintWarning], file_path: &str, _content: &str) -> String {
24 self.format_warnings(warnings, file_path)
25 }
26
27 fn format_summary(&self, _files_processed: usize, _total_warnings: usize, _duration_ms: u64) -> Option<String> {
29 None
31 }
32
33 fn use_colors(&self) -> bool {
35 false
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq)]
41pub enum OutputFormat {
42 Text,
44 Full,
46 Concise,
48 Grouped,
50 Json,
52 JsonLines,
54 GitHub,
56 GitLab,
58 Pylint,
60 Azure,
62 Sarif,
64 Junit,
66}
67
68impl FromStr for OutputFormat {
69 type Err = String;
70
71 fn from_str(s: &str) -> Result<Self, Self::Err> {
72 match s.to_lowercase().as_str() {
73 "text" => Ok(OutputFormat::Text),
74 "full" => Ok(OutputFormat::Full),
75 "concise" => Ok(OutputFormat::Concise),
76 "grouped" => Ok(OutputFormat::Grouped),
77 "json" => Ok(OutputFormat::Json),
78 "json-lines" | "jsonlines" => Ok(OutputFormat::JsonLines),
79 "github" => Ok(OutputFormat::GitHub),
80 "gitlab" => Ok(OutputFormat::GitLab),
81 "pylint" => Ok(OutputFormat::Pylint),
82 "azure" => Ok(OutputFormat::Azure),
83 "sarif" => Ok(OutputFormat::Sarif),
84 "junit" => Ok(OutputFormat::Junit),
85 _ => Err(format!("Unknown output format: {s}")),
86 }
87 }
88}
89
90impl OutputFormat {
91 pub fn is_machine_readable(&self) -> bool {
94 !matches!(
95 self,
96 OutputFormat::Text | OutputFormat::Full | OutputFormat::Concise | OutputFormat::Grouped
97 )
98 }
99
100 pub fn is_batch(&self) -> bool {
104 matches!(
105 self,
106 OutputFormat::Json | OutputFormat::GitLab | OutputFormat::Sarif | OutputFormat::Junit
107 )
108 }
109
110 pub fn needs_all_files(&self) -> bool {
113 matches!(self, OutputFormat::Junit)
114 }
115
116 pub fn format_batch(
123 &self,
124 file_warnings: &[(String, Vec<LintWarning>)],
125 all_files: &[String],
126 duration_ms: u64,
127 ) -> Option<String> {
128 match self {
129 OutputFormat::Json => Some(formatters::json::format_all_warnings_as_json(file_warnings)),
130 OutputFormat::GitLab => Some(formatters::gitlab::format_gitlab_report(file_warnings)),
131 OutputFormat::Sarif => Some(formatters::sarif::format_sarif_report(file_warnings)),
132 OutputFormat::Junit => Some(formatters::junit::format_junit_report(
133 file_warnings,
134 all_files,
135 duration_ms,
136 )),
137 _ => None,
138 }
139 }
140
141 pub fn create_formatter(&self) -> Box<dyn OutputFormatter> {
143 match self {
144 OutputFormat::Text => Box::new(TextFormatter::new()),
145 OutputFormat::Full => Box::new(FullFormatter::new()),
146 OutputFormat::Concise => Box::new(ConciseFormatter::new()),
147 OutputFormat::Grouped => Box::new(GroupedFormatter::new()),
148 OutputFormat::Json => Box::new(JsonFormatter::new()),
149 OutputFormat::JsonLines => Box::new(JsonLinesFormatter::new()),
150 OutputFormat::GitHub => Box::new(GitHubFormatter::new()),
151 OutputFormat::GitLab => Box::new(GitLabFormatter::new()),
152 OutputFormat::Pylint => Box::new(PylintFormatter::new()),
153 OutputFormat::Azure => Box::new(AzureFormatter::new()),
154 OutputFormat::Sarif => Box::new(SarifFormatter::new()),
155 OutputFormat::Junit => Box::new(JunitFormatter::new()),
156 }
157 }
158}
159
160pub struct OutputWriter {
162 use_stderr: bool,
163 silent: bool,
164}
165
166impl OutputWriter {
167 pub fn new(use_stderr: bool, silent: bool) -> Self {
168 Self { use_stderr, silent }
169 }
170
171 pub fn write(&self, content: &str) -> io::Result<()> {
173 if self.silent {
174 return Ok(());
175 }
176
177 if self.use_stderr {
178 eprint!("{content}");
179 io::stderr().flush()?;
180 } else {
181 print!("{content}");
182 io::stdout().flush()?;
183 }
184 Ok(())
185 }
186
187 pub fn writeln(&self, content: &str) -> io::Result<()> {
189 if self.silent {
190 return Ok(());
191 }
192
193 if self.use_stderr {
194 eprintln!("{content}");
195 } else {
196 println!("{content}");
197 }
198 Ok(())
199 }
200
201 pub fn write_error(&self, content: &str) -> io::Result<()> {
203 if self.silent {
204 return Ok(());
205 }
206
207 eprintln!("{content}");
208 Ok(())
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use crate::rule::{Fix, Severity};
216
217 fn create_test_warning(line: usize, message: &str) -> LintWarning {
218 LintWarning {
219 line,
220 column: 5,
221 end_line: line,
222 end_column: 10,
223 rule_name: Some("MD001".to_string()),
224 message: message.to_string(),
225 severity: Severity::Warning,
226 fix: None,
227 }
228 }
229
230 fn create_test_warning_with_fix(line: usize, message: &str, fix_text: &str) -> LintWarning {
231 LintWarning {
232 line,
233 column: 5,
234 end_line: line,
235 end_column: 10,
236 rule_name: Some("MD001".to_string()),
237 message: message.to_string(),
238 severity: Severity::Warning,
239 fix: Some(Fix::new(0..5, fix_text.to_string())),
240 }
241 }
242
243 #[test]
244 fn test_output_format_from_str() {
245 assert_eq!(OutputFormat::from_str("text").unwrap(), OutputFormat::Text);
247 assert_eq!(OutputFormat::from_str("full").unwrap(), OutputFormat::Full);
248 assert_eq!(OutputFormat::from_str("concise").unwrap(), OutputFormat::Concise);
249 assert_eq!(OutputFormat::from_str("grouped").unwrap(), OutputFormat::Grouped);
250 assert_eq!(OutputFormat::from_str("json").unwrap(), OutputFormat::Json);
251 assert_eq!(OutputFormat::from_str("json-lines").unwrap(), OutputFormat::JsonLines);
252 assert_eq!(OutputFormat::from_str("jsonlines").unwrap(), OutputFormat::JsonLines);
253 assert_eq!(OutputFormat::from_str("github").unwrap(), OutputFormat::GitHub);
254 assert_eq!(OutputFormat::from_str("gitlab").unwrap(), OutputFormat::GitLab);
255 assert_eq!(OutputFormat::from_str("pylint").unwrap(), OutputFormat::Pylint);
256 assert_eq!(OutputFormat::from_str("azure").unwrap(), OutputFormat::Azure);
257 assert_eq!(OutputFormat::from_str("sarif").unwrap(), OutputFormat::Sarif);
258 assert_eq!(OutputFormat::from_str("junit").unwrap(), OutputFormat::Junit);
259
260 assert_eq!(OutputFormat::from_str("TEXT").unwrap(), OutputFormat::Text);
262 assert_eq!(OutputFormat::from_str("GitHub").unwrap(), OutputFormat::GitHub);
263 assert_eq!(OutputFormat::from_str("JSON-LINES").unwrap(), OutputFormat::JsonLines);
264
265 assert!(OutputFormat::from_str("invalid").is_err());
267 assert!(OutputFormat::from_str("").is_err());
268 assert!(OutputFormat::from_str("xml").is_err());
269 }
270
271 #[test]
272 fn test_output_format_create_formatter() {
273 let formats = [
275 OutputFormat::Text,
276 OutputFormat::Full,
277 OutputFormat::Concise,
278 OutputFormat::Grouped,
279 OutputFormat::Json,
280 OutputFormat::JsonLines,
281 OutputFormat::GitHub,
282 OutputFormat::GitLab,
283 OutputFormat::Pylint,
284 OutputFormat::Azure,
285 OutputFormat::Sarif,
286 OutputFormat::Junit,
287 ];
288
289 for format in &formats {
290 let formatter = format.create_formatter();
291 let warnings = vec![create_test_warning(1, "Test warning")];
293 let output = formatter.format_warnings(&warnings, "test.md");
294 assert!(!output.is_empty(), "Formatter {format:?} should produce output");
295 }
296 }
297
298 #[test]
299 fn test_output_writer_new() {
300 let writer1 = OutputWriter::new(false, false);
301 assert!(!writer1.use_stderr);
302 assert!(!writer1.silent);
303
304 let writer2 = OutputWriter::new(true, false);
305 assert!(writer2.use_stderr);
306 assert!(!writer2.silent);
307
308 let writer3 = OutputWriter::new(false, true);
309 assert!(!writer3.use_stderr);
310 assert!(writer3.silent);
311 }
312
313 #[test]
314 fn test_output_writer_silent_mode() {
315 let writer = OutputWriter::new(false, true);
316
317 assert!(writer.write("test").is_ok());
319 assert!(writer.writeln("test").is_ok());
320 assert!(writer.write_error("test").is_ok());
321 }
322
323 #[test]
324 fn test_output_writer_write_methods() {
325 let writer = OutputWriter::new(false, false);
327
328 assert!(writer.write("test").is_ok());
330 assert!(writer.writeln("test line").is_ok());
331 assert!(writer.write_error("error message").is_ok());
332 }
333
334 #[test]
335 fn test_output_writer_stderr_mode() {
336 let writer = OutputWriter::new(true, false);
337
338 assert!(writer.write("stderr test").is_ok());
340 assert!(writer.writeln("stderr line").is_ok());
341
342 assert!(writer.write_error("error").is_ok());
344 }
345
346 #[test]
347 fn test_formatter_trait_default_summary() {
348 struct TestFormatter;
350 impl OutputFormatter for TestFormatter {
351 fn format_warnings(&self, _warnings: &[LintWarning], _file_path: &str) -> String {
352 "test".to_string()
353 }
354 }
355
356 let formatter = TestFormatter;
357 assert_eq!(formatter.format_summary(10, 5, 1000), None);
358 assert!(!formatter.use_colors());
359 }
360
361 #[test]
362 fn test_formatter_with_multiple_warnings() {
363 let warnings = vec![
364 create_test_warning(1, "First warning"),
365 create_test_warning(5, "Second warning"),
366 create_test_warning_with_fix(10, "Third warning with fix", "fixed content"),
367 ];
368
369 let text_formatter = TextFormatter::new();
371 let output = text_formatter.format_warnings(&warnings, "test.md");
372 assert!(output.contains("First warning"));
373 assert!(output.contains("Second warning"));
374 assert!(output.contains("Third warning with fix"));
375 }
376
377 #[test]
378 fn test_edge_cases() {
379 let empty_warnings: Vec<LintWarning> = vec![];
381 let formatter = TextFormatter::new();
382 let output = formatter.format_warnings(&empty_warnings, "test.md");
383 assert!(output.is_empty() || output.trim().is_empty());
385
386 let long_path = "a/".repeat(100) + "file.md";
388 let warnings = vec![create_test_warning(1, "Test")];
389 let output = formatter.format_warnings(&warnings, &long_path);
390 assert!(!output.is_empty());
391
392 let unicode_warning = LintWarning {
394 line: 1,
395 column: 1,
396 end_line: 1,
397 end_column: 10,
398 rule_name: Some("MD001".to_string()),
399 message: "Unicode test: 你好 🌟 émphasis".to_string(),
400 severity: Severity::Warning,
401 fix: None,
402 };
403 let output = formatter.format_warnings(&[unicode_warning], "test.md");
404 assert!(output.contains("Unicode test"));
405 }
406
407 #[test]
408 fn test_severity_variations() {
409 let severities = [Severity::Error, Severity::Warning, Severity::Info];
410
411 for severity in &severities {
412 let warning = LintWarning {
413 line: 1,
414 column: 1,
415 end_line: 1,
416 end_column: 5,
417 rule_name: Some("MD001".to_string()),
418 message: format!(
419 "Test {} message",
420 match severity {
421 Severity::Error => "error",
422 Severity::Warning => "warning",
423 Severity::Info => "info",
424 }
425 ),
426 severity: *severity,
427 fix: None,
428 };
429
430 let formatter = TextFormatter::new();
431 let output = formatter.format_warnings(&[warning], "test.md");
432 assert!(!output.is_empty());
433 }
434 }
435
436 #[test]
437 fn test_output_format_equality() {
438 assert_eq!(OutputFormat::Text, OutputFormat::Text);
439 assert_ne!(OutputFormat::Text, OutputFormat::Json);
440 assert_ne!(OutputFormat::Concise, OutputFormat::Grouped);
441 }
442
443 #[test]
444 fn test_all_formats_handle_no_rule_name() {
445 let warning = LintWarning {
446 line: 1,
447 column: 1,
448 end_line: 1,
449 end_column: 5,
450 rule_name: None, message: "Generic warning".to_string(),
452 severity: Severity::Warning,
453 fix: None,
454 };
455
456 let formats = [
457 OutputFormat::Text,
458 OutputFormat::Full,
459 OutputFormat::Concise,
460 OutputFormat::Grouped,
461 OutputFormat::Json,
462 OutputFormat::JsonLines,
463 OutputFormat::GitHub,
464 OutputFormat::GitLab,
465 OutputFormat::Pylint,
466 OutputFormat::Azure,
467 OutputFormat::Sarif,
468 OutputFormat::Junit,
469 ];
470
471 for format in &formats {
472 let formatter = format.create_formatter();
473 let output = formatter.format_warnings(std::slice::from_ref(&warning), "test.md");
474 assert!(
475 !output.is_empty(),
476 "Format {format:?} should handle warnings without rule names"
477 );
478 }
479 }
480
481 #[test]
482 fn test_batch_seam() {
483 let batch = [
484 OutputFormat::Json,
485 OutputFormat::GitLab,
486 OutputFormat::Sarif,
487 OutputFormat::Junit,
488 ];
489 let streaming = [
490 OutputFormat::Text,
491 OutputFormat::Full,
492 OutputFormat::Concise,
493 OutputFormat::Grouped,
494 OutputFormat::JsonLines,
495 OutputFormat::GitHub,
496 OutputFormat::Pylint,
497 OutputFormat::Azure,
498 ];
499
500 let file_warnings = vec![("dirty.md".to_string(), vec![create_test_warning(1, "w")])];
501 let all_files = vec!["dirty.md".to_string(), "clean.md".to_string()];
502
503 for format in &batch {
504 assert!(format.is_batch(), "{format:?} is a batch format");
505 let output = format
506 .format_batch(&file_warnings, &all_files, 5)
507 .unwrap_or_else(|| panic!("{format:?} must format a batch"));
508 assert!(!output.is_empty());
509 }
510 for format in &streaming {
511 assert!(!format.is_batch(), "{format:?} is a streaming format");
512 assert!(
513 format.format_batch(&file_warnings, &all_files, 5).is_none(),
514 "{format:?} must not claim batch output"
515 );
516 }
517
518 for format in batch.iter().chain(&streaming) {
520 assert_eq!(format.needs_all_files(), *format == OutputFormat::Junit);
521 }
522 let junit = OutputFormat::Junit.format_batch(&file_warnings, &all_files, 5).unwrap();
523 assert!(junit.contains("clean.md"), "JUnit batch output reports passing files");
524 }
525
526 #[test]
527 fn test_is_machine_readable() {
528 assert!(!OutputFormat::Text.is_machine_readable());
530 assert!(!OutputFormat::Full.is_machine_readable());
531 assert!(!OutputFormat::Concise.is_machine_readable());
532 assert!(!OutputFormat::Grouped.is_machine_readable());
533
534 assert!(OutputFormat::Json.is_machine_readable());
536 assert!(OutputFormat::JsonLines.is_machine_readable());
537 assert!(OutputFormat::GitHub.is_machine_readable());
538 assert!(OutputFormat::GitLab.is_machine_readable());
539 assert!(OutputFormat::Pylint.is_machine_readable());
540 assert!(OutputFormat::Azure.is_machine_readable());
541 assert!(OutputFormat::Sarif.is_machine_readable());
542 assert!(OutputFormat::Junit.is_machine_readable());
543 }
544}