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