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