Skip to main content

tracing_scribe/
lib.rs

1// © Copyright 2026 Helsing GmbH. All rights reserved.
2//! A `tracing-subscriber` layer for beautifully printing spans and events to the
3//! terminal.
4//!
5//! This crate provides a `ConsoleLayer` that can be used with `tracing` to format
6//! and display log messages in a hierarchical and easy-to-read format. It is
7//! designed to make command line applications more pleasant by providing clear
8//! visual cues for different log levels, tracking the duration of spans, and
9//! indicating success or failure states.
10//!
11//! ## Features
12//!
13//! - **Pretty Printing**: Spans and events are displayed with icons, colors, and
14//!   indentation to create a clear visual hierarchy.
15//! - **Automatic `Result` Handling**: The `console!` macro automatically logs
16//!   the outcome of a `Result`, indicating success or failure.
17//! - **Span Timings**: The layer automatically tracks and displays the execution
18//!   time of each span.
19//! - **Error Propagation**: Errors inside spans are propagated to parent spans,
20//!   marking them as failed.
21//!
22//! ## API Documentation
23//!
24//! [Click here!](https://docs.rs/tracing-scribe/latest/tracing_scribe/)
25//!
26//! ## Usage
27//!
28//! To use `ConsoleLayer`, add it to your `tracing` subscriber registry. The
29//! `console!` macro can then be used to log events with different status levels.
30//!
31//! ### Example
32//!
33//! Here is a complete example demonstrating a successful execution with nested
34//! spans and `#[tracing::instrument]`.
35//!
36//! ```rust
37//! use tracing_scribe::console;
38//! use tracing::info_span;
39//! use tracing_subscriber::prelude::*;
40//! use tracing_subscriber::registry::Registry;
41//!
42//! #[tracing::instrument]
43//! fn outer_task() {
44//!     inner_task();
45//! }
46//!
47//! #[tracing::instrument]
48//! fn inner_task() {
49//!     console!(notice, "Generating unicorns");
50//!     std::thread::sleep(std::time::Duration::from_millis(100));
51//!     console!(info, "Unicorns generation completed");
52//! }
53//!
54//! let subscriber = Registry::default().with(tracing_scribe::ConsoleLayer::default());
55//! tracing::subscriber::with_default(subscriber, || {
56//!     let _span = info_span!("main").entered();
57//!     outer_task();
58//! });
59//! ```
60//!
61//! This will produce the following output:
62//!
63//! ```text
64//! ╭─ ⚙ main
65//! │  ╭─ ⚙ outer_task
66//! │  │  ╭─ ⚙ inner_task
67//! │  │  │  ├─ ! Generating unicorns
68//! │  │  │  ├─ ✔ Unicorns generation completed
69//! │  │  ╰─ ✔ inner_task ⏱ 100.2ms
70//! │  ╰─ ✔ outer_task ⏱ 100.3ms
71//! ╰─ ✔ main succeeded ⏱ 100.3ms
72//! ```
73//!
74//! ### Error Handling
75//!
76//! The `ConsoleLayer` also handles errors gracefully. When a span instrumented
77//! with `#[tracing::instrument(err)]` returns an error, the span and its parents
78//! are marked as failed.
79//!
80//! ```rust
81//! use tracing_scribe::console;
82//! use tracing::info_span;
83//! use tracing_subscriber::prelude::*;
84//! use tracing_subscriber::registry::Registry;
85//! use std::error::Error;
86//! use std::fmt;
87//!
88//! #[derive(Debug)]
89//! struct MyError;
90//!
91//! impl Error for MyError {}
92//!
93//! impl fmt::Display for MyError {
94//!     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95//!         write!(f, "Something went wrong")
96//!     }
97//! }
98//!
99//! #[tracing::instrument(err)]
100//! fn failing_task() -> Result<(), MyError> {
101//!     console!(error, "This task is about to fail");
102//!     Err(MyError)
103//! }
104//!
105//! let subscriber = Registry::default().with(tracing_scribe::ConsoleLayer::default());
106//! tracing::subscriber::with_default(subscriber, || {
107//!     let _span = info_span!("main").entered();
108//!     failing_task().ok();
109//! });
110//! ```
111//!
112//! This will produce the following output:
113//!
114//! ```text
115//! ╭─ ⚙ main
116//! │  ╭─ ⚙ failing_task
117//! │  │  ├─ ⨯ This task is about to fail
118//! │  │  ├─ ⨯ Something went wrong
119//! │  ╰─ ⨯ failing_task ⏱ 63.8µs
120//! ╰─ ⨯ main failed ⏱ 111.4µs
121//! ```
122//!
123//! ### Asynchronous tasks
124//!
125//! Asynchronous tasks are handled their own way by tracing. To make sure they end up with the
126//! right indentation, you might have to wrap them with a tracing instrumentation call.
127//!
128//! ```no_run
129//! use tracing_scribe::console;
130//! use tracing::Instrument;
131//!
132//! #[tracing::instrument]
133//! async fn outer_task() {
134//!     tokio::spawn(
135//!         inner_task().instrument(tracing::Span::current())
136//!     );
137//! }
138//!
139//! #[tracing::instrument]
140//! async fn inner_task() {
141//!     console!(notice, "Generating async unicorns");
142//!     tokio::time::sleep(std::time::Duration::from_millis(100)).await;
143//!     console!(info, "Async unicorns generation completed");
144//! }
145//! ```
146//!
147//! This will produce the following output:
148//!
149//! ```text
150//! ╭─ ⚙ main
151//! │  ╭─ ⚙ outer_task
152//! │  │  ╭─ ⚙ inner_task
153//! │  │  │  ├─ ! Generating async unicorns
154//! │  │  │  ├─ ✔ Async unicorns generation completed
155//! │  │  ╰─ ✔ inner_task ⏱ 100.2ms
156//! │  ╰─ ✔ outer_task ⏱ 100.3ms
157//! ╰─ ✔ main succeeded ⏱ 100.3ms
158//! ```
159//!
160//! ### Attaching Extra Information
161//!
162//! The `extra` field can be used to attach additional context to a span, which
163//! will be displayed alongside the span name.
164//!
165//! ```rust
166//! use tracing_scribe::console;
167//! use tracing::info_span;
168//! use tracing_subscriber::prelude::*;
169//! use tracing_subscriber::registry::Registry;
170//!
171//! #[tracing::instrument(fields(extra = "some extra info"))]
172//! fn task_with_extra() {
173//!     console!(info, "This task has extra information");
174//! }
175//!
176//! let subscriber = Registry::default().with(tracing_scribe::ConsoleLayer::default());
177//! tracing::subscriber::with_default(subscriber, || {
178//!     let _span = info_span!("main").entered();
179//!     task_with_extra();
180//! });
181//! ```
182//!
183//! This will produce the following output:
184//!
185//! ```text
186//! ╭─ ⚙ main
187//! │  ╭─ ⚙ task_with_extra some extra info
188//! │  │  ├─ ✔ This task has extra information
189//! │  ╰─ ✔ task_with_extra some extra info ⏱ 2.8µs
190//! ╰─ ✔ main succeeded ⏱ 23.3µs
191//! ```
192//! ### Custom Status
193//!
194//! The `with_status` method allows you to customize the icons and colors used
195//! for the different statuses.
196//!
197//! ```rust
198//! use tracing_scribe::{console, ConsoleLayer, StatusFactory, Status};
199//! use tracing::info_span;
200//! use tracing_subscriber::prelude::*;
201//! use tracing_subscriber::registry::Registry;
202//! use owo_colors::OwoColorize;
203//! use std::io::{self, Write};
204//!
205//! #[derive(Clone, Copy)]
206//! pub struct CustomStatusFactory;
207//!
208//! impl StatusFactory<CustomStatus> for CustomStatusFactory {
209//!     fn group(&self) -> CustomStatus {
210//!         CustomStatus::Group
211//!     }
212//!     fn success(&self) -> CustomStatus {
213//!         CustomStatus::Success
214//!     }
215//!     fn failure(&self) -> CustomStatus {
216//!         CustomStatus::Failure
217//!     }
218//! }
219//!
220//! #[derive(Debug, Clone, Copy)]
221//! pub enum CustomStatus {
222//!     Success,
223//!     Failure,
224//!     Group,
225//!     Pass,
226//! }
227//!
228//! impl Status for CustomStatus {
229//!     fn from_str(s: &str) -> Self {
230//!         match s {
231//!             "success" => Self::Success,
232//!             _ => Self::Failure,
233//!         }
234//!     }
235//!
236//!     fn write_icon<W: Write>(&self, mut out: W, colors: &tracing_scribe::ColorScheme) -> io::Result<()> {
237//!         match self {
238//!             Self::Success => write!(out, "{}", colors.success.style("●")),
239//!             Self::Failure => write!(out, "{}", colors.error.style("●")),
240//!             Self::Group => write!(out, "{}", colors.group.style("●")),
241//!             Self::Pass => write!(out, ""),
242//!         }
243//!     }
244//!
245//!     fn is_passthrough(&self) -> bool {
246//!         matches!(self, Self::Pass)
247//!     }
248//! }
249//!
250//! let subscriber = Registry::default().with(
251//!     ConsoleLayer::default().with_status(CustomStatusFactory)
252//! );
253//! tracing::subscriber::with_default(subscriber, || {
254//!     let _span = info_span!("main").entered();
255//!     console!(warn, "This is a custom status");
256//! });
257//! ```
258//!
259//! This will produce the following output:
260//!
261//! ```text
262//! ╭─ ● main
263//! │  ├─ ● This is a custom status
264//! ╰─ ● main succeeded
265//! ```
266use std::io::{self, Write};
267use std::marker::PhantomData;
268use std::sync::Arc;
269use std::sync::atomic::{AtomicBool, Ordering};
270use std::time::{Duration, Instant};
271
272use owo_colors::{OwoColorize, Style};
273use tracing::field::Visit;
274use tracing::{Event, Id};
275use tracing_subscriber::fmt::MakeWriter;
276use tracing_subscriber::layer::{Context, Layer};
277use tracing_subscriber::registry::LookupSpan;
278
279/// A color scheme for customizing the appearance of console output.
280///
281/// This struct allows you to customize the colors used for different elements
282/// in the console output, including status icons, tree structure, messages,
283/// and various text elements.
284///
285/// ## Example
286///
287/// ```rust
288/// use tracing_scribe::{ConsoleLayer, ColorScheme};
289/// use owo_colors::{Style, colors};
290/// use tracing_subscriber::prelude::*;
291/// use tracing_subscriber::registry::Registry;
292///
293/// let colors = ColorScheme::default()
294///     .with_success_color(colors::BrightGreen)
295///     .with_error_color(colors::BrightRed)
296///     .with_tree_color(colors::Cyan);
297///
298/// let layer = ConsoleLayer::default().with_colors(colors);
299/// let subscriber = Registry::default().with(layer);
300/// ```
301#[derive(Clone, Debug)]
302pub struct ColorScheme {
303    /// Style for success icons and messages
304    pub success: Style,
305    /// Style for error/failure icons and messages
306    pub error: Style,
307    /// Style for warning icons and messages
308    pub warning: Style,
309    /// Style for notice icons and messages
310    pub notice: Style,
311    /// Style for group/span icons
312    pub group: Style,
313    /// Style for tree structure characters (pipes, boxes)
314    pub tree: Style,
315    /// Style for the "extra" field in spans
316    pub extra: Style,
317    /// Style for the "succeeded" message
318    pub succeeded: Style,
319    /// Style for the "failed" message
320    pub failed: Style,
321    /// Style for duration/clock text
322    pub duration: Style,
323}
324
325impl Default for ColorScheme {
326    /// Creates a default color scheme matching the original hardcoded colors.
327    fn default() -> Self {
328        Self {
329            success: Style::new().green(),
330            error: Style::new().red(),
331            warning: Style::new().yellow(),
332            notice: Style::new().purple(),
333            group: Style::new().dimmed(),
334            tree: Style::new().blue(),
335            extra: Style::new().cyan(),
336            succeeded: Style::new().green().bold(),
337            failed: Style::new().red().bold(),
338            duration: Style::new().dimmed(),
339        }
340    }
341}
342
343impl ColorScheme {
344    /// Creates a new color scheme with all colors set to their defaults.
345    pub fn new() -> Self {
346        Self::default()
347    }
348
349    /// Sets the color for success icons and messages.
350    ///
351    /// # Example
352    ///
353    /// ```rust
354    /// use tracing_scribe::ColorScheme;
355    /// use owo_colors::colors::BrightGreen;
356    ///
357    /// let colors = ColorScheme::new().with_success_color(BrightGreen);
358    /// ```
359    pub fn with_success_color<C: owo_colors::Color>(mut self, _color: C) -> Self {
360        self.success = Style::new().fg::<C>();
361        self
362    }
363
364    /// Sets the style for success icons and messages.
365    pub fn with_success_style(mut self, style: Style) -> Self {
366        self.success = style;
367        self
368    }
369
370    /// Sets the color for error/failure icons and messages.
371    pub fn with_error_color<C: owo_colors::Color>(mut self, _color: C) -> Self {
372        self.error = Style::new().fg::<C>();
373        self
374    }
375
376    /// Sets the style for error/failure icons and messages.
377    pub fn with_error_style(mut self, style: Style) -> Self {
378        self.error = style;
379        self
380    }
381
382    /// Sets the color for warning icons and messages.
383    pub fn with_warning_color<C: owo_colors::Color>(mut self, _color: C) -> Self {
384        self.warning = Style::new().fg::<C>();
385        self
386    }
387
388    /// Sets the style for warning icons and messages.
389    pub fn with_warning_style(mut self, style: Style) -> Self {
390        self.warning = style;
391        self
392    }
393
394    /// Sets the color for notice icons and messages.
395    pub fn with_notice_color<C: owo_colors::Color>(mut self, _color: C) -> Self {
396        self.notice = Style::new().fg::<C>();
397        self
398    }
399
400    /// Sets the style for notice icons and messages.
401    pub fn with_notice_style(mut self, style: Style) -> Self {
402        self.notice = style;
403        self
404    }
405
406    /// Sets the color for group/span icons.
407    pub fn with_group_color<C: owo_colors::Color>(mut self, _color: C) -> Self {
408        self.group = Style::new().fg::<C>();
409        self
410    }
411
412    /// Sets the style for group/span icons.
413    pub fn with_group_style(mut self, style: Style) -> Self {
414        self.group = style;
415        self
416    }
417
418    /// Sets the color for tree structure characters.
419    pub fn with_tree_color<C: owo_colors::Color>(mut self, _color: C) -> Self {
420        self.tree = Style::new().fg::<C>();
421        self
422    }
423
424    /// Sets the style for tree structure characters.
425    pub fn with_tree_style(mut self, style: Style) -> Self {
426        self.tree = style;
427        self
428    }
429
430    /// Sets the color for the "extra" field in spans.
431    pub fn with_extra_color<C: owo_colors::Color>(mut self, _color: C) -> Self {
432        self.extra = Style::new().fg::<C>();
433        self
434    }
435
436    /// Sets the style for the "extra" field in spans.
437    pub fn with_extra_style(mut self, style: Style) -> Self {
438        self.extra = style;
439        self
440    }
441
442    /// Sets the color for the "succeeded" message.
443    pub fn with_succeeded_color<C: owo_colors::Color>(mut self, _color: C) -> Self {
444        self.succeeded = Style::new().fg::<C>().bold();
445        self
446    }
447
448    /// Sets the style for the "succeeded" message.
449    pub fn with_succeeded_style(mut self, style: Style) -> Self {
450        self.succeeded = style;
451        self
452    }
453
454    /// Sets the color for the "failed" message.
455    pub fn with_failed_color<C: owo_colors::Color>(mut self, _color: C) -> Self {
456        self.failed = Style::new().fg::<C>().bold();
457        self
458    }
459
460    /// Sets the style for the "failed" message.
461    pub fn with_failed_style(mut self, style: Style) -> Self {
462        self.failed = style;
463        self
464    }
465
466    /// Sets the color for duration/clock text.
467    pub fn with_duration_color<C: owo_colors::Color>(mut self, _color: C) -> Self {
468        self.duration = Style::new().fg::<C>();
469        self
470    }
471
472    /// Sets the style for duration/clock text.
473    pub fn with_duration_style(mut self, style: Style) -> Self {
474        self.duration = style;
475        self
476    }
477}
478
479/// A trait that defines the behavior of a status.
480///
481/// This trait can be implemented to customize the icons and colors used in the
482/// console output.
483pub trait Status: Send + Sync {
484    /// Creates a `Status` from a string.
485    ///
486    /// This is used by the `console!` macro to create a status from the level
487    /// specified in the macro invocation.
488    fn from_str(s: &str) -> Self;
489
490    /// Writes the icon for the status to the given writer.
491    fn write_icon<W: Write>(&self, out: W, colors: &ColorScheme) -> io::Result<()>;
492
493    /// Specify which status code can be used for passthrough printing, allowing to remove
494    /// icons and styles when using the `pass` argument to the `console!` macro.
495    fn is_passthrough(&self) -> bool;
496}
497
498/// A trait that is responsible for creating statuses.
499///
500/// This trait can be implemented to customize the statuses used in the console
501/// output.
502pub trait StatusFactory<S: Status>: Send + Sync {
503    /// Creates a status for a group.
504    fn group(&self) -> S;
505
506    /// Creates a status for a success.
507    fn success(&self) -> S;
508
509    /// Creates a status for a failure.
510    fn failure(&self) -> S;
511}
512
513/// The default status implementation.
514#[derive(Debug, Clone, Copy)]
515pub enum DefaultStatus {
516    Notice,
517    Success,
518    Failure,
519    Warning,
520    Group,
521    Pass,
522}
523
524impl Status for DefaultStatus {
525    fn from_str(s: &str) -> Self {
526        match s {
527            "notice" => Self::Notice,
528            "success" => Self::Success,
529            "warn" => Self::Warning,
530            "error" => Self::Failure,
531            "pass" => Self::Pass,
532            _ => Self::Success,
533        }
534    }
535
536    fn write_icon<W: Write>(&self, mut out: W, colors: &ColorScheme) -> io::Result<()> {
537        match self {
538            Self::Notice => write!(out, "{}", colors.notice.style("!")),
539            Self::Success => write!(out, "{}", colors.success.style("✔")),
540            Self::Failure => write!(out, "{}", colors.error.style("⨯")),
541            Self::Warning => write!(out, "{}", colors.warning.style("⚠")),
542            Self::Group => write!(out, "{}", colors.group.style("⚙")),
543            Self::Pass => write!(out, ""),
544        }
545    }
546
547    fn is_passthrough(&self) -> bool {
548        matches!(self, Self::Pass)
549    }
550}
551
552/// The default status factory.
553#[derive(Debug, Clone, Copy, Default)]
554pub struct DefaultStatusFactory;
555
556impl StatusFactory<DefaultStatus> for DefaultStatusFactory {
557    fn group(&self) -> DefaultStatus {
558        DefaultStatus::Group
559    }
560
561    fn success(&self) -> DefaultStatus {
562        DefaultStatus::Success
563    }
564
565    fn failure(&self) -> DefaultStatus {
566        DefaultStatus::Failure
567    }
568}
569
570/// Format a duration with 1 decimal place, choosing the most readable unit.
571///
572/// Tier thresholds are set so that rounding to 1 decimal place never produces
573/// `"1000.0ms"` or `"1000.0µs"` — values that would round up are promoted to
574/// the next unit instead. Sub-microsecond durations are displayed as whole
575/// nanoseconds.
576fn format_duration(d: Duration) -> String {
577    let nanos = d.as_nanos();
578    // 999_950_000ns rounds to 1000.0ms at 1 decimal place, so promote to seconds.
579    if nanos >= 999_950_000 {
580        format!("{:.1}s", d.as_secs_f64())
581    } else if nanos >= 999_950 {
582        // 999_950ns rounds to 1000.0µs at 1 decimal place, so promote to milliseconds.
583        format!("{:.1}ms", nanos as f64 / 1_000_000.0)
584    } else if nanos >= 1_000 {
585        format!("{:.1}µs", nanos as f64 / 1_000.0)
586    } else {
587        format!("{nanos}ns")
588    }
589}
590
591/// A `tracing-subscriber` layer that formats and prints events to the console.
592///
593/// This layer is responsible for the hierarchical and colorful output, including
594/// span timings, status icons, and error propagation. It keeps track of the
595/// state of each span and formats messages accordingly.
596#[derive(Debug)]
597pub struct ConsoleLayer<W, F = DefaultStatusFactory, S = DefaultStatus> {
598    make_writer: W,
599    status_factory: F,
600    colors: Arc<ColorScheme>,
601    _status: PhantomData<S>,
602}
603
604impl Default for ConsoleLayer<fn() -> io::Stderr> {
605    /// Creates a new `ConsoleLayer` that writes to `io::stderr`.
606    ///
607    /// This is the default constructor and the most common way to create a
608    /// `ConsoleLayer`.
609    fn default() -> Self {
610        Self {
611            make_writer: io::stderr,
612            status_factory: DefaultStatusFactory,
613            colors: Arc::new(ColorScheme::default()),
614            _status: PhantomData,
615        }
616    }
617}
618
619impl<W> ConsoleLayer<W> {
620    /// Creates a new `ConsoleLayer` that writes to a custom writer.
621    ///
622    /// This allows for capturing output in tests or redirecting it to a file
623    /// or other sink.
624    pub fn with_writer(make_writer: W) -> Self {
625        Self {
626            make_writer,
627            status_factory: DefaultStatusFactory,
628            colors: Arc::new(ColorScheme::default()),
629            _status: PhantomData,
630        }
631    }
632}
633
634impl<W, F, S> ConsoleLayer<W, F, S> {
635    // The following constants define the characters used to draw the tree
636    // structure in the console output.
637
638    /// The string printed at the beginning of a root span.
639    const HEADER_START: &str = "╭─";
640    /// The string printed at the end of a root span.
641    const FOOTER_START: &str = "╰─";
642    /// The string printed at the beginning of a nested span.
643    const GROUP_START: &str = "╭─";
644    /// The string printed at the end of a nested span.
645    const GROUP_END: &str = "╰─";
646    /// The string printed before an event message.
647    const ITEM_START: &str = "├─";
648    /// The string used to draw a continuous vertical line.
649    const PIPE: &str = "│  ";
650    /// The string used to draw a vertical line with a prefix.
651    const PIPE_PREFIX: &str = "│  ";
652    /// The icon used to indicate a time measurement.
653    const CLOCK: &str = "⏱";
654
655    /// Creates a new `ConsoleLayer` with a custom status factory.
656    pub fn with_status<CustomF, CustomS>(
657        self,
658        status_factory: CustomF,
659    ) -> ConsoleLayer<W, CustomF, CustomS>
660    where
661        CustomF: StatusFactory<CustomS>,
662        CustomS: Status,
663    {
664        ConsoleLayer {
665            make_writer: self.make_writer,
666            status_factory,
667            colors: self.colors,
668            _status: PhantomData,
669        }
670    }
671
672    /// Sets a custom color scheme for the console output.
673    ///
674    /// This allows you to customize the colors used for different elements
675    /// in the output, such as status icons, tree structure, and messages.
676    ///
677    /// # Example
678    ///
679    /// ```rust
680    /// use tracing_scribe::{ConsoleLayer, ColorScheme};
681    /// use owo_colors::colors;
682    ///
683    /// let colors = ColorScheme::default()
684    ///     .with_success_color(colors::BrightGreen)
685    ///     .with_error_color(colors::BrightRed);
686    ///
687    /// let layer = ConsoleLayer::default().with_colors(colors);
688    /// ```
689    pub fn with_colors(mut self, colors: ColorScheme) -> Self {
690        self.colors = Arc::new(colors);
691        self
692    }
693
694    /// Prints a single line to the console with the proper formatting.
695    ///
696    /// This internal method is responsible for constructing the final output string
697    /// for a single event or span boundary. It combines the indentation, status
698    /// icon, message, and optional duration into a single formatted line.
699    fn print_line<St: Status>(
700        &self,
701        depth: usize,
702        start_symbol: &str,
703        status: St,
704        message: &str,
705        duration: Option<Duration>,
706        bold: bool,
707    ) where
708        W: for<'writer> MakeWriter<'writer>,
709    {
710        let mut out = self.make_writer.make_writer();
711
712        if depth > 0 {
713            // Every other level has one initial pipe, followed by (depth - 1) prefixes.
714            write!(out, "{}", self.colors.tree.style(Self::PIPE)).ok();
715            for _ in 1..depth {
716                write!(out, "{}", self.colors.tree.style(Self::PIPE_PREFIX)).ok();
717            }
718        }
719
720        write!(out, "{}", self.colors.tree.style(start_symbol)).ok();
721        write!(out, " ").ok();
722        status.write_icon(&mut out, &self.colors).ok();
723        if bold {
724            write!(out, " {}", message.bold()).ok();
725        } else {
726            write!(out, " {}", message).ok();
727        }
728        if let Some(d) = duration {
729            write!(
730                out,
731                " {}",
732                self.colors
733                    .duration
734                    .style(format!("{} {} ", Self::CLOCK, format_duration(d)))
735            )
736            .ok();
737        }
738        writeln!(out).ok();
739    }
740}
741
742#[derive(Debug)]
743struct ConsoleSpanInfo {
744    extra: Option<String>,
745    has_failed: AtomicBool,
746}
747
748impl<S, W, F, St> Layer<S> for ConsoleLayer<W, F, St>
749where
750    S: tracing::Subscriber + for<'lookup> LookupSpan<'lookup>,
751    W: for<'writer> MakeWriter<'writer> + 'static + Send + Sync,
752    F: StatusFactory<St> + 'static,
753    St: Status + 'static,
754{
755    /// Called when a new span is created.
756    ///
757    /// This method is called by the `tracing` framework whenever a new span
758    /// is created. We use this to extract any relevant fields from the span
759    /// and attach them to the span's extensions for later use. This allows
760    /// us to access fields like `extra` when the span is entered or closed.
761    fn on_new_span(&self, attrs: &tracing::span::Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
762        if let Some(span) = ctx.span(id) {
763            let mut visitor = FieldVisitor::default();
764            attrs.record(&mut visitor);
765
766            span.extensions_mut().insert(ConsoleSpanInfo {
767                extra: visitor.extra,
768                has_failed: AtomicBool::new(false),
769            });
770        }
771    }
772
773    /// Called when a span is entered.
774    ///
775    /// This is triggered when the `tracing` instrumentation context is entered.
776    /// We record the `Instant` of entry to calculate the span's duration later.
777    /// It then prints the formatted header for the span, indicating that a new
778    /// block of execution has started.
779    fn on_enter(&self, id: &Id, ctx: Context<'_, S>) {
780        if let Some(span) = ctx.span(id) {
781            if span.extensions().get::<Instant>().is_some() {
782                return;
783            }
784            span.extensions_mut().insert(Instant::now());
785
786            let metadata = span.metadata();
787            let extensions = span.extensions();
788            let info = extensions.get::<ConsoleSpanInfo>().unwrap();
789            let depth = span.scope().from_root().count() - 1;
790            let start_symbol = if depth == 0 {
791                Self::HEADER_START
792            } else {
793                Self::GROUP_START
794            };
795            let message = if let Some(extra) = &info.extra {
796                format!("{} {}", metadata.name(), self.colors.extra.style(extra))
797            } else {
798                metadata.name().to_string()
799            };
800            self.print_line(
801                depth,
802                start_symbol,
803                self.status_factory.group(),
804                &message,
805                None,
806                true,
807            );
808        }
809    }
810
811    /// Called when an event is dispatched.
812    ///
813    /// This method is the core of the event logging logic. It receives all
814    /// `tracing` events and decides how to display them.
815    ///
816    /// - It uses the `FieldVisitor` to extract relevant data like the message,
817    ///   status, and error details.
818    /// - It checks if the event represents a failure (i.e., has a `Level::ERROR`).
819    /// - If an event is a failure, it propagates the failed state to all parent spans.
820    /// - It formats and prints the event message, but only for events created
821    ///   with the `console!` macro or for events that represent an error. This
822    ///   is to avoid printing standard `tracing` events that are not intended
823    ///   for this layer.
824    fn on_event(&self, event: &Event, ctx: Context<'_, S>) {
825        let mut visitor = FieldVisitor::default();
826        event.record(&mut visitor);
827
828        // An event is a failure if its level is ERROR.
829        // This catches errors from `#[instrument(err)]` and `tracing::error!`.
830        let is_failure = *event.metadata().level() == tracing::Level::ERROR;
831
832        let mut message = visitor.message.clone();
833
834        // For non-console errors (e.g. from `#[instrument(err)]` propagation),
835        // check whether the innermost span was already marked as failed BEFORE
836        // we propagate. If it was, a child span already printed this error —
837        // we still propagate has_failed but skip printing the duplicate message.
838        let was_already_failed = if is_failure && !visitor.console {
839            ctx.event_scope(event)
840                .and_then(|mut scope| scope.next())
841                .and_then(|span| {
842                    span.extensions()
843                        .get::<ConsoleSpanInfo>()
844                        .map(|info| info.has_failed.load(Ordering::SeqCst))
845                })
846                .unwrap_or(false)
847        } else {
848            false
849        };
850
851        if is_failure {
852            // Mark all parent spans as failed
853            if let Some(scope) = ctx.event_scope(event) {
854                for span in scope.from_root() {
855                    if let Some(info) = span.extensions().get::<ConsoleSpanInfo>() {
856                        info.has_failed.store(true, Ordering::SeqCst);
857                    }
858                }
859            }
860            // Prepend error details to the message if available
861            if let Some(error) = visitor.error {
862                if message.is_empty() {
863                    message = error;
864                } else {
865                    message = format!("{message}: {error}");
866                }
867            }
868        }
869
870        // Only print events that are explicitly for the console, or are
871        // first-time errors. Duplicate errors from `#[instrument(err)]`
872        // propagation are suppressed — the span close lines still show ⨯.
873        if visitor.console || (is_failure && !was_already_failed) {
874            let status = if visitor.console {
875                St::from_str(&visitor.status)
876            } else {
877                // It must be a failure if we got here without `visitor.console` being true
878                self.status_factory.failure()
879            };
880
881            let (start_symbol, bold) = if status.is_passthrough() {
882                ("", false)
883            } else {
884                (Self::ITEM_START, true)
885            };
886
887            let depth = ctx.event_scope(event).map_or(0, |scope| scope.count());
888            self.print_line(depth, start_symbol, status, &message, None, bold);
889        }
890    }
891
892    /// Called when a span is closed.
893    ///
894    /// When a span's context is exited, this method is called. It calculates
895    /// the total duration the span was active by comparing the current `Instant`
896    /// with the one stored in `on_enter`.
897    ///
898    /// It then determines the final status of the span (success or failure) based
899    /// on whether any errors were reported within it. Finally, it prints the
900    /// formatted footer for the span, including its total duration and final status.
901    fn on_close(&self, id: Id, ctx: Context<'_, S>) {
902        if let Some(span) = ctx.span(&id) {
903            let extensions = span.extensions();
904            let info = extensions.get::<ConsoleSpanInfo>().unwrap();
905
906            let duration = extensions
907                .get::<Instant>()
908                .map(|start_time| start_time.elapsed());
909
910            let metadata = span.metadata();
911            let has_failed = info.has_failed.load(Ordering::SeqCst);
912            let status = if has_failed {
913                self.status_factory.failure()
914            } else {
915                self.status_factory.success()
916            };
917
918            let base_message = if let Some(extra) = &info.extra {
919                format!("{} {}", metadata.name(), self.colors.extra.style(extra))
920            } else {
921                metadata.name().to_string()
922            };
923
924            let depth = span.scope().from_root().count() - 1;
925            let (final_message, start_symbol) = if depth == 0 {
926                let status_string = if has_failed {
927                    self.colors.failed.style("failed").to_string()
928                } else {
929                    self.colors.succeeded.style("succeeded").to_string()
930                };
931                (
932                    format!("{base_message} {status_string}"),
933                    Self::FOOTER_START,
934                )
935            } else {
936                (base_message, Self::GROUP_END)
937            };
938            self.print_line(depth, start_symbol, status, &final_message, duration, true);
939        }
940    }
941}
942
943/// A visitor to extract "status", "message", and other relevant fields from a
944/// tracing event or span.
945///
946/// This struct implements the `tracing::field::Visit` trait, which allows it to
947/// traverse the fields of an event or span and capture the values we care about.
948/// An instance of `FieldVisitor` is created for each event and span to collect
949/// the necessary data for formatting the console output.
950#[derive(Default)]
951struct FieldVisitor {
952    status: String,
953    message: String,
954    console: bool,
955    extra: Option<String>,
956    error: Option<String>,
957}
958
959impl Visit for FieldVisitor {
960    /// Captures string values for fields we are interested in.
961    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
962        match field.name() {
963            "message" => self.message = value.to_string(),
964            "status" => self.status = value.to_string(),
965            "extra" => self.extra = Some(value.to_string()),
966            _ => {}
967        }
968    }
969
970    /// Captures boolean values. This is used to detect if an event was
971    /// created by the `console!` macro.
972    fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
973        if let "console" = field.name() {
974            self.console = value
975        }
976    }
977
978    /// Captures debug-formatted values, primarily used for extracting error
979    /// information from the `error` field.
980    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
981        if field.name() == "error" {
982            self.error = Some(format!("{value:?}"));
983        }
984    }
985}
986
987#[doc(hidden)]
988/// Internal helpers for the `console!` macro.
989///
990/// This module is not part of the public API and should not be used directly.
991/// It contains a collection of traits and functions that provide the underlying
992/// logic for the `console!` macro, particularly for handling `Result` types
993/// and formatting success and failure messages.
994///
995/// The core of this module is the `ConsoleResult` trait, which allows the macro
996/// to differentiate between `Result`s that return a `()` on success and those
997/// that return a value implementing `Display`. This is what enables the macro
998/// to either print just a message, or a message followed by the success value.
999pub mod macros {
1000    use owo_colors::OwoColorize;
1001    use std::fmt::Display;
1002
1003    /// A wrapper to help the compiler resolve trait implementations.
1004    ///
1005    /// This is part of the mechanism to allow the `console!` macro to handle both
1006    /// `Result<T, E>` where `T: Display` and `Result<(), E>`. By wrapping the successor
1007    /// value in `Displayable`, we can provide a distinct type for the `ConsoleResult`
1008    /// trait to implement.
1009    pub struct Displayable<T>(pub T);
1010
1011    /// A trait to format the success message of a `console!` invocation.
1012    ///
1013    /// This trait is the key to allowing the `console!` macro to handle different
1014    /// success types in a `Result`. We provide two implementations: one for the unit
1015    /// type `()`, and a generic one for any type that implements `Display`.
1016    ///
1017    /// The `console!` macro calls `format_success` on the `Ok` value of a `Result`.
1018    /// Depending on which implementation is chosen, the message will either be
1019    /// printed as-is (for `()`) or with the value appended (for `T: Display`).
1020    pub trait ConsoleResult<T> {
1021        /// Formats the success message.
1022        fn format_success(self, colorize: impl Fn(String) -> String, message: String) -> String;
1023    }
1024
1025    impl ConsoleResult<()> for () {
1026        fn format_success(self, _colorize: impl Fn(String) -> String, message: String) -> String {
1027            message
1028        }
1029    }
1030
1031    impl<T: Display> ConsoleResult<Displayable<T>> for T {
1032        fn format_success(self, colorize: impl Fn(String) -> String, message: String) -> String {
1033            let this = self.to_string();
1034            if this.is_empty() {
1035                message
1036            } else {
1037                format!("{}: {}", message, colorize(self.to_string()))
1038            }
1039        }
1040    }
1041
1042    /// Formats a failure message.
1043    ///
1044    /// This is a helper function that takes an error which implements `Display`
1045    /// and a message, and formats them into a standardized failure string with
1046    /// the error details colored in red.
1047    pub fn format_failure<E: Display>(err: &E, message: String) -> String {
1048        format!("{}: {}", message, err.to_string().red())
1049    }
1050}
1051
1052#[doc(hidden)]
1053/// An internal helper macro to handle the logic for a `Result`.
1054/// This is not intended to be called directly.
1055#[macro_export]
1056macro_rules! __console_impl_result {
1057    ($result:expr, $colorize:expr, $($arg:tt)+) => {
1058        match $result {
1059            Ok(result) => {
1060                use $crate::macros::ConsoleResult;
1061                let message = result.format_success($colorize, format!($($arg)+));
1062                tracing::event!(
1063                    tracing::Level::INFO,
1064                    console = true,
1065                    status = "success",
1066                    message = message
1067                );
1068                Ok(result)
1069            }
1070            Err(e) => {
1071                let error_message = $crate::macros::format_failure(&e, format!($($arg)+));
1072                tracing::event!(
1073                    tracing::Level::ERROR,
1074                    console = true,
1075                    status = "error",
1076                    message = error_message
1077                );
1078                Err(e)
1079            }
1080        }
1081    };
1082}
1083
1084/// Creates a tracing event with the required fields for our `ConsoleLayer`.
1085///
1086/// This macro provides a  way to log events with different status levels and to handle
1087/// `Result` types automatically.
1088///
1089/// ## Usage
1090///
1091/// The macro has several forms depending on the desired outcome.
1092///
1093/// ### Simple Logging
1094///
1095/// You can log a simple message with a specific status. The supported statuses are
1096/// `notice`, `info`, `warn`, and `error`.
1097///
1098/// ```rust
1099/// # use tracing_scribe::console;
1100/// # fn main() {
1101/// console!(notice, "This is a notice");
1102/// console!(info, "This is an informational message");
1103/// console!(warn, "This is a warning");
1104/// console!(error, "This is an error");
1105/// # }
1106/// ```
1107///
1108/// Finally, you can completely passthrough colors and text styles by using the `pass` argument to
1109/// the macro.
1110///
1111/// ```rust
1112/// # use tracing_scribe::console;
1113/// # fn main() {
1114/// console!(pass, "This is a string with no style");
1115/// # }
1116/// ```
1117///
1118/// ### Handling `Result` Types
1119///
1120/// The macro can also be used to log the outcome of a `Result`. It will automatically
1121/// log a success or failure message based on the variant of the `Result`.
1122///
1123/// If the `Ok` variant of the `Result` contains a value that implements `Display`,
1124/// it will be appended to the message.
1125///
1126/// ```rust
1127/// # use tracing_scribe::console;
1128/// # use owo_colors::OwoColorize;
1129/// # fn main() {
1130/// let successful_result: Result<&str, &str> = Ok("everything is fine");
1131/// console!(successful_result, "A successful operation");
1132///
1133/// let failed_result: Result<&str, &str> = Err("something went wrong");
1134/// console!(failed_result, "A failed operation");
1135/// # }
1136/// ```
1137///
1138/// If the `Ok` variant is a unit type (`()`), no extra value is appended to the message.
1139///
1140/// ```rust
1141/// # use tracing_scribe::console;
1142/// # use owo_colors::OwoColorize;
1143/// # fn main() {
1144/// let successful_result: Result<(), &str> = Ok(());
1145/// console!(successful_result, "An operation with no output");
1146/// # }
1147/// ```
1148///
1149/// You can also customize the color of the success message output.
1150///
1151/// ```rust
1152/// # use tracing_scribe::console;
1153/// # use owo_colors::OwoColorize;
1154/// # fn main() {
1155/// let successful_result: Result<&str, &str> = Ok("everything is fine");
1156/// console!(successful_result, |s: String| s.blue().to_string(), "A successful operation with custom colors");
1157/// # }
1158/// ```
1159#[macro_export]
1160macro_rules! console {
1161    (pass, $($arg:tt)+) => {
1162        tracing::event!(tracing::Level::INFO, console = true, status = "pass", message = &format!($($arg)+));
1163    };
1164    (notice, $($arg:tt)+) => {
1165        tracing::event!(tracing::Level::INFO, console = true, status = "notice", message = &format!($($arg)+));
1166    };
1167    (info, $($arg:tt)+) => {
1168        tracing::event!(tracing::Level::INFO, console = true, status = "success", message = &format!($($arg)+));
1169    };
1170    (warn, $($arg:tt)+) => {
1171        tracing::event!(tracing::Level::WARN, console = true, status = "warn", message = &format!($($arg)+));
1172    };
1173    (error, $($arg:tt)+) => {
1174        tracing::event!(tracing::Level::ERROR, console = true, status = "error", message = &format!($($arg)+));
1175    };
1176    ($result:expr, $color:expr, $($arg:tt)+) => {
1177        $crate::__console_impl_result!($result, $color, $($arg)+)
1178    };
1179    ($result:expr, $($arg:tt)+) => {
1180        $crate::__console_impl_result!($result, |s: String| s.green().to_string(), $($arg)+)
1181    };
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186    use super::*;
1187    use regex::Regex;
1188    use std::fmt::Display;
1189    use std::sync::{Arc, Mutex};
1190    use tracing_subscriber::Registry;
1191    use tracing_subscriber::fmt::MakeWriter;
1192    use tracing_subscriber::prelude::*;
1193
1194    #[derive(Debug, Clone)]
1195    struct MockWriter {
1196        buf: Arc<Mutex<Vec<u8>>>,
1197    }
1198
1199    impl Display for MockWriter {
1200        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1201            write!(
1202                f,
1203                "{}",
1204                String::from_utf8(self.buf.lock().unwrap().clone()).unwrap()
1205            )
1206        }
1207    }
1208
1209    impl MockWriter {
1210        fn new() -> Self {
1211            Self {
1212                buf: Arc::new(Mutex::new(Vec::new())),
1213            }
1214        }
1215    }
1216
1217    impl<'a> MakeWriter<'a> for MockWriter {
1218        type Writer = Self;
1219
1220        fn make_writer(&'a self) -> Self::Writer {
1221            self.clone()
1222        }
1223    }
1224
1225    impl io::Write for MockWriter {
1226        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1227            self.buf.lock().unwrap().write(buf)
1228        }
1229
1230        fn flush(&mut self) -> io::Result<()> {
1231            self.buf.lock().unwrap().flush()
1232        }
1233    }
1234
1235    fn strip_non_deterministic(output: &str) -> String {
1236        // Strip ANSI escape codes
1237        let output = strip_ansi_escapes::strip_str(output);
1238
1239        // Strip durations
1240        let duration_regex = Regex::new(r" ⏱ .*? ").unwrap();
1241        duration_regex.replace_all(&output, "").trim().to_string()
1242    }
1243
1244    #[test]
1245    fn test_simple_span() {
1246        let writer = MockWriter::new();
1247        let subscriber = Registry::default().with(ConsoleLayer::with_writer(writer.clone()));
1248
1249        tracing::subscriber::with_default(subscriber, || {
1250            let _span = tracing::info_span!("simple").entered();
1251            console!(info, "It works!");
1252        });
1253
1254        let output = strip_non_deterministic(&writer.to_string());
1255        insta::assert_snapshot!(output, @r###"
1256        ╭─ ⚙ simple
1257        │  ├─ ✔ It works!
1258        ╰─ ✔ simple succeeded
1259        "###);
1260    }
1261
1262    #[test]
1263    fn test_nested_spans() {
1264        let writer = MockWriter::new();
1265        let subscriber = Registry::default().with(ConsoleLayer::with_writer(writer.clone()));
1266
1267        tracing::subscriber::with_default(subscriber, || {
1268            let _outer = tracing::info_span!("outer").entered();
1269            let _inner = tracing::info_span!("inner").entered();
1270            console!(notice, "Something is happening");
1271        });
1272
1273        let output = strip_non_deterministic(&writer.to_string());
1274        insta::assert_snapshot!(output, @r###"
1275        ╭─ ⚙ outer
1276        │  ╭─ ⚙ inner
1277        │  │  ├─ ! Something is happening
1278        │  ╰─ ✔ inner
1279        ╰─ ✔ outer succeeded
1280        "###);
1281    }
1282
1283    #[test]
1284    fn test_error_propagation() {
1285        let writer = MockWriter::new();
1286        let subscriber = Registry::default().with(ConsoleLayer::with_writer(writer.clone()));
1287
1288        tracing::subscriber::with_default(subscriber, || {
1289            #[tracing::instrument(name = "inner", err)]
1290            fn inner() -> Result<(), &'static str> {
1291                Err("An error occurred")
1292            }
1293            let _outer = tracing::info_span!("outer").entered();
1294            inner().ok()
1295        });
1296
1297        let output = strip_non_deterministic(&writer.to_string());
1298        insta::assert_snapshot!(output, @r"
1299        ╭─ ⚙ outer
1300        │  ╭─ ⚙ inner
1301        │  │  ├─ ⨯ An error occurred
1302        │  ╰─ ⨯ inner
1303        ╰─ ⨯ outer failed
1304        ");
1305    }
1306
1307    #[test]
1308    fn test_nested_instrument_err_deduplication() {
1309        let writer = MockWriter::new();
1310        let subscriber = Registry::default().with(ConsoleLayer::with_writer(writer.clone()));
1311
1312        tracing::subscriber::with_default(subscriber, || {
1313            #[tracing::instrument(name = "inner", err)]
1314            fn inner() -> Result<(), &'static str> {
1315                Err("something went wrong")
1316            }
1317
1318            #[tracing::instrument(name = "middle", err)]
1319            fn middle() -> Result<(), &'static str> {
1320                inner()
1321            }
1322
1323            #[tracing::instrument(name = "outer", err)]
1324            fn outer() -> Result<(), &'static str> {
1325                middle()
1326            }
1327
1328            let _root = tracing::info_span!("root").entered();
1329            outer().ok();
1330        });
1331
1332        let output = strip_non_deterministic(&writer.to_string());
1333        // The error message should appear only once (at the innermost span),
1334        // but all parent spans should still show the failure icon on close.
1335        insta::assert_snapshot!(output, @r"
1336        ╭─ ⚙ root
1337        │  ╭─ ⚙ outer
1338        │  │  ╭─ ⚙ middle
1339        │  │  │  ╭─ ⚙ inner
1340        │  │  │  │  ├─ ⨯ something went wrong
1341        │  │  │  ╰─ ⨯ inner
1342        │  │  ╰─ ⨯ middle
1343        │  ╰─ ⨯ outer
1344        ╰─ ⨯ root failed
1345        ");
1346    }
1347
1348    /// Reproduces the "branch already exists" CI failure scenario.
1349    ///
1350    /// Without deduplication, the error message would appear 3 times (once at
1351    /// each `#[instrument(err)]` level). With deduplication it appears once.
1352    #[test]
1353    fn test_bump_branch_already_exists() {
1354        let writer = MockWriter::new();
1355        let subscriber = Registry::default().with(ConsoleLayer::with_writer(writer.clone()));
1356
1357        tracing::subscriber::with_default(subscriber, || {
1358            #[tracing::instrument(name = "run command git checkout -b bump/v0.15.4", err)]
1359            fn git_checkout_new_branch() -> Result<(), String> {
1360                console!(warn, "fatal: a branch named 'bump/v0.15.4' already exists");
1361                Err(
1362                    "error running command git checkout -b bump/v0.15.4, exit code: Some(128)"
1363                        .to_string(),
1364                )
1365            }
1366
1367            #[tracing::instrument(name = "bump", err)]
1368            fn bump_inner() -> Result<(), String> {
1369                {
1370                    let _span = tracing::info_span!("configure git").entered();
1371                    {
1372                        let _cmd =
1373                            tracing::info_span!("run command git config user.name").entered();
1374                    }
1375                    {
1376                        let _cmd =
1377                            tracing::info_span!("run command git config user.email").entered();
1378                    }
1379                }
1380                {
1381                    let _span = tracing::info_span!("fetch and checkout").entered();
1382                    {
1383                        let _cmd =
1384                            tracing::info_span!("run command git fetch origin develop").entered();
1385                    }
1386                    {
1387                        let _cmd = tracing::info_span!("run command git checkout origin/develop")
1388                            .entered();
1389                    }
1390                }
1391                console!(info, "current version: 0.15.3");
1392                console!(info, "new version: 0.15.4");
1393                {
1394                    let _cmd = tracing::info_span!(
1395                        "run command git ls-remote --exit-code --heads origin bump/v0.15.4"
1396                    )
1397                    .entered();
1398                }
1399                git_checkout_new_branch()
1400            }
1401
1402            #[tracing::instrument(name = "bump", err)]
1403            fn bump_outer() -> Result<(), String> {
1404                bump_inner()
1405            }
1406
1407            let _cli = tracing::info_span!("cli").entered();
1408            console!(notice, "run id: 019c7593-1f68-784a-ade1-9a06a33a38be");
1409            bump_outer().ok();
1410            console!(notice, "run id: 019c7593-1f68-784a-ade1-9a06a33a38be");
1411        });
1412
1413        let output = strip_non_deterministic(&writer.to_string());
1414        insta::assert_snapshot!(output, @r"
1415        ╭─ ⚙ cli
1416        │  ├─ ! run id: 019c7593-1f68-784a-ade1-9a06a33a38be
1417        │  ╭─ ⚙ bump
1418        │  │  ╭─ ⚙ bump
1419        │  │  │  ╭─ ⚙ configure git
1420        │  │  │  │  ╭─ ⚙ run command git config user.name
1421        │  │  │  │  ╰─ ✔ run command git config user.name
1422        │  │  │  │  ╭─ ⚙ run command git config user.email
1423        │  │  │  │  ╰─ ✔ run command git config user.email
1424        │  │  │  ╰─ ✔ configure git
1425        │  │  │  ╭─ ⚙ fetch and checkout
1426        │  │  │  │  ╭─ ⚙ run command git fetch origin develop
1427        │  │  │  │  ╰─ ✔ run command git fetch origin develop
1428        │  │  │  │  ╭─ ⚙ run command git checkout origin/develop
1429        │  │  │  │  ╰─ ✔ run command git checkout origin/develop
1430        │  │  │  ╰─ ✔ fetch and checkout
1431        │  │  │  ├─ ✔ current version: 0.15.3
1432        │  │  │  ├─ ✔ new version: 0.15.4
1433        │  │  │  ╭─ ⚙ run command git ls-remote --exit-code --heads origin bump/v0.15.4
1434        │  │  │  ╰─ ✔ run command git ls-remote --exit-code --heads origin bump/v0.15.4
1435        │  │  │  ╭─ ⚙ run command git checkout -b bump/v0.15.4
1436        │  │  │  │  ├─ ⚠ fatal: a branch named 'bump/v0.15.4' already exists
1437        │  │  │  │  ├─ ⨯ error running command git checkout -b bump/v0.15.4, exit code: Some(128)
1438        │  │  │  ╰─ ⨯ run command git checkout -b bump/v0.15.4
1439        │  │  ╰─ ⨯ bump
1440        │  ╰─ ⨯ bump
1441        │  ├─ ! run id: 019c7593-1f68-784a-ade1-9a06a33a38be
1442        ╰─ ⨯ cli failed
1443        ");
1444    }
1445
1446    /// Reproduces the "push permission denied" CI failure scenario.
1447    ///
1448    /// Without deduplication, the long error message would appear 4 times (once
1449    /// at each `#[instrument(err)]` level). With deduplication it appears once.
1450    #[test]
1451    fn test_bump_push_permission_denied() {
1452        let writer = MockWriter::new();
1453        let subscriber = Registry::default().with(ConsoleLayer::with_writer(writer.clone()));
1454
1455        tracing::subscriber::with_default(subscriber, || {
1456            #[tracing::instrument(name = "run command git push -u origin HEAD", err)]
1457            fn git_push() -> Result<(), String> {
1458                console!(
1459                    warn,
1460                    "remote: You are not allowed to push code to this project."
1461                );
1462                console!(
1463                    warn,
1464                    "fatal: unable to access 'https://git.example.com/example/project.git/': The requested URL returned error: 403"
1465                );
1466                Err("You are not allowed to push code to this project.".to_string())
1467            }
1468
1469            #[tracing::instrument(name = "commit and push", err)]
1470            fn commit_and_push() -> Result<(), String> {
1471                {
1472                    let _cmd =
1473                        tracing::info_span!("run command git add Cargo.toml Cargo.lock").entered();
1474                }
1475                {
1476                    let _cmd =
1477                        tracing::info_span!("run command git commit -m bump version to 0.15.4")
1478                            .entered();
1479                }
1480                git_push()
1481            }
1482
1483            #[tracing::instrument(name = "bump", err)]
1484            fn bump_inner() -> Result<(), String> {
1485                {
1486                    let _span = tracing::info_span!("configure git").entered();
1487                }
1488                {
1489                    let _span = tracing::info_span!("fetch and checkout").entered();
1490                }
1491                console!(info, "current version: 0.15.3");
1492                console!(info, "new version: 0.15.4");
1493                {
1494                    let _cmd =
1495                        tracing::info_span!("run command git checkout -b bump/v0.15.4").entered();
1496                }
1497                {
1498                    let _span = tracing::info_span!("update cargo.toml").entered();
1499                    console!(info, "updated Cargo.toml to version 0.15.4");
1500                }
1501                {
1502                    let _span = tracing::info_span!("update cargo.lock").entered();
1503                    {
1504                        let _cmd =
1505                            tracing::info_span!("run command cargo update --workspace").entered();
1506                    }
1507                    console!(info, "updated Cargo.lock");
1508                }
1509                commit_and_push()
1510            }
1511
1512            #[tracing::instrument(name = "bump", err)]
1513            fn bump_outer() -> Result<(), String> {
1514                bump_inner()
1515            }
1516
1517            let _cli = tracing::info_span!("cli").entered();
1518            console!(notice, "run id: 019c75a8-e7a6-7bec-822a-e371c363d73e");
1519            bump_outer().ok();
1520            console!(notice, "run id: 019c75a8-e7a6-7bec-822a-e371c363d73e");
1521        });
1522
1523        let output = strip_non_deterministic(&writer.to_string());
1524        insta::assert_snapshot!(output, @r"
1525        ╭─ ⚙ cli
1526        │  ├─ ! run id: 019c75a8-e7a6-7bec-822a-e371c363d73e
1527        │  ╭─ ⚙ bump
1528        │  │  ╭─ ⚙ bump
1529        │  │  │  ╭─ ⚙ configure git
1530        │  │  │  ╰─ ✔ configure git
1531        │  │  │  ╭─ ⚙ fetch and checkout
1532        │  │  │  ╰─ ✔ fetch and checkout
1533        │  │  │  ├─ ✔ current version: 0.15.3
1534        │  │  │  ├─ ✔ new version: 0.15.4
1535        │  │  │  ╭─ ⚙ run command git checkout -b bump/v0.15.4
1536        │  │  │  ╰─ ✔ run command git checkout -b bump/v0.15.4
1537        │  │  │  ╭─ ⚙ update cargo.toml
1538        │  │  │  │  ├─ ✔ updated Cargo.toml to version 0.15.4
1539        │  │  │  ╰─ ✔ update cargo.toml
1540        │  │  │  ╭─ ⚙ update cargo.lock
1541        │  │  │  │  ╭─ ⚙ run command cargo update --workspace
1542        │  │  │  │  ╰─ ✔ run command cargo update --workspace
1543        │  │  │  │  ├─ ✔ updated Cargo.lock
1544        │  │  │  ╰─ ✔ update cargo.lock
1545        │  │  │  ╭─ ⚙ commit and push
1546        │  │  │  │  ╭─ ⚙ run command git add Cargo.toml Cargo.lock
1547        │  │  │  │  ╰─ ✔ run command git add Cargo.toml Cargo.lock
1548        │  │  │  │  ╭─ ⚙ run command git commit -m bump version to 0.15.4
1549        │  │  │  │  ╰─ ✔ run command git commit -m bump version to 0.15.4
1550        │  │  │  │  ╭─ ⚙ run command git push -u origin HEAD
1551        │  │  │  │  │  ├─ ⚠ remote: You are not allowed to push code to this project.
1552        │  │  │  │  │  ├─ ⚠ fatal: unable to access 'https://git.example.com/example/project.git/': The requested URL returned error: 403
1553        │  │  │  │  │  ├─ ⨯ You are not allowed to push code to this project.
1554        │  │  │  │  ╰─ ⨯ run command git push -u origin HEAD
1555        │  │  │  ╰─ ⨯ commit and push
1556        │  │  ╰─ ⨯ bump
1557        │  ╰─ ⨯ bump
1558        │  ├─ ! run id: 019c75a8-e7a6-7bec-822a-e371c363d73e
1559        ╰─ ⨯ cli failed
1560        ");
1561    }
1562
1563    #[test]
1564    fn test_console_macros() {
1565        let writer = MockWriter::new();
1566        let subscriber = Registry::default().with(ConsoleLayer::with_writer(writer.clone()));
1567
1568        tracing::subscriber::with_default(subscriber, || {
1569            let _span = tracing::info_span!("macros").entered();
1570            console!(notice, "A notice");
1571            console!(info, "Some info");
1572            console!(warn, "A warning");
1573            console!(error, "An error");
1574        });
1575
1576        let output = strip_non_deterministic(&writer.to_string());
1577        insta::assert_snapshot!(output, @r###"
1578        ╭─ ⚙ macros
1579        │  ├─ ! A notice
1580        │  ├─ ✔ Some info
1581        │  ├─ ⚠ A warning
1582        │  ├─ ⨯ An error
1583        ╰─ ⨯ macros failed
1584        "###);
1585    }
1586
1587    #[test]
1588    fn test_console_result() {
1589        let writer = MockWriter::new();
1590        let subscriber = Registry::default().with(ConsoleLayer::with_writer(writer.clone()));
1591
1592        tracing::subscriber::with_default(subscriber, || {
1593            let _span = tracing::info_span!("result").entered();
1594            let res: Result<&str, &str> = Ok("all good");
1595            assert_eq!(console!(res, "Operation 1").unwrap(), "all good");
1596
1597            let res: Result<&str, &str> = Err("very bad");
1598            assert_eq!(console!(res, "Operation 2").unwrap_err(), "very bad");
1599        });
1600
1601        let output = strip_non_deterministic(&writer.to_string());
1602        insta::assert_snapshot!(output, @r###"
1603        ╭─ ⚙ result
1604        │  ├─ ✔ Operation 1: all good
1605        │  ├─ ⨯ Operation 2: very bad
1606        ╰─ ⨯ result failed
1607        "###);
1608    }
1609
1610    #[test]
1611    fn test_console_result_unit_type() {
1612        let writer = MockWriter::new();
1613        let subscriber = Registry::default().with(ConsoleLayer::with_writer(writer.clone()));
1614
1615        tracing::subscriber::with_default(subscriber, || {
1616            let _span = tracing::info_span!("result_unit").entered();
1617            let res: Result<(), &str> = Ok(());
1618            console!(res, "Operation without output").unwrap();
1619        });
1620
1621        let output = strip_non_deterministic(&writer.to_string());
1622        insta::assert_snapshot!(output, @r###"
1623        ╭─ ⚙ result_unit
1624        │  ├─ ✔ Operation without output
1625        ╰─ ✔ result_unit succeeded
1626        "###);
1627    }
1628
1629    #[test]
1630    fn test_complex_scenario() {
1631        let writer = MockWriter::new();
1632        let subscriber = Registry::default().with(ConsoleLayer::with_writer(writer.clone()));
1633
1634        tracing::subscriber::with_default(subscriber, || {
1635            let _cli = tracing::info_span!("cli", extra = "cli").entered();
1636            console!(notice, "run id: 928ac7fa-f9d3-4121-9401-b6edbc8369d4");
1637            let _gen = tracing::info_span!("gen").entered();
1638            {
1639                let _config = tracing::info_span!("configuration").entered();
1640                console!(info, "project root folder: /home/user/myapp");
1641                console!(
1642                    info,
1643                    "configuration loaded from /home/user/myapp/config.toml"
1644                );
1645            }
1646            {
1647                let _templates = tracing::info_span!("templates").entered();
1648                let _render = tracing::info_span!("render package", extra = "cli").entered();
1649                console!(notice, "file /home/user/myapp/build/cli.nix is up to date");
1650            }
1651            {
1652                let _deps = tracing::info_span!("dependencies").entered();
1653                console!(info, "1445 transitive dependencies found");
1654                tracing::error!("something");
1655            }
1656        });
1657
1658        let output = strip_non_deterministic(&writer.to_string());
1659        insta::assert_snapshot!(output, @r"
1660        ╭─ ⚙ cli cli
1661        │  ├─ ! run id: 928ac7fa-f9d3-4121-9401-b6edbc8369d4
1662        │  ╭─ ⚙ gen
1663        │  │  ╭─ ⚙ configuration
1664        │  │  │  ├─ ✔ project root folder: /home/user/myapp
1665        │  │  │  ├─ ✔ configuration loaded from /home/user/myapp/config.toml
1666        │  │  ╰─ ✔ configuration
1667        │  │  ╭─ ⚙ templates
1668        │  │  │  ╭─ ⚙ render package cli
1669        │  │  │  │  ├─ ! file /home/user/myapp/build/cli.nix is up to date
1670        │  │  │  ╰─ ✔ render package cli
1671        │  │  ╰─ ✔ templates
1672        │  │  ╭─ ⚙ dependencies
1673        │  │  │  ├─ ✔ 1445 transitive dependencies found
1674        │  │  │  ├─ ⨯ 
1675        │  │  ╰─ ⨯ dependencies
1676        │  ╰─ ⨯ gen
1677        ╰─ ⨯ cli cli failed
1678        ");
1679    }
1680
1681    #[test]
1682    fn test_custom_colors() {
1683        use owo_colors::colors;
1684
1685        let writer = MockWriter::new();
1686        let colors = ColorScheme::default()
1687            .with_success_color(colors::BrightGreen)
1688            .with_error_color(colors::BrightRed)
1689            .with_tree_color(colors::Magenta);
1690
1691        let layer = ConsoleLayer::with_writer(writer.clone()).with_colors(colors);
1692        let subscriber = Registry::default().with(layer);
1693
1694        tracing::subscriber::with_default(subscriber, || {
1695            let _span = tracing::info_span!("custom_colors").entered();
1696            console!(info, "Testing custom colors");
1697            console!(error, "This is an error with custom colors");
1698        });
1699
1700        let output = strip_non_deterministic(&writer.to_string());
1701        // We just verify the structure is correct - the colors themselves
1702        // are encoded as ANSI codes which are stripped by our test helper
1703        insta::assert_snapshot!(output, @r###"
1704        ╭─ ⚙ custom_colors
1705        │  ├─ ✔ Testing custom colors
1706        │  ├─ ⨯ This is an error with custom colors
1707        ╰─ ⨯ custom_colors failed
1708        "###);
1709    }
1710
1711    #[test]
1712    fn test_color_scheme_builder() {
1713        // Test that the builder pattern works correctly
1714        let colors = ColorScheme::new()
1715            .with_success_color(owo_colors::colors::Green)
1716            .with_error_color(owo_colors::colors::Red)
1717            .with_warning_color(owo_colors::colors::Yellow)
1718            .with_notice_color(owo_colors::colors::Magenta);
1719
1720        // Just verify we can create it without errors
1721        assert!(format!("{:?}", colors).contains("ColorScheme"));
1722    }
1723
1724    #[test]
1725    fn test_colors_with_custom_status() {
1726        #[derive(Clone, Copy)]
1727        struct TestStatusFactory;
1728
1729        impl StatusFactory<DefaultStatus> for TestStatusFactory {
1730            fn group(&self) -> DefaultStatus {
1731                DefaultStatus::Group
1732            }
1733            fn success(&self) -> DefaultStatus {
1734                DefaultStatus::Success
1735            }
1736            fn failure(&self) -> DefaultStatus {
1737                DefaultStatus::Failure
1738            }
1739        }
1740
1741        let writer = MockWriter::new();
1742        let colors = ColorScheme::default().with_success_color(owo_colors::colors::BrightGreen);
1743
1744        let layer = ConsoleLayer::with_writer(writer.clone())
1745            .with_colors(colors)
1746            .with_status(TestStatusFactory);
1747
1748        let subscriber = Registry::default().with(layer);
1749
1750        tracing::subscriber::with_default(subscriber, || {
1751            let _span = tracing::info_span!("test").entered();
1752            console!(info, "Works with custom status factory");
1753        });
1754
1755        let output = strip_non_deterministic(&writer.to_string());
1756        insta::assert_snapshot!(output, @r###"
1757        ╭─ ⚙ test
1758        │  ├─ ✔ Works with custom status factory
1759        ╰─ ✔ test succeeded
1760        "###);
1761    }
1762
1763    #[test]
1764    fn test_format_duration_seconds() {
1765        assert_eq!(format_duration(Duration::from_secs(1)), "1.0s");
1766        assert_eq!(format_duration(Duration::from_millis(1500)), "1.5s");
1767        assert_eq!(format_duration(Duration::from_secs(65)), "65.0s");
1768        assert_eq!(format_duration(Duration::from_secs_f64(1.06)), "1.1s");
1769    }
1770
1771    #[test]
1772    fn test_format_duration_milliseconds() {
1773        assert_eq!(format_duration(Duration::from_millis(1)), "1.0ms");
1774        assert_eq!(format_duration(Duration::from_millis(500)), "500.0ms");
1775        assert_eq!(format_duration(Duration::from_millis(999)), "999.0ms");
1776        assert_eq!(format_duration(Duration::from_micros(1500)), "1.5ms");
1777    }
1778
1779    #[test]
1780    fn test_format_duration_microseconds() {
1781        assert_eq!(format_duration(Duration::from_micros(1)), "1.0µs");
1782        assert_eq!(format_duration(Duration::from_micros(500)), "500.0µs");
1783        assert_eq!(format_duration(Duration::from_micros(999)), "999.0µs");
1784        assert_eq!(format_duration(Duration::from_nanos(1500)), "1.5µs");
1785    }
1786
1787    #[test]
1788    fn test_format_duration_nanoseconds() {
1789        assert_eq!(format_duration(Duration::from_nanos(1)), "1ns");
1790        assert_eq!(format_duration(Duration::from_nanos(500)), "500ns");
1791        assert_eq!(format_duration(Duration::from_nanos(999)), "999ns");
1792    }
1793
1794    #[test]
1795    fn test_format_duration_zero() {
1796        assert_eq!(format_duration(Duration::ZERO), "0ns");
1797    }
1798
1799    #[test]
1800    fn test_format_duration_no_1000_units_at_boundaries() {
1801        // Values that would round to "1000.0ms" are promoted to seconds
1802        assert_eq!(format_duration(Duration::from_nanos(999_999_999)), "1.0s");
1803        assert_eq!(format_duration(Duration::from_nanos(999_950_000)), "1.0s");
1804
1805        // Values that would round to "1000.0µs" are promoted to milliseconds
1806        assert_eq!(format_duration(Duration::from_nanos(999_999)), "1.0ms");
1807        assert_eq!(format_duration(Duration::from_nanos(999_950)), "1.0ms");
1808
1809        // Just below the promotion threshold stays in the lower tier
1810        assert_eq!(
1811            format_duration(Duration::from_nanos(999_949_999)),
1812            "999.9ms"
1813        );
1814        assert_eq!(format_duration(Duration::from_nanos(999_949)), "999.9µs");
1815
1816        // At exact unit boundaries
1817        assert_eq!(format_duration(Duration::from_nanos(1_000_000_000)), "1.0s");
1818        assert_eq!(format_duration(Duration::from_nanos(1_000_000)), "1.0ms");
1819        assert_eq!(format_duration(Duration::from_nanos(1_000)), "1.0µs");
1820    }
1821}