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