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 create_formatter(&self) -> Box<dyn OutputFormatter> {
102 match self {
103 OutputFormat::Text => Box::new(TextFormatter::new()),
104 OutputFormat::Full => Box::new(FullFormatter::new()),
105 OutputFormat::Concise => Box::new(ConciseFormatter::new()),
106 OutputFormat::Grouped => Box::new(GroupedFormatter::new()),
107 OutputFormat::Json => Box::new(JsonFormatter::new()),
108 OutputFormat::JsonLines => Box::new(JsonLinesFormatter::new()),
109 OutputFormat::GitHub => Box::new(GitHubFormatter::new()),
110 OutputFormat::GitLab => Box::new(GitLabFormatter::new()),
111 OutputFormat::Pylint => Box::new(PylintFormatter::new()),
112 OutputFormat::Azure => Box::new(AzureFormatter::new()),
113 OutputFormat::Sarif => Box::new(SarifFormatter::new()),
114 OutputFormat::Junit => Box::new(JunitFormatter::new()),
115 }
116 }
117}
118
119pub struct OutputWriter {
121 use_stderr: bool,
122 silent: bool,
123}
124
125impl OutputWriter {
126 pub fn new(use_stderr: bool, silent: bool) -> Self {
127 Self { use_stderr, silent }
128 }
129
130 pub fn write(&self, content: &str) -> io::Result<()> {
132 if self.silent {
133 return Ok(());
134 }
135
136 if self.use_stderr {
137 eprint!("{content}");
138 io::stderr().flush()?;
139 } else {
140 print!("{content}");
141 io::stdout().flush()?;
142 }
143 Ok(())
144 }
145
146 pub fn writeln(&self, content: &str) -> io::Result<()> {
148 if self.silent {
149 return Ok(());
150 }
151
152 if self.use_stderr {
153 eprintln!("{content}");
154 } else {
155 println!("{content}");
156 }
157 Ok(())
158 }
159
160 pub fn write_error(&self, content: &str) -> io::Result<()> {
162 if self.silent {
163 return Ok(());
164 }
165
166 eprintln!("{content}");
167 Ok(())
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::rule::{Fix, Severity};
175
176 fn create_test_warning(line: usize, message: &str) -> LintWarning {
177 LintWarning {
178 line,
179 column: 5,
180 end_line: line,
181 end_column: 10,
182 rule_name: Some("MD001".to_string()),
183 message: message.to_string(),
184 severity: Severity::Warning,
185 fix: None,
186 }
187 }
188
189 fn create_test_warning_with_fix(line: usize, message: &str, fix_text: &str) -> LintWarning {
190 LintWarning {
191 line,
192 column: 5,
193 end_line: line,
194 end_column: 10,
195 rule_name: Some("MD001".to_string()),
196 message: message.to_string(),
197 severity: Severity::Warning,
198 fix: Some(Fix {
199 range: 0..5,
200 replacement: fix_text.to_string(),
201 }),
202 }
203 }
204
205 #[test]
206 fn test_output_format_from_str() {
207 assert_eq!(OutputFormat::from_str("text").unwrap(), OutputFormat::Text);
209 assert_eq!(OutputFormat::from_str("full").unwrap(), OutputFormat::Full);
210 assert_eq!(OutputFormat::from_str("concise").unwrap(), OutputFormat::Concise);
211 assert_eq!(OutputFormat::from_str("grouped").unwrap(), OutputFormat::Grouped);
212 assert_eq!(OutputFormat::from_str("json").unwrap(), OutputFormat::Json);
213 assert_eq!(OutputFormat::from_str("json-lines").unwrap(), OutputFormat::JsonLines);
214 assert_eq!(OutputFormat::from_str("jsonlines").unwrap(), OutputFormat::JsonLines);
215 assert_eq!(OutputFormat::from_str("github").unwrap(), OutputFormat::GitHub);
216 assert_eq!(OutputFormat::from_str("gitlab").unwrap(), OutputFormat::GitLab);
217 assert_eq!(OutputFormat::from_str("pylint").unwrap(), OutputFormat::Pylint);
218 assert_eq!(OutputFormat::from_str("azure").unwrap(), OutputFormat::Azure);
219 assert_eq!(OutputFormat::from_str("sarif").unwrap(), OutputFormat::Sarif);
220 assert_eq!(OutputFormat::from_str("junit").unwrap(), OutputFormat::Junit);
221
222 assert_eq!(OutputFormat::from_str("TEXT").unwrap(), OutputFormat::Text);
224 assert_eq!(OutputFormat::from_str("GitHub").unwrap(), OutputFormat::GitHub);
225 assert_eq!(OutputFormat::from_str("JSON-LINES").unwrap(), OutputFormat::JsonLines);
226
227 assert!(OutputFormat::from_str("invalid").is_err());
229 assert!(OutputFormat::from_str("").is_err());
230 assert!(OutputFormat::from_str("xml").is_err());
231 }
232
233 #[test]
234 fn test_output_format_create_formatter() {
235 let formats = [
237 OutputFormat::Text,
238 OutputFormat::Full,
239 OutputFormat::Concise,
240 OutputFormat::Grouped,
241 OutputFormat::Json,
242 OutputFormat::JsonLines,
243 OutputFormat::GitHub,
244 OutputFormat::GitLab,
245 OutputFormat::Pylint,
246 OutputFormat::Azure,
247 OutputFormat::Sarif,
248 OutputFormat::Junit,
249 ];
250
251 for format in &formats {
252 let formatter = format.create_formatter();
253 let warnings = vec![create_test_warning(1, "Test warning")];
255 let output = formatter.format_warnings(&warnings, "test.md");
256 assert!(!output.is_empty(), "Formatter {format:?} should produce output");
257 }
258 }
259
260 #[test]
261 fn test_output_writer_new() {
262 let writer1 = OutputWriter::new(false, false);
263 assert!(!writer1.use_stderr);
264 assert!(!writer1.silent);
265
266 let writer2 = OutputWriter::new(true, false);
267 assert!(writer2.use_stderr);
268 assert!(!writer2.silent);
269
270 let writer3 = OutputWriter::new(false, true);
271 assert!(!writer3.use_stderr);
272 assert!(writer3.silent);
273 }
274
275 #[test]
276 fn test_output_writer_silent_mode() {
277 let writer = OutputWriter::new(false, true);
278
279 assert!(writer.write("test").is_ok());
281 assert!(writer.writeln("test").is_ok());
282 assert!(writer.write_error("test").is_ok());
283 }
284
285 #[test]
286 fn test_output_writer_write_methods() {
287 let writer = OutputWriter::new(false, false);
289
290 assert!(writer.write("test").is_ok());
292 assert!(writer.writeln("test line").is_ok());
293 assert!(writer.write_error("error message").is_ok());
294 }
295
296 #[test]
297 fn test_output_writer_stderr_mode() {
298 let writer = OutputWriter::new(true, false);
299
300 assert!(writer.write("stderr test").is_ok());
302 assert!(writer.writeln("stderr line").is_ok());
303
304 assert!(writer.write_error("error").is_ok());
306 }
307
308 #[test]
309 fn test_formatter_trait_default_summary() {
310 struct TestFormatter;
312 impl OutputFormatter for TestFormatter {
313 fn format_warnings(&self, _warnings: &[LintWarning], _file_path: &str) -> String {
314 "test".to_string()
315 }
316 }
317
318 let formatter = TestFormatter;
319 assert_eq!(formatter.format_summary(10, 5, 1000), None);
320 assert!(!formatter.use_colors());
321 }
322
323 #[test]
324 fn test_formatter_with_multiple_warnings() {
325 let warnings = vec![
326 create_test_warning(1, "First warning"),
327 create_test_warning(5, "Second warning"),
328 create_test_warning_with_fix(10, "Third warning with fix", "fixed content"),
329 ];
330
331 let text_formatter = TextFormatter::new();
333 let output = text_formatter.format_warnings(&warnings, "test.md");
334 assert!(output.contains("First warning"));
335 assert!(output.contains("Second warning"));
336 assert!(output.contains("Third warning with fix"));
337 }
338
339 #[test]
340 fn test_edge_cases() {
341 let empty_warnings: Vec<LintWarning> = vec![];
343 let formatter = TextFormatter::new();
344 let output = formatter.format_warnings(&empty_warnings, "test.md");
345 assert!(output.is_empty() || output.trim().is_empty());
347
348 let long_path = "a/".repeat(100) + "file.md";
350 let warnings = vec![create_test_warning(1, "Test")];
351 let output = formatter.format_warnings(&warnings, &long_path);
352 assert!(!output.is_empty());
353
354 let unicode_warning = LintWarning {
356 line: 1,
357 column: 1,
358 end_line: 1,
359 end_column: 10,
360 rule_name: Some("MD001".to_string()),
361 message: "Unicode test: 你好 🌟 émphasis".to_string(),
362 severity: Severity::Warning,
363 fix: None,
364 };
365 let output = formatter.format_warnings(&[unicode_warning], "test.md");
366 assert!(output.contains("Unicode test"));
367 }
368
369 #[test]
370 fn test_severity_variations() {
371 let severities = [Severity::Error, Severity::Warning, Severity::Info];
372
373 for severity in &severities {
374 let warning = LintWarning {
375 line: 1,
376 column: 1,
377 end_line: 1,
378 end_column: 5,
379 rule_name: Some("MD001".to_string()),
380 message: format!(
381 "Test {} message",
382 match severity {
383 Severity::Error => "error",
384 Severity::Warning => "warning",
385 Severity::Info => "info",
386 }
387 ),
388 severity: *severity,
389 fix: None,
390 };
391
392 let formatter = TextFormatter::new();
393 let output = formatter.format_warnings(&[warning], "test.md");
394 assert!(!output.is_empty());
395 }
396 }
397
398 #[test]
399 fn test_output_format_equality() {
400 assert_eq!(OutputFormat::Text, OutputFormat::Text);
401 assert_ne!(OutputFormat::Text, OutputFormat::Json);
402 assert_ne!(OutputFormat::Concise, OutputFormat::Grouped);
403 }
404
405 #[test]
406 fn test_all_formats_handle_no_rule_name() {
407 let warning = LintWarning {
408 line: 1,
409 column: 1,
410 end_line: 1,
411 end_column: 5,
412 rule_name: None, message: "Generic warning".to_string(),
414 severity: Severity::Warning,
415 fix: None,
416 };
417
418 let formats = [
419 OutputFormat::Text,
420 OutputFormat::Full,
421 OutputFormat::Concise,
422 OutputFormat::Grouped,
423 OutputFormat::Json,
424 OutputFormat::JsonLines,
425 OutputFormat::GitHub,
426 OutputFormat::GitLab,
427 OutputFormat::Pylint,
428 OutputFormat::Azure,
429 OutputFormat::Sarif,
430 OutputFormat::Junit,
431 ];
432
433 for format in &formats {
434 let formatter = format.create_formatter();
435 let output = formatter.format_warnings(std::slice::from_ref(&warning), "test.md");
436 assert!(
437 !output.is_empty(),
438 "Format {format:?} should handle warnings without rule names"
439 );
440 }
441 }
442
443 #[test]
444 fn test_is_machine_readable() {
445 assert!(!OutputFormat::Text.is_machine_readable());
447 assert!(!OutputFormat::Full.is_machine_readable());
448 assert!(!OutputFormat::Concise.is_machine_readable());
449 assert!(!OutputFormat::Grouped.is_machine_readable());
450
451 assert!(OutputFormat::Json.is_machine_readable());
453 assert!(OutputFormat::JsonLines.is_machine_readable());
454 assert!(OutputFormat::GitHub.is_machine_readable());
455 assert!(OutputFormat::GitLab.is_machine_readable());
456 assert!(OutputFormat::Pylint.is_machine_readable());
457 assert!(OutputFormat::Azure.is_machine_readable());
458 assert!(OutputFormat::Sarif.is_machine_readable());
459 assert!(OutputFormat::Junit.is_machine_readable());
460 }
461}