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_summary(&self, _files_processed: usize, _total_warnings: usize, _duration_ms: u64) -> Option<String> {
22 None
24 }
25
26 fn use_colors(&self) -> bool {
28 false
29 }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq)]
34pub enum OutputFormat {
35 Text,
37 Concise,
39 Grouped,
41 Json,
43 JsonLines,
45 GitHub,
47 GitLab,
49 Pylint,
51 Azure,
53 Sarif,
55 Junit,
57}
58
59impl FromStr for OutputFormat {
60 type Err = String;
61
62 fn from_str(s: &str) -> Result<Self, Self::Err> {
63 match s.to_lowercase().as_str() {
64 "text" | "full" => Ok(OutputFormat::Text),
65 "concise" => Ok(OutputFormat::Concise),
66 "grouped" => Ok(OutputFormat::Grouped),
67 "json" => Ok(OutputFormat::Json),
68 "json-lines" | "jsonlines" => Ok(OutputFormat::JsonLines),
69 "github" => Ok(OutputFormat::GitHub),
70 "gitlab" => Ok(OutputFormat::GitLab),
71 "pylint" => Ok(OutputFormat::Pylint),
72 "azure" => Ok(OutputFormat::Azure),
73 "sarif" => Ok(OutputFormat::Sarif),
74 "junit" => Ok(OutputFormat::Junit),
75 _ => Err(format!("Unknown output format: {s}")),
76 }
77 }
78}
79
80impl OutputFormat {
81 pub fn create_formatter(&self) -> Box<dyn OutputFormatter> {
83 match self {
84 OutputFormat::Text => Box::new(TextFormatter::new()),
85 OutputFormat::Concise => Box::new(ConciseFormatter::new()),
86 OutputFormat::Grouped => Box::new(GroupedFormatter::new()),
87 OutputFormat::Json => Box::new(JsonFormatter::new()),
88 OutputFormat::JsonLines => Box::new(JsonLinesFormatter::new()),
89 OutputFormat::GitHub => Box::new(GitHubFormatter::new()),
90 OutputFormat::GitLab => Box::new(GitLabFormatter::new()),
91 OutputFormat::Pylint => Box::new(PylintFormatter::new()),
92 OutputFormat::Azure => Box::new(AzureFormatter::new()),
93 OutputFormat::Sarif => Box::new(SarifFormatter::new()),
94 OutputFormat::Junit => Box::new(JunitFormatter::new()),
95 }
96 }
97}
98
99pub struct OutputWriter {
101 use_stderr: bool,
102 _quiet: bool,
103 silent: bool,
104}
105
106impl OutputWriter {
107 pub fn new(use_stderr: bool, quiet: bool, silent: bool) -> Self {
108 Self {
109 use_stderr,
110 _quiet: quiet,
111 silent,
112 }
113 }
114
115 pub fn write(&self, content: &str) -> io::Result<()> {
117 if self.silent {
118 return Ok(());
119 }
120
121 if self.use_stderr {
122 eprint!("{content}");
123 io::stderr().flush()?;
124 } else {
125 print!("{content}");
126 io::stdout().flush()?;
127 }
128 Ok(())
129 }
130
131 pub fn writeln(&self, content: &str) -> io::Result<()> {
133 if self.silent {
134 return Ok(());
135 }
136
137 if self.use_stderr {
138 eprintln!("{content}");
139 } else {
140 println!("{content}");
141 }
142 Ok(())
143 }
144
145 pub fn write_error(&self, content: &str) -> io::Result<()> {
147 if self.silent {
148 return Ok(());
149 }
150
151 eprintln!("{content}");
152 Ok(())
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::rule::{Fix, Severity};
160
161 fn create_test_warning(line: usize, message: &str) -> LintWarning {
162 LintWarning {
163 line,
164 column: 5,
165 end_line: line,
166 end_column: 10,
167 rule_name: Some("MD001".to_string()),
168 message: message.to_string(),
169 severity: Severity::Warning,
170 fix: None,
171 }
172 }
173
174 fn create_test_warning_with_fix(line: usize, message: &str, fix_text: &str) -> LintWarning {
175 LintWarning {
176 line,
177 column: 5,
178 end_line: line,
179 end_column: 10,
180 rule_name: Some("MD001".to_string()),
181 message: message.to_string(),
182 severity: Severity::Warning,
183 fix: Some(Fix {
184 range: 0..5,
185 replacement: fix_text.to_string(),
186 }),
187 }
188 }
189
190 #[test]
191 fn test_output_format_from_str() {
192 assert_eq!(OutputFormat::from_str("text").unwrap(), OutputFormat::Text);
194 assert_eq!(OutputFormat::from_str("full").unwrap(), OutputFormat::Text);
195 assert_eq!(OutputFormat::from_str("concise").unwrap(), OutputFormat::Concise);
196 assert_eq!(OutputFormat::from_str("grouped").unwrap(), OutputFormat::Grouped);
197 assert_eq!(OutputFormat::from_str("json").unwrap(), OutputFormat::Json);
198 assert_eq!(OutputFormat::from_str("json-lines").unwrap(), OutputFormat::JsonLines);
199 assert_eq!(OutputFormat::from_str("jsonlines").unwrap(), OutputFormat::JsonLines);
200 assert_eq!(OutputFormat::from_str("github").unwrap(), OutputFormat::GitHub);
201 assert_eq!(OutputFormat::from_str("gitlab").unwrap(), OutputFormat::GitLab);
202 assert_eq!(OutputFormat::from_str("pylint").unwrap(), OutputFormat::Pylint);
203 assert_eq!(OutputFormat::from_str("azure").unwrap(), OutputFormat::Azure);
204 assert_eq!(OutputFormat::from_str("sarif").unwrap(), OutputFormat::Sarif);
205 assert_eq!(OutputFormat::from_str("junit").unwrap(), OutputFormat::Junit);
206
207 assert_eq!(OutputFormat::from_str("TEXT").unwrap(), OutputFormat::Text);
209 assert_eq!(OutputFormat::from_str("GitHub").unwrap(), OutputFormat::GitHub);
210 assert_eq!(OutputFormat::from_str("JSON-LINES").unwrap(), OutputFormat::JsonLines);
211
212 assert!(OutputFormat::from_str("invalid").is_err());
214 assert!(OutputFormat::from_str("").is_err());
215 assert!(OutputFormat::from_str("xml").is_err());
216 }
217
218 #[test]
219 fn test_output_format_create_formatter() {
220 let formats = [
222 OutputFormat::Text,
223 OutputFormat::Concise,
224 OutputFormat::Grouped,
225 OutputFormat::Json,
226 OutputFormat::JsonLines,
227 OutputFormat::GitHub,
228 OutputFormat::GitLab,
229 OutputFormat::Pylint,
230 OutputFormat::Azure,
231 OutputFormat::Sarif,
232 OutputFormat::Junit,
233 ];
234
235 for format in &formats {
236 let formatter = format.create_formatter();
237 let warnings = vec![create_test_warning(1, "Test warning")];
239 let output = formatter.format_warnings(&warnings, "test.md");
240 assert!(!output.is_empty(), "Formatter {format:?} should produce output");
241 }
242 }
243
244 #[test]
245 fn test_output_writer_new() {
246 let writer1 = OutputWriter::new(false, false, false);
247 assert!(!writer1.use_stderr);
248 assert!(!writer1._quiet);
249 assert!(!writer1.silent);
250
251 let writer2 = OutputWriter::new(true, true, false);
252 assert!(writer2.use_stderr);
253 assert!(writer2._quiet);
254 assert!(!writer2.silent);
255
256 let writer3 = OutputWriter::new(false, false, true);
257 assert!(!writer3.use_stderr);
258 assert!(!writer3._quiet);
259 assert!(writer3.silent);
260 }
261
262 #[test]
263 fn test_output_writer_silent_mode() {
264 let writer = OutputWriter::new(false, false, true);
265
266 assert!(writer.write("test").is_ok());
268 assert!(writer.writeln("test").is_ok());
269 assert!(writer.write_error("test").is_ok());
270 }
271
272 #[test]
273 fn test_output_writer_write_methods() {
274 let writer = OutputWriter::new(false, false, false);
276
277 assert!(writer.write("test").is_ok());
279 assert!(writer.writeln("test line").is_ok());
280 assert!(writer.write_error("error message").is_ok());
281 }
282
283 #[test]
284 fn test_output_writer_stderr_mode() {
285 let writer = OutputWriter::new(true, false, false);
286
287 assert!(writer.write("stderr test").is_ok());
289 assert!(writer.writeln("stderr line").is_ok());
290
291 assert!(writer.write_error("error").is_ok());
293 }
294
295 #[test]
296 fn test_formatter_trait_default_summary() {
297 struct TestFormatter;
299 impl OutputFormatter for TestFormatter {
300 fn format_warnings(&self, _warnings: &[LintWarning], _file_path: &str) -> String {
301 "test".to_string()
302 }
303 }
304
305 let formatter = TestFormatter;
306 assert_eq!(formatter.format_summary(10, 5, 1000), None);
307 assert!(!formatter.use_colors());
308 }
309
310 #[test]
311 fn test_formatter_with_multiple_warnings() {
312 let warnings = vec![
313 create_test_warning(1, "First warning"),
314 create_test_warning(5, "Second warning"),
315 create_test_warning_with_fix(10, "Third warning with fix", "fixed content"),
316 ];
317
318 let text_formatter = TextFormatter::new();
320 let output = text_formatter.format_warnings(&warnings, "test.md");
321 assert!(output.contains("First warning"));
322 assert!(output.contains("Second warning"));
323 assert!(output.contains("Third warning with fix"));
324 }
325
326 #[test]
327 fn test_edge_cases() {
328 let empty_warnings: Vec<LintWarning> = vec![];
330 let formatter = TextFormatter::new();
331 let output = formatter.format_warnings(&empty_warnings, "test.md");
332 assert!(output.is_empty() || output.trim().is_empty());
334
335 let long_path = "a/".repeat(100) + "file.md";
337 let warnings = vec![create_test_warning(1, "Test")];
338 let output = formatter.format_warnings(&warnings, &long_path);
339 assert!(!output.is_empty());
340
341 let unicode_warning = LintWarning {
343 line: 1,
344 column: 1,
345 end_line: 1,
346 end_column: 10,
347 rule_name: Some("MD001".to_string()),
348 message: "Unicode test: 你好 🌟 émphasis".to_string(),
349 severity: Severity::Warning,
350 fix: None,
351 };
352 let output = formatter.format_warnings(&[unicode_warning], "test.md");
353 assert!(output.contains("Unicode test"));
354 }
355
356 #[test]
357 fn test_severity_variations() {
358 let severities = [Severity::Error, Severity::Warning, Severity::Info];
359
360 for severity in &severities {
361 let warning = LintWarning {
362 line: 1,
363 column: 1,
364 end_line: 1,
365 end_column: 5,
366 rule_name: Some("MD001".to_string()),
367 message: format!(
368 "Test {} message",
369 match severity {
370 Severity::Error => "error",
371 Severity::Warning => "warning",
372 Severity::Info => "info",
373 }
374 ),
375 severity: *severity,
376 fix: None,
377 };
378
379 let formatter = TextFormatter::new();
380 let output = formatter.format_warnings(&[warning], "test.md");
381 assert!(!output.is_empty());
382 }
383 }
384
385 #[test]
386 fn test_output_format_equality() {
387 assert_eq!(OutputFormat::Text, OutputFormat::Text);
388 assert_ne!(OutputFormat::Text, OutputFormat::Json);
389 assert_ne!(OutputFormat::Concise, OutputFormat::Grouped);
390 }
391
392 #[test]
393 fn test_all_formats_handle_no_rule_name() {
394 let warning = LintWarning {
395 line: 1,
396 column: 1,
397 end_line: 1,
398 end_column: 5,
399 rule_name: None, message: "Generic warning".to_string(),
401 severity: Severity::Warning,
402 fix: None,
403 };
404
405 let formats = [
406 OutputFormat::Text,
407 OutputFormat::Concise,
408 OutputFormat::Grouped,
409 OutputFormat::Json,
410 OutputFormat::JsonLines,
411 OutputFormat::GitHub,
412 OutputFormat::GitLab,
413 OutputFormat::Pylint,
414 OutputFormat::Azure,
415 OutputFormat::Sarif,
416 OutputFormat::Junit,
417 ];
418
419 for format in &formats {
420 let formatter = format.create_formatter();
421 let output = formatter.format_warnings(std::slice::from_ref(&warning), "test.md");
422 assert!(
423 !output.is_empty(),
424 "Format {format:?} should handle warnings without rule names"
425 );
426 }
427 }
428}