Skip to main content

tanu_core/
reporter.rs

1//! # Test Reporter Module
2//!
3//! The reporter system provides pluggable output formatting for test results.
4//! Reporters subscribe to test execution events and format them for different
5//! output destinations (console, files, etc.). Multiple reporters can run
6//! simultaneously to generate multiple output formats.
7//!
8//! ## Built-in Reporters
9//!
10//! - **`NullReporter`**: No output (useful for testing)
11//! - **`ListReporter`**: Real-time streaming output with detailed logs
12//! - **`TableReporter`**: Summary table output after all tests complete
13//!
14//! ## Custom Reporters
15//!
16//! Implement the `Reporter` trait to create custom output formats:
17//!
18//! ```rust,ignore
19//! use tanu_core::reporter::Reporter;
20//!
21//! struct JsonReporter;
22//!
23//! #[async_trait::async_trait]
24//! impl Reporter for JsonReporter {
25//!     async fn on_end(
26//!         &mut self,
27//!         project: String,
28//!         module: String,
29//!         test_name: String,
30//!         test: Test
31//!     ) -> eyre::Result<()> {
32//!         println!("{}", serde_json::to_string(&test)?);
33//!         Ok(())
34//!     }
35//! }
36//! ```
37
38use console::{style, StyledObject, Term};
39use eyre::WrapErr;
40use indexmap::IndexMap;
41use itertools::Itertools;
42use std::{
43    collections::HashMap,
44    sync::{LazyLock, Mutex},
45};
46use tokio::sync::broadcast;
47use tracing::*;
48
49use crate::{
50    get_tanu_config, http,
51    runner::{self, Event, EventBody, Test},
52    ModuleName, ProjectName, TestName,
53};
54
55/// Available built-in reporter types.
56///
57/// Used for selecting which reporter to use via configuration or CLI arguments.
58/// Each type corresponds to a different output format and behavior.
59///
60/// # Variants
61///
62/// - `Null`: No output, useful for testing or when output is not needed
63/// - `List`: Real-time streaming output with detailed information
64/// - `Table`: Summary table displayed after all tests complete
65#[derive(Debug, Clone, Default, strum::EnumString, strum::Display)]
66#[strum(serialize_all = "snake_case")]
67pub enum ReporterType {
68    Null,
69    #[default]
70    List,
71    Table,
72}
73
74async fn run<R: Reporter + Send + ?Sized>(reporter: &mut R) -> eyre::Result<()> {
75    // Attempt to subscribe first
76    let rx_result = runner::subscribe();
77
78    // Always participate in barrier, even if subscribe failed
79    // This prevents deadlock if any reporter fails to subscribe
80    runner::wait_reporter_barrier().await;
81
82    // Now check if subscribe succeeded
83    let mut rx = rx_result?;
84
85    loop {
86        let res = match rx.recv().await {
87            Ok(Event {
88                project,
89                module,
90                test,
91                body: EventBody::Start,
92            }) => reporter.on_start(project, module, test).await,
93            Ok(Event {
94                project,
95                module,
96                test,
97                body: EventBody::Check(check),
98            }) => reporter.on_check(project, module, test, check).await,
99            Ok(Event {
100                project,
101                module,
102                test,
103                body: EventBody::Call(log),
104            }) => reporter.on_call(project, module, test, log).await,
105            Ok(Event {
106                project,
107                module,
108                test: test_name,
109                body: EventBody::Retry(test),
110            }) => reporter.on_retry(project, module, test_name, test).await,
111            Ok(Event {
112                project,
113                module,
114                test: test_name,
115                body: EventBody::End(test),
116            }) => reporter.on_end(project, module, test_name, test).await,
117            Ok(Event {
118                project: _,
119                module: _,
120                test: _,
121                body: EventBody::Summary(summary),
122            }) => reporter.on_summary(summary).await,
123            Err(broadcast::error::RecvError::Closed) => {
124                debug!("runner channel has been closed");
125                break;
126            }
127            Err(broadcast::error::RecvError::Lagged(_)) => {
128                debug!("runner channel recv error");
129                continue;
130            }
131        };
132
133        if let Err(e) = res {
134            warn!("reporter error: {e:#}");
135        }
136    }
137
138    Ok(())
139}
140
141/// Trait for implementing custom test result reporting.
142///
143/// Reporters receive real-time events during test execution and can format
144/// and output results in various ways. The trait uses the template method pattern:
145/// implement the `on_*` methods to handle specific events, or override `run()`
146/// for complete control.
147///
148/// # Event Flow
149///
150/// For each test, events are fired in this order:
151/// 1. `on_start()` - Test begins
152/// 2. `on_check()` - Each assertion (0 or more)
153/// 3. `on_call()` - Each protocol call (HTTP, gRPC, etc.) (0 or more)
154/// 4. `on_retry()` - If test fails and retry is configured
155/// 5. `on_end()` - Test completes with final result
156///
157/// # Examples
158///
159/// ```rust,ignore
160/// use tanu_core::reporter::Reporter;
161/// use tanu_core::runner::Test;
162///
163/// struct SimpleReporter;
164///
165/// #[async_trait::async_trait]
166/// impl Reporter for SimpleReporter {
167///     async fn on_start(
168///         &mut self,
169///         project: String,
170///         module: String,
171///         test_name: String,
172///     ) -> eyre::Result<()> {
173///         println!("Starting {project}::{module}::{test_name}");
174///         Ok(())
175///     }
176///
177///     async fn on_end(
178///         &mut self,
179///         project: String,
180///         module: String,
181///         test_name: String,
182///         test: Test,
183///     ) -> eyre::Result<()> {
184///         let status = if test.result.is_ok() { "PASS" } else { "FAIL" };
185///         println!("{status}: {project}::{module}::{test_name}");
186///         Ok(())
187///     }
188/// }
189/// ```
190#[async_trait::async_trait]
191pub trait Reporter {
192    async fn run(&mut self) -> eyre::Result<()> {
193        run(self).await
194    }
195
196    /// Called when a test case starts.
197    async fn on_start(
198        &mut self,
199        _project: String,
200        _module: String,
201        _test_name: String,
202    ) -> eyre::Result<()> {
203        Ok(())
204    }
205
206    /// Called when a check macro is used.
207    async fn on_check(
208        &mut self,
209        _project: String,
210        _module: String,
211        _test_name: String,
212        _check: Box<runner::Check>,
213    ) -> eyre::Result<()> {
214        Ok(())
215    }
216
217    /// Called when a protocol call (HTTP, gRPC, etc.) is made.
218    async fn on_call(
219        &mut self,
220        _project: String,
221        _module: String,
222        _test_name: String,
223        _log: runner::CallLog,
224    ) -> eyre::Result<()> {
225        Ok(())
226    }
227
228    /// Called when a test case fails but to be retried.
229    async fn on_retry(
230        &mut self,
231        _project: String,
232        _module: String,
233        _test_name: String,
234        _test: Test,
235    ) -> eyre::Result<()> {
236        Ok(())
237    }
238
239    /// Called when a test case ends.
240    async fn on_end(
241        &mut self,
242        _project: String,
243        _module: String,
244        _test_name: String,
245        _test: Test,
246    ) -> eyre::Result<()> {
247        Ok(())
248    }
249
250    /// Called when all tests complete with summary statistics.
251    async fn on_summary(&mut self, _summary: runner::TestSummary) -> eyre::Result<()> {
252        Ok(())
253    }
254}
255
256/// A reporter that produces no output.
257///
258/// Useful for testing scenarios where you want to run tests without
259/// any console output, or when implementing custom output handling
260/// outside of the reporter system.
261///
262/// # Examples
263///
264/// ```rust,ignore
265/// use tanu_core::{Runner, reporter::NullReporter};
266///
267/// let mut runner = Runner::new();
268/// runner.add_reporter(NullReporter);
269/// ```
270pub struct NullReporter;
271
272#[async_trait::async_trait]
273impl Reporter for NullReporter {}
274
275/// Capture current states of the stdout for the test case.
276#[allow(clippy::vec_box)]
277#[derive(Default, Debug)]
278struct Buffer {
279    test_number: Option<usize>,
280    http_logs: Vec<Box<http::Log>>,
281    #[cfg(feature = "grpc")]
282    grpc_logs: Vec<Box<crate::grpc::Log>>,
283}
284
285fn generate_test_number() -> usize {
286    static TEST_NUMBER: LazyLock<Mutex<usize>> = LazyLock::new(|| Mutex::new(0));
287    let mut test_number = TEST_NUMBER.lock().unwrap();
288    *test_number += 1;
289    *test_number
290}
291/// A real-time streaming reporter that outputs test results as they happen.
292///
293/// This reporter provides immediate feedback during test execution, showing
294/// test results, retry attempts, and optional HTTP request/response details.
295/// Output is formatted with colors and symbols for easy readability.
296///
297/// # Features
298///
299/// - **Real-time output**: Results appear as tests complete
300/// - **HTTP logging**: Optional detailed HTTP request/response logs
301/// - **Retry indication**: Shows when tests are being retried
302/// - **Colored output**: Success/failure indicators with colors
303/// - **Test numbering**: Sequential numbering for easy reference
304///
305/// # Examples
306///
307/// ```rust,ignore
308/// use tanu_core::{Runner, reporter::ListReporter};
309///
310/// let mut runner = Runner::new();
311/// runner.add_reporter(ListReporter::new(true)); // Enable HTTP logging
312/// ```
313///
314/// # Output Format
315///
316/// ```text
317/// ✓ 1 [staging] api::health_check (45.2ms)
318/// ✘ 2 [production] auth::login (123.4ms):
319/// Error: Authentication failed
320///   => POST https://api.example.com/auth/login
321///   > request:
322///     > headers:
323///        > content-type: application/json
324///   < response:
325///     < headers:
326///        < content-type: application/json
327///     < body: {"error": "invalid credentials"}
328/// ```
329pub struct ListReporter {
330    terminal: Term,
331    buffer: IndexMap<(ProjectName, ModuleName, TestName), Buffer>,
332    capture_http: bool,
333}
334
335impl ListReporter {
336    /// Creates a new list reporter.
337    ///
338    /// # Parameters
339    ///
340    /// - `capture_http`: Whether to include HTTP request/response details in output
341    ///
342    /// # Examples
343    ///
344    /// ```rust,ignore
345    /// use tanu_core::reporter::ListReporter;
346    ///
347    /// // With HTTP logging
348    /// let reporter = ListReporter::new(true);
349    ///
350    /// // Without HTTP logging (faster, less verbose)
351    /// let reporter = ListReporter::new(false);
352    /// ```
353    pub fn new(capture_http: bool) -> ListReporter {
354        ListReporter {
355            terminal: Term::stdout(),
356            buffer: IndexMap::new(),
357            capture_http,
358        }
359    }
360}
361
362#[async_trait::async_trait]
363impl Reporter for ListReporter {
364    async fn on_start(
365        &mut self,
366        project_name: String,
367        module_name: String,
368        test_name: String,
369    ) -> eyre::Result<()> {
370        self.buffer
371            .insert((project_name, module_name, test_name), Buffer::default());
372        Ok(())
373    }
374
375    async fn on_call(
376        &mut self,
377        project_name: String,
378        module_name: String,
379        test_name: String,
380        log: runner::CallLog,
381    ) -> eyre::Result<()> {
382        if self.capture_http {
383            let buffer = self
384                .buffer
385                .get_mut(&(project_name, module_name, test_name.clone()))
386                .ok_or_else(|| eyre::eyre!("test case \"{test_name}\" not found in the buffer"))?;
387            match log {
388                runner::CallLog::Http(http_log) => buffer.http_logs.push(http_log),
389                #[cfg(feature = "grpc")]
390                runner::CallLog::Grpc(grpc_log) => buffer.grpc_logs.push(grpc_log),
391            }
392        }
393        Ok(())
394    }
395
396    async fn on_retry(
397        &mut self,
398        project_name: String,
399        module_name: String,
400        test_name: String,
401        test: Test,
402    ) -> eyre::Result<()> {
403        let buffer = self
404            .buffer
405            .get_mut(&(project_name.clone(), module_name.clone(), test_name.clone()))
406            .ok_or_else(|| eyre::eyre!("test case \"{test_name}\" not found in the buffer",))?;
407
408        let test_number = style(buffer.test_number.get_or_insert_with(generate_test_number)).dim();
409
410        if let Err(e) = test.result {
411            self.terminal.write_line(&format!(
412                "{status} {test_number} {project} {path}: {retry_message}\n{error}",
413                status = symbol_error(),
414                project = style_project(&project_name),
415                path = style_module_path(&module_name, &test_name),
416                retry_message = style("retrying...").blue(),
417                error = style(format!("{e:#}")).dim(),
418            ))?;
419        }
420        Ok(())
421    }
422
423    async fn on_end(
424        &mut self,
425        project_name: String,
426        module_name: String,
427        test_name: String,
428        test: Test,
429    ) -> eyre::Result<()> {
430        let mut buffer = self
431            .buffer
432            .swap_remove(&(project_name.clone(), module_name, test_name.clone()))
433            .ok_or_else(|| eyre::eyre!("test case \"{test_name}\" not found in the buffer"))?;
434
435        for log in buffer.http_logs {
436            // Request line with colored method
437            self.terminal.write_line(&format!(
438                " {} {} {}",
439                style("=>").cyan(),
440                style_http_method(log.request.method.as_ref()),
441                style(&log.request.url.to_string()).underlined()
442            ))?;
443            // Request section
444            self.terminal.write_line(&format!(
445                "  {} {}",
446                style(">").cyan(),
447                style("request:").cyan()
448            ))?;
449            self.terminal.write_line(&format!(
450                "    {} {}",
451                style(">").cyan(),
452                style("headers:").dim()
453            ))?;
454            for key in log.request.headers.keys() {
455                self.terminal.write_line(&format!(
456                    "       {} {}: {}",
457                    style(">").cyan(),
458                    style(key.as_str()).bold(),
459                    style(log.request.headers.get(key).unwrap().to_str().unwrap()).dim()
460                ))?;
461            }
462            // Response section with status code
463            self.terminal.write_line(&format!(
464                "  {} {} {}",
465                style("<").yellow(),
466                style("response:").yellow(),
467                style_status_code(log.response.status.as_u16())
468            ))?;
469            self.terminal.write_line(&format!(
470                "    {} {}",
471                style("<").yellow(),
472                style("headers:").dim()
473            ))?;
474            for key in log.response.headers.keys() {
475                self.terminal.write_line(&format!(
476                    "       {} {}: {}",
477                    style("<").yellow(),
478                    style(key.as_str()).bold(),
479                    style(log.response.headers.get(key).unwrap().to_str().unwrap()).dim()
480                ))?;
481            }
482            self.terminal.write_line(&format!(
483                "    {} {} {}",
484                style("<").yellow(),
485                style("body:").dim(),
486                style(&log.response.body).dim()
487            ))?;
488        }
489
490        // Display gRPC logs
491        #[cfg(feature = "grpc")]
492        for log in buffer.grpc_logs {
493            // Request line with colored gRPC indicator
494            self.terminal.write_line(&format!(
495                " {} {} {}",
496                style("=>").magenta(),
497                style("gRPC").magenta().bold(),
498                style(&log.request.method).underlined()
499            ))?;
500            // Request section
501            self.terminal.write_line(&format!(
502                "  {} {}",
503                style(">").magenta(),
504                style("request:").magenta()
505            ))?;
506            self.terminal.write_line(&format!(
507                "    {} {}",
508                style(">").magenta(),
509                style("metadata:").dim()
510            ))?;
511            for key_value in log.request.metadata.iter() {
512                let (key, value) = match key_value {
513                    tonic::metadata::KeyAndValueRef::Ascii(k, v) => (
514                        k.as_str().to_string(),
515                        v.to_str().unwrap_or("<binary>").to_string(),
516                    ),
517                    tonic::metadata::KeyAndValueRef::Binary(k, v) => (
518                        k.as_str().to_string(),
519                        format!("<binary: {} bytes>", v.as_encoded_bytes().len()),
520                    ),
521                };
522                self.terminal.write_line(&format!(
523                    "       {} {}: {}",
524                    style(">").magenta(),
525                    style(&key).bold(),
526                    style(&value).dim()
527                ))?;
528            }
529            if !log.request.message.is_empty() {
530                self.terminal.write_line(&format!(
531                    "    {} {} {}",
532                    style(">").magenta(),
533                    style("message:").dim(),
534                    style(format!("{} bytes", log.request.message.len())).dim()
535                ))?;
536            }
537            // Response section with status code
538            self.terminal.write_line(&format!(
539                "  {} {} {}",
540                style("<").yellow(),
541                style("response:").yellow(),
542                style_grpc_status(log.response.status_code)
543            ))?;
544            self.terminal.write_line(&format!(
545                "    {} {}",
546                style("<").yellow(),
547                style("metadata:").dim()
548            ))?;
549            for key_value in log.response.metadata.iter() {
550                let (key, value) = match key_value {
551                    tonic::metadata::KeyAndValueRef::Ascii(k, v) => (
552                        k.as_str().to_string(),
553                        v.to_str().unwrap_or("<binary>").to_string(),
554                    ),
555                    tonic::metadata::KeyAndValueRef::Binary(k, v) => (
556                        k.as_str().to_string(),
557                        format!("<binary: {} bytes>", v.as_encoded_bytes().len()),
558                    ),
559                };
560                self.terminal.write_line(&format!(
561                    "       {} {}: {}",
562                    style("<").yellow(),
563                    style(&key).bold(),
564                    style(&value).dim()
565                ))?;
566            }
567            if !log.response.message.is_empty() {
568                self.terminal.write_line(&format!(
569                    "    {} {} {}",
570                    style("<").yellow(),
571                    style("message:").dim(),
572                    style(format!("{} bytes", log.response.message.len())).dim()
573                ))?;
574            }
575            if !log.response.status_message.is_empty() {
576                self.terminal.write_line(&format!(
577                    "    {} {} {}",
578                    style("<").yellow(),
579                    style("status_message:").dim(),
580                    style(&log.response.status_message).dim()
581                ))?;
582            }
583        }
584
585        let status = symbol_test_result(&test);
586        let Test {
587            result,
588            info,
589            request_time,
590            started_at: _,
591            ended_at: _,
592            worker_id: _,
593        } = test;
594        let test_number = style(buffer.test_number.get_or_insert_with(generate_test_number)).dim();
595        let request_time = style(format!("({request_time:.2?})")).dim();
596        let project = style_project(&project_name);
597        let path = style_module_path(&info.module, &info.name);
598        match result {
599            Ok(_res) => {
600                self.terminal.write_line(&format!(
601                    "{status} {test_number} {project} {path} {request_time}"
602                ))?;
603            }
604            Err(e) => {
605                self.terminal.write_line(&format!(
606                    "{status} {test_number} {project} {path} {request_time}:\n{error}",
607                    error = style(format!("{e:#}")).red()
608                ))?;
609            }
610        }
611
612        Ok(())
613    }
614
615    async fn on_summary(&mut self, summary: runner::TestSummary) -> eyre::Result<()> {
616        let runner::TestSummary {
617            total_tests,
618            passed_tests,
619            failed_tests,
620            total_time,
621            test_prep_time,
622        } = summary;
623
624        self.terminal.write_line("")?;
625        self.terminal.write_line(&format!(
626            "{}: {} {}, {} {}, {} {}",
627            style("Tests").bold(),
628            style(passed_tests).green().bold(),
629            style("passed").green(),
630            if failed_tests > 0 {
631                style(failed_tests).red().bold()
632            } else {
633                style(failed_tests).bold()
634            },
635            if failed_tests > 0 {
636                style("failed").red()
637            } else {
638                style("failed")
639            },
640            style(total_tests).bold(),
641            style("total").dim()
642        ))?;
643        self.terminal.write_line(&format!(
644            "{}: {} ({}: {})",
645            style("Time").bold(),
646            style(format!("{total_time:.2?}")).cyan(),
647            style("prep").dim(),
648            style(format!("{test_prep_time:.2?}")).dim()
649        ))?;
650
651        Ok(())
652    }
653}
654
655fn write(term: &Term, s: impl AsRef<str>) -> eyre::Result<()> {
656    let colored = style(s.as_ref()).dim();
657    term.write_line(&format!("{colored}"))
658        .wrap_err("failed to write character on terminal")
659}
660
661fn symbol_test_result(test: &Test) -> StyledObject<&'static str> {
662    match test.result {
663        Ok(_) => symbol_success(),
664        Err(_) => symbol_error(),
665    }
666}
667
668fn symbol_success() -> StyledObject<&'static str> {
669    style("✓").green()
670}
671
672fn symbol_error() -> StyledObject<&'static str> {
673    style("✘").red()
674}
675
676fn emoji_symbol_test_result(test: &Test) -> char {
677    match test.result {
678        Ok(_) => '🟢',
679        Err(_) => '🔴',
680    }
681}
682
683/// Color HTTP methods for visual distinction
684fn style_http_method(method: &str) -> StyledObject<&str> {
685    match method.to_uppercase().as_str() {
686        "GET" => style(method).green(),
687        "POST" => style(method).yellow(),
688        "PUT" => style(method).blue(),
689        "DELETE" => style(method).red(),
690        "PATCH" => style(method).magenta(),
691        "HEAD" => style(method).cyan(),
692        "OPTIONS" => style(method).white(),
693        _ => style(method),
694    }
695}
696
697/// Color HTTP status codes by category
698fn style_status_code(status: u16) -> StyledObject<String> {
699    let s = status.to_string();
700    match status {
701        100..=199 => style(s).cyan(),       // Informational
702        200..=299 => style(s).green(),      // Success
703        300..=399 => style(s).yellow(),     // Redirection
704        400..=499 => style(s).red(),        // Client error
705        500..=599 => style(s).red().bold(), // Server error
706        _ => style(s),
707    }
708}
709
710/// Color gRPC status codes
711#[cfg(feature = "grpc")]
712fn style_grpc_status(code: tonic::Code) -> StyledObject<String> {
713    let s = format!("{:?}", code);
714    match code {
715        tonic::Code::Ok => style(s).green(),
716        tonic::Code::Cancelled
717        | tonic::Code::Unknown
718        | tonic::Code::DeadlineExceeded
719        | tonic::Code::ResourceExhausted
720        | tonic::Code::Aborted
721        | tonic::Code::Unavailable => style(s).yellow(),
722        tonic::Code::InvalidArgument
723        | tonic::Code::NotFound
724        | tonic::Code::AlreadyExists
725        | tonic::Code::PermissionDenied
726        | tonic::Code::FailedPrecondition
727        | tonic::Code::OutOfRange
728        | tonic::Code::Unauthenticated => style(s).red(),
729        tonic::Code::Unimplemented | tonic::Code::Internal | tonic::Code::DataLoss => {
730            style(s).red().bold()
731        }
732    }
733}
734
735/// Style project name with bold magenta color
736fn style_project(name: &str) -> StyledObject<String> {
737    style(format!("[{name}]")).magenta().bold()
738}
739
740/// Style module path with cyan color and test name in bold blue
741fn style_module_path(module: &str, test: &str) -> String {
742    format!("{}::{}", style(module).cyan(), style(test).blue().bold())
743}
744
745#[allow(clippy::vec_box, dead_code)]
746/// A reporter that displays test results in a summary table after all tests complete.
747///
748/// This reporter buffers all test results and displays them in a formatted table
749/// at the end of execution. Useful for getting an overview of all test results
750/// without the noise of real-time output.
751///
752/// # Features
753///
754/// - **Summary table**: Clean tabular output after test completion
755/// - **Project ordering**: Results ordered by project configuration
756/// - **Emoji indicators**: Visual success/failure indicators
757/// - **Modern styling**: Attractive table borders and formatting
758///
759/// # Examples
760///
761/// ```rust,ignore
762/// use tanu_core::{Runner, reporter::TableReporter};
763///
764/// let mut runner = Runner::new();
765/// runner.add_reporter(TableReporter::new(false)); // No HTTP details in table
766/// ```
767///
768/// # Output Format
769///
770/// ```text
771/// ┌─────────┬────────┬──────────────┬────────┐
772/// │ Project │ Module │ Test         │ Result │
773/// ├─────────┼────────┼──────────────┼────────┤
774/// │ staging │ api    │ health_check │   🟢   │
775/// │ staging │ auth   │ login        │   🔴   │
776/// │ prod    │ api    │ status       │   🟢   │
777/// └─────────┴────────┴──────────────┴────────┘
778/// ```
779pub struct TableReporter {
780    terminal: Term,
781    buffer: HashMap<(ProjectName, ModuleName, TestName), Test>,
782    capture_http: bool,
783}
784
785impl TableReporter {
786    /// Creates a new table reporter.
787    ///
788    /// # Parameters
789    ///
790    /// - `capture_http`: Whether to capture HTTP details (currently unused in table output)
791    ///
792    /// # Examples
793    ///
794    /// ```rust,ignore
795    /// use tanu_core::reporter::TableReporter;
796    ///
797    /// let reporter = TableReporter::new(false);
798    /// ```
799    pub fn new(capture_http: bool) -> TableReporter {
800        TableReporter {
801            terminal: Term::stdout(),
802            buffer: HashMap::new(),
803            capture_http,
804        }
805    }
806}
807
808#[async_trait::async_trait]
809impl Reporter for TableReporter {
810    async fn run(&mut self) -> eyre::Result<()> {
811        run(self).await?;
812
813        let project_order: Vec<_> = get_tanu_config().projects.iter().map(|p| &p.name).collect();
814
815        let mut builder = tabled::builder::Builder::default();
816        builder.push_record(["Project", "Module", "Test", "Result"]);
817        self.buffer
818            .drain()
819            .sorted_by(|(a, _), (b, _)| {
820                let project_order_a = project_order
821                    .iter()
822                    .position(|&p| *p == a.0)
823                    .unwrap_or(usize::MAX);
824                let project_order_b = project_order
825                    .iter()
826                    .position(|&p| *p == b.0)
827                    .unwrap_or(usize::MAX);
828
829                project_order_a
830                    .cmp(&project_order_b)
831                    .then(a.1.cmp(&b.1))
832                    .then(a.2.cmp(&b.2))
833            })
834            .for_each(|((p, m, t), test)| {
835                builder.push_record([p, m, t, emoji_symbol_test_result(&test).to_string()])
836            });
837
838        let mut table = builder.build();
839        table.with(tabled::settings::Style::modern()).with(
840            tabled::settings::Modify::new(tabled::settings::object::Columns::single(3))
841                .with(tabled::settings::Alignment::center()),
842        );
843
844        write(&self.terminal, format!("{table}")).wrap_err("failed to write table on terminal")?;
845
846        Ok(())
847    }
848
849    async fn on_end(
850        &mut self,
851        project_name: String,
852        module_name: String,
853        test_name: String,
854        test: Test,
855    ) -> eyre::Result<()> {
856        self.buffer
857            .insert((project_name, module_name, test_name), test);
858        Ok(())
859    }
860
861    async fn on_summary(&mut self, summary: runner::TestSummary) -> eyre::Result<()> {
862        let runner::TestSummary {
863            total_tests,
864            passed_tests,
865            failed_tests,
866            total_time,
867            test_prep_time,
868        } = summary;
869
870        self.terminal.write_line("")?;
871        self.terminal.write_line(&format!(
872            "{}: {} {}, {} {}, {} {}",
873            style("Tests").bold(),
874            style(passed_tests).green().bold(),
875            style("passed").green(),
876            if failed_tests > 0 {
877                style(failed_tests).red().bold()
878            } else {
879                style(failed_tests).bold()
880            },
881            if failed_tests > 0 {
882                style("failed").red()
883            } else {
884                style("failed")
885            },
886            style(total_tests).bold(),
887            style("total").dim()
888        ))?;
889        self.terminal.write_line(&format!(
890            "{}: {} ({}: {})",
891            style("Time").bold(),
892            style(format!("{total_time:.2?}")).cyan(),
893            style("prep").dim(),
894            style(format!("{test_prep_time:.2?}")).dim()
895        ))?;
896
897        Ok(())
898    }
899}