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 carries_diff(&self) -> bool {
115 !self.is_batch() && !matches!(self, OutputFormat::JsonLines)
116 }
117
118 pub fn needs_all_files(&self) -> bool {
121 matches!(self, OutputFormat::Junit)
122 }
123
124 pub fn format_batch_with_invocation(
127 &self,
128 file_warnings: &[(String, Vec<LintWarning>)],
129 all_files: &[String],
130 duration_ms: u64,
131 invocation_error: Option<&str>,
132 ) -> Option<String> {
133 if *self == OutputFormat::Sarif {
134 Some(formatters::sarif::format_sarif_report_with_invocation(
135 file_warnings,
136 invocation_error,
137 ))
138 } else {
139 self.format_batch(file_warnings, all_files, duration_ms)
140 }
141 }
142
143 pub fn format_batch(
150 &self,
151 file_warnings: &[(String, Vec<LintWarning>)],
152 all_files: &[String],
153 duration_ms: u64,
154 ) -> Option<String> {
155 match self {
156 OutputFormat::Json => Some(formatters::json::format_all_warnings_as_json(file_warnings)),
157 OutputFormat::GitLab => Some(formatters::gitlab::format_gitlab_report(file_warnings)),
158 OutputFormat::Sarif => Some(formatters::sarif::format_sarif_report(file_warnings)),
159 OutputFormat::Junit => Some(formatters::junit::format_junit_report(
160 file_warnings,
161 all_files,
162 duration_ms,
163 )),
164 _ => None,
165 }
166 }
167
168 pub fn create_formatter(&self) -> Box<dyn OutputFormatter> {
170 match self {
171 OutputFormat::Text => Box::new(TextFormatter::new()),
172 OutputFormat::Full => Box::new(FullFormatter::new()),
173 OutputFormat::Concise => Box::new(ConciseFormatter::new()),
174 OutputFormat::Grouped => Box::new(GroupedFormatter::new()),
175 OutputFormat::Json => Box::new(JsonFormatter::new()),
176 OutputFormat::JsonLines => Box::new(JsonLinesFormatter::new()),
177 OutputFormat::GitHub => Box::new(GitHubFormatter::new()),
178 OutputFormat::GitLab => Box::new(GitLabFormatter::new()),
179 OutputFormat::Pylint => Box::new(PylintFormatter::new()),
180 OutputFormat::Azure => Box::new(AzureFormatter::new()),
181 OutputFormat::Sarif => Box::new(SarifFormatter::new()),
182 OutputFormat::Junit => Box::new(JunitFormatter::new()),
183 }
184 }
185}
186
187pub struct OutputWriter {
189 use_stderr: bool,
190 silent: bool,
191}
192
193impl OutputWriter {
194 pub fn new(use_stderr: bool, silent: bool) -> Self {
195 Self { use_stderr, silent }
196 }
197
198 pub fn write(&self, content: &str) -> io::Result<()> {
200 if self.silent {
201 return Ok(());
202 }
203
204 if self.use_stderr {
205 eprint!("{content}");
206 io::stderr().flush()?;
207 } else {
208 print!("{content}");
209 io::stdout().flush()?;
210 }
211 Ok(())
212 }
213
214 pub fn writeln(&self, content: &str) -> io::Result<()> {
216 if self.silent {
217 return Ok(());
218 }
219
220 if self.use_stderr {
221 eprintln!("{content}");
222 } else {
223 println!("{content}");
224 }
225 Ok(())
226 }
227
228 pub fn write_error(&self, content: &str) -> io::Result<()> {
230 if self.silent {
231 return Ok(());
232 }
233
234 eprintln!("{content}");
235 Ok(())
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::rule::{Fix, Severity};
243
244 fn create_test_warning(line: usize, message: &str) -> LintWarning {
245 LintWarning {
246 line,
247 column: 5,
248 end_line: line,
249 end_column: 10,
250 rule_name: Some("MD001".to_string()),
251 message: message.to_string(),
252 severity: Severity::Warning,
253 fix: None,
254 }
255 }
256
257 fn create_test_warning_with_fix(line: usize, message: &str, fix_text: &str) -> LintWarning {
258 LintWarning {
259 line,
260 column: 5,
261 end_line: line,
262 end_column: 10,
263 rule_name: Some("MD001".to_string()),
264 message: message.to_string(),
265 severity: Severity::Warning,
266 fix: Some(Fix::new(0..5, fix_text.to_string())),
267 }
268 }
269
270 #[test]
271 fn test_output_format_from_str() {
272 assert_eq!(OutputFormat::from_str("text").unwrap(), OutputFormat::Text);
274 assert_eq!(OutputFormat::from_str("full").unwrap(), OutputFormat::Full);
275 assert_eq!(OutputFormat::from_str("concise").unwrap(), OutputFormat::Concise);
276 assert_eq!(OutputFormat::from_str("grouped").unwrap(), OutputFormat::Grouped);
277 assert_eq!(OutputFormat::from_str("json").unwrap(), OutputFormat::Json);
278 assert_eq!(OutputFormat::from_str("json-lines").unwrap(), OutputFormat::JsonLines);
279 assert_eq!(OutputFormat::from_str("jsonlines").unwrap(), OutputFormat::JsonLines);
280 assert_eq!(OutputFormat::from_str("github").unwrap(), OutputFormat::GitHub);
281 assert_eq!(OutputFormat::from_str("gitlab").unwrap(), OutputFormat::GitLab);
282 assert_eq!(OutputFormat::from_str("pylint").unwrap(), OutputFormat::Pylint);
283 assert_eq!(OutputFormat::from_str("azure").unwrap(), OutputFormat::Azure);
284 assert_eq!(OutputFormat::from_str("sarif").unwrap(), OutputFormat::Sarif);
285 assert_eq!(OutputFormat::from_str("junit").unwrap(), OutputFormat::Junit);
286
287 assert_eq!(OutputFormat::from_str("TEXT").unwrap(), OutputFormat::Text);
289 assert_eq!(OutputFormat::from_str("GitHub").unwrap(), OutputFormat::GitHub);
290 assert_eq!(OutputFormat::from_str("JSON-LINES").unwrap(), OutputFormat::JsonLines);
291
292 assert!(OutputFormat::from_str("invalid").is_err());
294 assert!(OutputFormat::from_str("").is_err());
295 assert!(OutputFormat::from_str("xml").is_err());
296 }
297
298 #[test]
299 fn test_output_format_create_formatter() {
300 let formats = [
302 OutputFormat::Text,
303 OutputFormat::Full,
304 OutputFormat::Concise,
305 OutputFormat::Grouped,
306 OutputFormat::Json,
307 OutputFormat::JsonLines,
308 OutputFormat::GitHub,
309 OutputFormat::GitLab,
310 OutputFormat::Pylint,
311 OutputFormat::Azure,
312 OutputFormat::Sarif,
313 OutputFormat::Junit,
314 ];
315
316 for format in &formats {
317 let formatter = format.create_formatter();
318 let warnings = vec![create_test_warning(1, "Test warning")];
320 let output = formatter.format_warnings(&warnings, "test.md");
321 assert!(!output.is_empty(), "Formatter {format:?} should produce output");
322 }
323 }
324
325 #[test]
326 fn test_output_writer_new() {
327 let writer1 = OutputWriter::new(false, false);
328 assert!(!writer1.use_stderr);
329 assert!(!writer1.silent);
330
331 let writer2 = OutputWriter::new(true, false);
332 assert!(writer2.use_stderr);
333 assert!(!writer2.silent);
334
335 let writer3 = OutputWriter::new(false, true);
336 assert!(!writer3.use_stderr);
337 assert!(writer3.silent);
338 }
339
340 #[test]
341 fn test_output_writer_silent_mode() {
342 let writer = OutputWriter::new(false, true);
343
344 assert!(writer.write("test").is_ok());
346 assert!(writer.writeln("test").is_ok());
347 assert!(writer.write_error("test").is_ok());
348 }
349
350 #[test]
351 fn test_output_writer_write_methods() {
352 let writer = OutputWriter::new(false, false);
354
355 assert!(writer.write("test").is_ok());
357 assert!(writer.writeln("test line").is_ok());
358 assert!(writer.write_error("error message").is_ok());
359 }
360
361 #[test]
362 fn test_output_writer_stderr_mode() {
363 let writer = OutputWriter::new(true, false);
364
365 assert!(writer.write("stderr test").is_ok());
367 assert!(writer.writeln("stderr line").is_ok());
368
369 assert!(writer.write_error("error").is_ok());
371 }
372
373 #[test]
374 fn test_formatter_trait_default_summary() {
375 struct TestFormatter;
377 impl OutputFormatter for TestFormatter {
378 fn format_warnings(&self, _warnings: &[LintWarning], _file_path: &str) -> String {
379 "test".to_string()
380 }
381 }
382
383 let formatter = TestFormatter;
384 assert_eq!(formatter.format_summary(10, 5, 1000), None);
385 assert!(!formatter.use_colors());
386 }
387
388 #[test]
389 fn test_formatter_with_multiple_warnings() {
390 let warnings = vec![
391 create_test_warning(1, "First warning"),
392 create_test_warning(5, "Second warning"),
393 create_test_warning_with_fix(10, "Third warning with fix", "fixed content"),
394 ];
395
396 let text_formatter = TextFormatter::new();
398 let output = text_formatter.format_warnings(&warnings, "test.md");
399 assert!(output.contains("First warning"));
400 assert!(output.contains("Second warning"));
401 assert!(output.contains("Third warning with fix"));
402 }
403
404 #[test]
405 fn test_edge_cases() {
406 let empty_warnings: Vec<LintWarning> = vec![];
408 let formatter = TextFormatter::new();
409 let output = formatter.format_warnings(&empty_warnings, "test.md");
410 assert!(output.is_empty() || output.trim().is_empty());
412
413 let long_path = "a/".repeat(100) + "file.md";
415 let warnings = vec![create_test_warning(1, "Test")];
416 let output = formatter.format_warnings(&warnings, &long_path);
417 assert!(!output.is_empty());
418
419 let unicode_warning = LintWarning {
421 line: 1,
422 column: 1,
423 end_line: 1,
424 end_column: 10,
425 rule_name: Some("MD001".to_string()),
426 message: "Unicode test: 你好 🌟 émphasis".to_string(),
427 severity: Severity::Warning,
428 fix: None,
429 };
430 let output = formatter.format_warnings(&[unicode_warning], "test.md");
431 assert!(output.contains("Unicode test"));
432 }
433
434 #[test]
435 fn test_severity_variations() {
436 let severities = [Severity::Error, Severity::Warning, Severity::Info];
437
438 for severity in &severities {
439 let warning = LintWarning {
440 line: 1,
441 column: 1,
442 end_line: 1,
443 end_column: 5,
444 rule_name: Some("MD001".to_string()),
445 message: format!(
446 "Test {} message",
447 match severity {
448 Severity::Error => "error",
449 Severity::Warning => "warning",
450 Severity::Info => "info",
451 }
452 ),
453 severity: *severity,
454 fix: None,
455 };
456
457 let formatter = TextFormatter::new();
458 let output = formatter.format_warnings(&[warning], "test.md");
459 assert!(!output.is_empty());
460 }
461 }
462
463 #[test]
464 fn test_output_format_equality() {
465 assert_eq!(OutputFormat::Text, OutputFormat::Text);
466 assert_ne!(OutputFormat::Text, OutputFormat::Json);
467 assert_ne!(OutputFormat::Concise, OutputFormat::Grouped);
468 }
469
470 #[test]
471 fn test_all_formats_handle_no_rule_name() {
472 let warning = LintWarning {
473 line: 1,
474 column: 1,
475 end_line: 1,
476 end_column: 5,
477 rule_name: None, message: "Generic warning".to_string(),
479 severity: Severity::Warning,
480 fix: None,
481 };
482
483 let formats = [
484 OutputFormat::Text,
485 OutputFormat::Full,
486 OutputFormat::Concise,
487 OutputFormat::Grouped,
488 OutputFormat::Json,
489 OutputFormat::JsonLines,
490 OutputFormat::GitHub,
491 OutputFormat::GitLab,
492 OutputFormat::Pylint,
493 OutputFormat::Azure,
494 OutputFormat::Sarif,
495 OutputFormat::Junit,
496 ];
497
498 for format in &formats {
499 let formatter = format.create_formatter();
500 let output = formatter.format_warnings(std::slice::from_ref(&warning), "test.md");
501 assert!(
502 !output.is_empty(),
503 "Format {format:?} should handle warnings without rule names"
504 );
505 }
506 }
507
508 #[test]
509 fn test_batch_seam() {
510 let batch = [
511 OutputFormat::Json,
512 OutputFormat::GitLab,
513 OutputFormat::Sarif,
514 OutputFormat::Junit,
515 ];
516 let streaming = [
517 OutputFormat::Text,
518 OutputFormat::Full,
519 OutputFormat::Concise,
520 OutputFormat::Grouped,
521 OutputFormat::JsonLines,
522 OutputFormat::GitHub,
523 OutputFormat::Pylint,
524 OutputFormat::Azure,
525 ];
526
527 let file_warnings = vec![("dirty.md".to_string(), vec![create_test_warning(1, "w")])];
528 let all_files = vec!["dirty.md".to_string(), "clean.md".to_string()];
529
530 for format in &batch {
531 assert!(format.is_batch(), "{format:?} is a batch format");
532 let output = format
533 .format_batch(&file_warnings, &all_files, 5)
534 .unwrap_or_else(|| panic!("{format:?} must format a batch"));
535 assert!(!output.is_empty());
536 }
537 for format in &streaming {
538 assert!(!format.is_batch(), "{format:?} is a streaming format");
539 assert!(
540 format.format_batch(&file_warnings, &all_files, 5).is_none(),
541 "{format:?} must not claim batch output"
542 );
543 }
544
545 for format in batch.iter().chain(&streaming) {
547 assert_eq!(format.needs_all_files(), *format == OutputFormat::Junit);
548 }
549 let junit = OutputFormat::Junit.format_batch(&file_warnings, &all_files, 5).unwrap();
550 assert!(junit.contains("clean.md"), "JUnit batch output reports passing files");
551 }
552
553 #[test]
554 fn test_carries_diff() {
555 let findings_only = [
556 OutputFormat::Json,
557 OutputFormat::JsonLines,
558 OutputFormat::GitLab,
559 OutputFormat::Sarif,
560 OutputFormat::Junit,
561 ];
562 let carries = [
563 OutputFormat::Text,
564 OutputFormat::Full,
565 OutputFormat::Concise,
566 OutputFormat::Grouped,
567 OutputFormat::GitHub,
568 OutputFormat::Pylint,
569 OutputFormat::Azure,
570 ];
571 for format in &findings_only {
572 assert!(!format.carries_diff(), "{format:?} holds nothing but findings");
573 }
574 for format in &carries {
575 assert!(format.carries_diff(), "{format:?} can carry a diff");
576 }
577 }
578
579 #[test]
580 fn test_is_machine_readable() {
581 assert!(!OutputFormat::Text.is_machine_readable());
583 assert!(!OutputFormat::Full.is_machine_readable());
584 assert!(!OutputFormat::Concise.is_machine_readable());
585 assert!(!OutputFormat::Grouped.is_machine_readable());
586
587 assert!(OutputFormat::Json.is_machine_readable());
589 assert!(OutputFormat::JsonLines.is_machine_readable());
590 assert!(OutputFormat::GitHub.is_machine_readable());
591 assert!(OutputFormat::GitLab.is_machine_readable());
592 assert!(OutputFormat::Pylint.is_machine_readable());
593 assert!(OutputFormat::Azure.is_machine_readable());
594 assert!(OutputFormat::Sarif.is_machine_readable());
595 assert!(OutputFormat::Junit.is_machine_readable());
596 }
597}