1use console::{style, Style, Term};
4use indicatif::{ProgressBar, ProgressStyle};
5use serde::{Deserialize, Serialize};
6use std::time::Duration;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
10pub enum OutputFormat {
11 #[default]
13 Text,
14 Json,
16 Tap,
18}
19
20#[derive(Debug)]
22pub struct ProgressReporter {
23 term: Term,
24 progress_bar: Option<ProgressBar>,
25 pub use_color: bool,
27 pub quiet: bool,
29}
30
31impl Default for ProgressReporter {
32 fn default() -> Self {
33 Self::new(true, false)
34 }
35}
36
37impl ProgressReporter {
38 #[must_use]
40 pub fn new(use_color: bool, quiet: bool) -> Self {
41 Self {
42 term: Term::stderr(),
43 progress_bar: None,
44 use_color,
45 quiet,
46 }
47 }
48
49 pub fn start_progress(&mut self, total: u64, message: &str) {
51 if self.quiet {
52 return;
53 }
54
55 let pb = ProgressBar::new(total);
56 pb.set_style(
57 ProgressStyle::default_bar()
58 .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
59 .unwrap_or_else(|_| ProgressStyle::default_bar())
60 .progress_chars("=>-"),
61 );
62 pb.set_message(message.to_string());
63 self.progress_bar = Some(pb);
64 }
65
66 pub fn increment(&self, delta: u64) {
68 if let Some(ref pb) = self.progress_bar {
69 pb.inc(delta);
70 }
71 }
72
73 pub fn set_message(&self, message: &str) {
75 if let Some(ref pb) = self.progress_bar {
76 pb.set_message(message.to_string());
77 }
78 }
79
80 pub fn finish(&self) {
82 if let Some(ref pb) = self.progress_bar {
83 pb.finish_with_message("Done");
84 }
85 }
86
87 pub fn success(&self, message: &str) {
89 if self.quiet {
90 return;
91 }
92
93 let prefix = if self.use_color {
94 style("✓").green().bold().to_string()
95 } else {
96 "PASS".to_string()
97 };
98
99 let _ = self.term.write_line(&format!("{prefix} {message}"));
100 }
101
102 pub fn failure(&self, message: &str) {
104 let prefix = if self.use_color {
106 style("✗").red().bold().to_string()
107 } else {
108 "FAIL".to_string()
109 };
110
111 let _ = self.term.write_line(&format!("{prefix} {message}"));
112 }
113
114 pub fn warning(&self, message: &str) {
116 if self.quiet {
117 return;
118 }
119
120 let prefix = if self.use_color {
121 style("⚠").yellow().bold().to_string()
122 } else {
123 "WARN".to_string()
124 };
125
126 let _ = self.term.write_line(&format!("{prefix} {message}"));
127 }
128
129 pub fn info(&self, message: &str) {
131 if self.quiet {
132 return;
133 }
134
135 let prefix = if self.use_color {
136 style("ℹ").blue().bold().to_string()
137 } else {
138 "INFO".to_string()
139 };
140
141 let _ = self.term.write_line(&format!("{prefix} {message}"));
142 }
143
144 pub fn header(&self, title: &str) {
146 if self.quiet {
147 return;
148 }
149
150 let styled = if self.use_color {
151 style(title).bold().underlined().to_string()
152 } else {
153 format!("=== {title} ===")
154 };
155
156 let _ = self.term.write_line("");
157 let _ = self.term.write_line(&styled);
158 }
159
160 pub fn summary(&self, passed: usize, failed: usize, skipped: usize, duration: Duration) {
162 if self.quiet && failed == 0 {
163 return;
164 }
165
166 let _ = self.term.write_line("");
167
168 let total = passed + failed + skipped;
169 let duration_secs = duration.as_secs_f64();
170
171 if self.use_color {
172 let passed_style = Style::new().green().bold();
173 let failed_style = Style::new().red().bold();
174 let skipped_style = Style::new().yellow();
175
176 let status = if failed > 0 {
177 failed_style.apply_to("FAILED")
178 } else {
179 passed_style.apply_to("PASSED")
180 };
181
182 let _ = self.term.write_line(&format!(
183 "{} {} tests in {:.2}s ({} passed, {} failed, {} skipped)",
184 status,
185 total,
186 duration_secs,
187 passed_style.apply_to(passed),
188 if failed > 0 {
189 failed_style.apply_to(failed).to_string()
190 } else {
191 failed.to_string()
192 },
193 skipped_style.apply_to(skipped)
194 ));
195 } else {
196 let status = if failed > 0 { "FAILED" } else { "PASSED" };
197 let _ = self.term.write_line(&format!(
198 "{status} {total} tests in {duration_secs:.2}s ({passed} passed, {failed} failed, {skipped} skipped)"
199 ));
200 }
201 }
202}
203
204#[cfg(test)]
205#[allow(clippy::unwrap_used, clippy::expect_used)]
206mod tests {
207 use super::*;
208
209 mod output_format_tests {
210 use super::*;
211
212 #[test]
213 fn test_default_format() {
214 let format = OutputFormat::default();
215 assert_eq!(format, OutputFormat::Text);
216 }
217
218 #[test]
219 fn test_format_variants() {
220 let _ = OutputFormat::Text;
221 let _ = OutputFormat::Json;
222 let _ = OutputFormat::Tap;
223 }
224 }
225
226 mod progress_reporter_tests {
227 use super::*;
228
229 #[test]
230 fn test_new_reporter() {
231 let reporter = ProgressReporter::new(true, false);
232 assert!(reporter.use_color);
233 assert!(!reporter.quiet);
234 }
235
236 #[test]
237 fn test_default_reporter() {
238 let reporter = ProgressReporter::default();
239 assert!(reporter.use_color);
240 assert!(!reporter.quiet);
241 }
242
243 #[test]
244 fn test_quiet_reporter() {
245 let reporter = ProgressReporter::new(false, true);
246 assert!(reporter.quiet);
247 }
248
249 #[test]
250 fn test_success_message() {
251 let reporter = ProgressReporter::new(false, false);
252 reporter.success("Test passed");
253 }
255
256 #[test]
257 fn test_failure_message() {
258 let reporter = ProgressReporter::new(false, false);
259 reporter.failure("Test failed");
260 }
262
263 #[test]
264 fn test_warning_message() {
265 let reporter = ProgressReporter::new(false, false);
266 reporter.warning("Test warning");
267 }
269
270 #[test]
271 fn test_info_message() {
272 let reporter = ProgressReporter::new(false, false);
273 reporter.info("Test info");
274 }
276
277 #[test]
278 fn test_header() {
279 let reporter = ProgressReporter::new(false, false);
280 reporter.header("Test Header");
281 }
283
284 #[test]
285 fn test_summary_passed() {
286 let reporter = ProgressReporter::new(false, false);
287 reporter.summary(10, 0, 2, Duration::from_secs(5));
288 }
290
291 #[test]
292 fn test_summary_failed() {
293 let reporter = ProgressReporter::new(false, false);
294 reporter.summary(8, 2, 0, Duration::from_secs(3));
295 }
297
298 #[test]
299 fn test_progress_bar() {
300 let mut reporter = ProgressReporter::new(false, false);
301 reporter.start_progress(10, "Running tests");
302 reporter.increment(1);
303 reporter.set_message("test_1");
304 reporter.increment(1);
305 reporter.finish();
306 }
308
309 #[test]
310 fn test_quiet_mode_suppresses_output() {
311 let mut reporter = ProgressReporter::new(false, true);
312 reporter.start_progress(10, "Running tests");
313 reporter.success("hidden");
314 reporter.warning("hidden");
315 reporter.info("hidden");
316 reporter.header("hidden");
317 reporter.failure("shown");
319 }
321
322 #[test]
323 fn test_color_mode_messages() {
324 let reporter = ProgressReporter::new(true, false);
325 reporter.success("Pass with color");
326 reporter.failure("Fail with color");
327 reporter.warning("Warn with color");
328 reporter.info("Info with color");
329 reporter.header("Header with color");
330 }
331
332 #[test]
333 fn test_summary_all_skipped() {
334 let reporter = ProgressReporter::new(false, false);
335 reporter.summary(0, 0, 5, Duration::from_secs(1));
336 }
337
338 #[test]
339 fn test_summary_mixed() {
340 let reporter = ProgressReporter::new(true, false);
341 reporter.summary(5, 3, 2, Duration::from_millis(500));
342 }
343
344 #[test]
345 fn test_progress_without_start() {
346 let reporter = ProgressReporter::new(false, false);
347 reporter.increment(1);
349 reporter.set_message("test");
350 reporter.finish();
351 }
352
353 #[test]
354 fn test_debug() {
355 let reporter = ProgressReporter::new(true, false);
356 let debug = format!("{reporter:?}");
357 assert!(debug.contains("ProgressReporter"));
358 }
359 }
360
361 mod output_format_additional_tests {
362 use super::*;
363
364 #[test]
365 fn test_clone() {
366 let format = OutputFormat::Json;
367 let cloned = format;
368 assert_eq!(format, cloned);
369 }
370
371 #[test]
372 fn test_debug() {
373 let debug = format!("{:?}", OutputFormat::Text);
374 assert!(debug.contains("Text"));
375 }
376
377 #[test]
378 fn test_serialize() {
379 let format = OutputFormat::Json;
380 let json = serde_json::to_string(&format).unwrap();
381 assert!(json.contains("Json"));
382 }
383 }
384}