Skip to main content

git_cliff/
logger.rs

1use std::fmt;
2use std::sync::atomic::{AtomicUsize, Ordering};
3
4use git_cliff_core::error::{Error, Result};
5use indicatif::{ProgressState, ProgressStyle};
6use owo_colors::{OwoColorize, Style, Styled};
7use tracing::{Event, Level, Span, Subscriber};
8use tracing_indicatif::IndicatifLayer;
9use tracing_indicatif::span_ext::IndicatifSpanExt;
10use tracing_subscriber::fmt::FmtContext;
11use tracing_subscriber::fmt::format::{self, FormatEvent, FormatFields};
12use tracing_subscriber::layer::{Context, Layer, SubscriberExt};
13use tracing_subscriber::registry::LookupSpan;
14use tracing_subscriber::util::SubscriberInitExt;
15use tracing_subscriber::{EnvFilter, Registry};
16
17/// Global variable for storing the maximum width of the modules.
18static MAX_MODULE_WIDTH: AtomicUsize = AtomicUsize::new(0);
19
20/// Classic single-cell spinner frames used by indicatif.
21const ROOT_SPINNER_TICKS: &[&str] = &["◐", "◓", "◑", "◒"];
22/// The previous quarter-circle spinner for nested spans.
23const CHILD_SPINNER_TICKS: &[&str] = &["◴", "◷", "◶", "◵"];
24
25/// Wrapper for the padded values.
26struct Padded<T> {
27    value: T,
28    width: usize,
29}
30
31impl<T: fmt::Display> fmt::Display for Padded<T> {
32    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
33        write!(f, "{: <width$}", self.value, width = self.width)
34    }
35}
36
37/// Returns the max width of the target.
38fn max_target_width(target: &str) -> usize {
39    let max_width = MAX_MODULE_WIDTH.load(Ordering::Relaxed);
40    if max_width < target.len() {
41        MAX_MODULE_WIDTH.store(target.len(), Ordering::Relaxed);
42        target.len()
43    } else {
44        max_width
45    }
46}
47
48/// Adds styles/colors to the given level and returns it.
49fn style_level(level: Level) -> Styled<&'static str> {
50    match level {
51        Level::ERROR => Style::new().red().bold().style("ERROR"),
52        Level::WARN => Style::new().yellow().bold().style("WARN"),
53        Level::INFO => Style::new().green().bold().style("INFO"),
54        Level::DEBUG => Style::new().blue().bold().style("DEBUG"),
55        Level::TRACE => Style::new().magenta().bold().style("TRACE"),
56    }
57}
58
59/// Computes the spinner/elapsed color based on elapsed time.
60///
61/// The color gradually transitions:
62/// - green  -> yellow (0–16s)
63/// - yellow -> red    (16–32s)
64fn progress_color(state: &ProgressState) -> (u8, u8, u8) {
65    let elapsed = state.elapsed().as_secs_f32();
66    let t = (elapsed / 32.0).min(1.0);
67    if t < 0.5 {
68        let nt = t * 2.0;
69        (lerp(140, 230, nt), lerp(200, 210, nt), lerp(160, 150, nt))
70    } else {
71        let nt = (t - 0.5) * 2.0;
72        (lerp(230, 230, nt), lerp(210, 140, nt), lerp(150, 140, nt))
73    }
74}
75
76/// Performs linear interpolation between two color components.
77#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
78fn lerp(a: u8, b: u8, t: f32) -> u8 {
79    ((f32::from(a) + (f32::from(b) - f32::from(a)) * t).clamp(0.0, 255.0)) as u8
80}
81
82/// Formats the elapsed time as `X.Ys` (sub-second precision).
83fn elapsed_subsec_key(state: &ProgressState, writer: &mut dyn fmt::Write) {
84    let seconds = state.elapsed().as_secs();
85    let sub_seconds = (state.elapsed().as_millis() % 1000) / 100;
86    let (r, g, b) = progress_color(state);
87    let _ = write!(
88        writer,
89        "{}",
90        Style::new()
91            .truecolor(r, g, b)
92            .style(format!("{seconds}.{sub_seconds}s"))
93    );
94}
95
96/// Formats the current spinner tick and colors it based on elapsed time.
97fn spinner_key(state: &ProgressState, writer: &mut dyn fmt::Write, ticks: &'static [&'static str]) {
98    let index = ((state.elapsed().as_millis() / 100) as usize) % ticks.len();
99    let (r, g, b) = progress_color(state);
100    let _ = write!(
101        writer,
102        "{}",
103        Style::new().truecolor(r, g, b).style(ticks[index])
104    );
105}
106
107fn root_spinner_key(state: &ProgressState, writer: &mut dyn fmt::Write) {
108    spinner_key(state, writer, ROOT_SPINNER_TICKS);
109}
110
111fn child_spinner_key(state: &ProgressState, writer: &mut dyn fmt::Write) {
112    spinner_key(state, writer, CHILD_SPINNER_TICKS);
113}
114
115/// Builds the `indicatif::ProgressStyle` used for tracing spans.
116///
117/// This style:
118/// - renders a Unicode spinner for active spans
119/// - colorizes the spinner based on elapsed time
120/// - shows span name, fields, and wide messages
121/// - appends a sub-second elapsed timer
122fn indicatif_progress_style(spinner_key: fn(&ProgressState, &mut dyn fmt::Write)) -> ProgressStyle {
123    ProgressStyle::with_template(
124        "{span_child_prefix}{spinner} {wide_msg} {span_name} {span_fields} [{elapsed_subsec}]",
125    )
126    .unwrap()
127    .with_key("elapsed_subsec", elapsed_subsec_key)
128    .with_key("spinner", spinner_key)
129}
130
131/// Applies different progress spinner styles to root and nested tracing spans.
132struct SpinnerStyleLayer;
133
134impl<S> Layer<S> for SpinnerStyleLayer
135where
136    S: Subscriber + for<'a> LookupSpan<'a>,
137{
138    fn on_enter(&self, id: &tracing::span::Id, ctx: Context<'_, S>) {
139        let ticks = if ctx.span(id).is_some_and(|span| span.parent().is_some()) {
140            child_spinner_key
141        } else {
142            root_spinner_key
143        };
144        Span::current().pb_set_style(&indicatif_progress_style(ticks));
145    }
146}
147
148/// Simple formatter. We format: "LEVEL TARGET > MESSAGE", with a basic padding for target.
149struct GitCliffFormatter;
150
151impl<S, N> FormatEvent<S, N> for GitCliffFormatter
152where
153    S: Subscriber + for<'a> LookupSpan<'a>,
154    N: for<'a> FormatFields<'a> + 'static,
155{
156    fn format_event(
157        &self,
158        ctx: &FmtContext<'_, S, N>,
159        mut writer: format::Writer<'_>,
160        event: &Event<'_>,
161    ) -> fmt::Result {
162        let metadata = event.metadata();
163        let level = style_level(*metadata.level());
164        let target = metadata.target();
165        let max_width = max_target_width(target);
166        write!(
167            &mut writer,
168            "{} {} > ",
169            Padded {
170                value: level,
171                width: 5,
172            },
173            Padded {
174                value: target.bright_black().bold(),
175                width: max_width,
176            },
177        )?;
178        if let Some(scope) = ctx.event_scope() {
179            for span in scope.from_root() {
180                write!(writer, "{}", span.name().bright_black().bold())?;
181                write!(writer, "{}", ": ".bright_black().bold())?;
182            }
183        }
184        ctx.field_format().format_fields(writer.by_ref(), event)?;
185        writeln!(writer)
186    }
187}
188
189/// Initializes the global tracing subscriber.
190pub fn init() -> Result<()> {
191    // Build EnvFilter from `RUST_LOG` or fallback to "info"
192    let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into());
193    let indicatif_layer = IndicatifLayer::new()
194        .with_progress_style(indicatif_progress_style(root_spinner_key))
195        .with_span_child_prefix_symbol("↳ ")
196        .with_span_child_prefix_indent(" ");
197    let fmt_layer = tracing_subscriber::fmt::layer()
198        .with_writer(indicatif_layer.get_stderr_writer())
199        .with_ansi(true)
200        .event_format(GitCliffFormatter);
201    let subscriber = Registry::default()
202        .with(env_filter)
203        .with(indicatif_layer)
204        .with(SpinnerStyleLayer)
205        .with(fmt_layer);
206    subscriber
207        .try_init()
208        .map_err(|e| Error::LoggerError(e.to_string()))
209}