acta 0.1.0

Make Tracing Great Again.
Documentation
use crate::config::{Icons, LevelLabels, StyleConfig, Theme};
use arrayvec::ArrayString;
use chrono::Utc;
use owo_colors::Style;
use smallvec::SmallVec;
use std::borrow::Cow;
use std::fmt;
use std::fmt::{Debug, Write};

use tracing::field::Field;
use tracing::{Event, Level, Subscriber};
use tracing_subscriber::fmt::FormattedFields;
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::{FmtContext, FormatEvent};
use tracing_subscriber::registry::LookupSpan;

#[derive(Clone, Copy, Debug)]
struct LevelStyles {
    bracket_fg: Style,
    bracket_bg: Style,
    label: Style,
}

#[allow(clippy::missing_const_for_fn)]
fn make_level_styles(r: u8, g: u8, b: u8) -> LevelStyles {
    LevelStyles {
        bracket_fg: Style::new().truecolor(r, g, b),
        bracket_bg: Style::new().truecolor(r, g, b).on_truecolor(r, g, b),
        label: Style::new()
            .truecolor(r >> 2, g >> 2, b >> 2)
            .on_truecolor(r, g, b),
    }
}

fn build_all_level_styles(theme: &Theme) -> [LevelStyles; 5] {
    [
        make_level_styles(theme.error.0, theme.error.1, theme.error.2),
        make_level_styles(theme.warn.0, theme.warn.1, theme.warn.2),
        make_level_styles(theme.info.0, theme.info.1, theme.info.2),
        make_level_styles(theme.debug.0, theme.debug.1, theme.debug.2),
        make_level_styles(theme.trace.0, theme.trace.1, theme.trace.2),
    ]
}

const BUILD_PATH_WIDTH: usize = include!(concat!(env!("OUT_DIR"), "/path_width"));

const PATH_BUF_SIZE: usize = 256;

#[derive(Debug, Clone)]
pub struct AnsiFormatter {
    pub(crate) time_format: String,
    pub(crate) path_width: usize,
    pub(crate) show_path: bool,
    pub(crate) show_spans: bool,
    style: StyleConfig,
    level_styles: [LevelStyles; 5],
}

impl Default for AnsiFormatter {
    fn default() -> Self {
        Self::new()
    }
}

impl AnsiFormatter {
    #[must_use]
    pub fn new() -> Self {
        let config = StyleConfig::default();
        let level_styles = build_all_level_styles(&config.theme);
        Self {
            time_format: String::from("%H:%M:%S"),
            path_width: BUILD_PATH_WIDTH,
            show_path: true,
            show_spans: true,
            style: config,
            level_styles,
        }
    }

    #[must_use]
    pub const fn style_config(&self) -> &StyleConfig {
        &self.style
    }

    #[must_use]
    pub const fn style_config_mut(&mut self) -> &mut StyleConfig {
        &mut self.style
    }

    #[must_use]
    pub fn with_style_config(mut self, style: StyleConfig) -> Self {
        self.level_styles = build_all_level_styles(&style.theme);
        self.style = style;
        self
    }

    #[must_use]
    pub const fn with_icons(mut self, icons: Icons) -> Self {
        self.style.icons = icons;
        self
    }

    #[must_use]
    pub const fn with_labels(mut self, labels: LevelLabels) -> Self {
        self.style.labels = labels;
        self
    }

    #[must_use]
    pub fn with_theme(mut self, theme: Theme) -> Self {
        self.level_styles = build_all_level_styles(&theme);
        self.style.theme = theme;
        self
    }

    #[must_use]
    pub fn with_time_format(mut self, fmt: impl Into<String>) -> Self {
        self.time_format = fmt.into();
        self
    }

    #[must_use]
    pub const fn with_path_width(mut self, width: usize) -> Self {
        self.path_width = width;
        self
    }

    #[must_use]
    pub const fn with_show_path(mut self, show: bool) -> Self {
        self.show_path = show;
        self
    }

    #[must_use]
    pub const fn with_show_spans(mut self, show: bool) -> Self {
        self.show_spans = show;
        self
    }

    fn write_time(&self, writer: &mut Writer<'_>, theme: &Theme) -> fmt::Result {
        let now = Utc::now();
        write!(
            writer,
            "{}",
            theme.text.style(now.format(&self.time_format))
        )
    }
    #[allow(clippy::single_call_fn)]
    fn format_path(file: &str, line: u32, max_width: usize) -> ArrayString<PATH_BUF_SIZE> {
        let normalized: Cow<'_, str> = if file.contains('\\') {
            Cow::Owned(file.replace('\\', "/"))
        } else {
            Cow::Borrowed(file)
        };
        Self::smart_truncate(
            normalized.find("src/").map_or(&*normalized, |i| {
                normalized.get(i.saturating_add(4)..).unwrap_or(&normalized)
            }),
            line,
            max_width,
        )
    }
    #[allow(clippy::single_call_fn)]
    fn smart_truncate(path: &str, line: u32, max_width: usize) -> ArrayString<PATH_BUF_SIZE> {
        let mut full = ArrayString::<PATH_BUF_SIZE>::new();
        let _ = write!(full, "{path}:{line}");

        if full.len() <= max_width {
            let mut result = ArrayString::<PATH_BUF_SIZE>::new();
            write!(result, "{full:>max_width$}").ok();
            return result;
        }

        if let Some(last_slash) = path.rfind('/') {
            let tail = path.get(last_slash.saturating_add(1)..).unwrap_or(path);
            let mut file_part = ArrayString::<PATH_BUF_SIZE>::new();
            let _ = write!(file_part, "{tail}:{line}");

            if file_part.len().saturating_add(2) <= max_width {
                let dir_part = path.get(..last_slash).unwrap_or("");
                let dir_start = dir_part
                    .len()
                    .saturating_sub(max_width.saturating_sub(file_part.len()).saturating_sub(1));
                let dir_tail = dir_part.get(dir_start..).unwrap_or("");
                let clean_dir = dir_tail.find('/').map_or(dir_tail, |i| {
                    dir_tail.get(i.saturating_add(1)..).unwrap_or(dir_tail)
                });

                let mut result = ArrayString::<PATH_BUF_SIZE>::new();
                let _ = write!(result, "{clean_dir}/{file_part}");
                return result;
            }
        }

        let start = full.len().saturating_sub(max_width.saturating_sub(1));
        let mut result = ArrayString::<PATH_BUF_SIZE>::new();
        result.push('\u{2026}');
        result.push_str(full.get(start..).unwrap_or(""));
        result
    }

    fn format_path_section(
        &self,
        writer: &mut Writer<'_>,
        event: &Event<'_>,
        theme: &Theme,
        icons: &Icons,
    ) -> fmt::Result {
        write!(
            writer,
            "{}",
            theme.text.dimmed().style(Self::format_path(
                event.metadata().file().unwrap_or("?"),
                event.metadata().line().unwrap_or(0),
                self.path_width
            ))
        )?;
        write!(writer, " {} ", theme.accent.style(icons.arrow))?;
        Ok(())
    }
    #[allow(clippy::single_call_fn)]
    fn format_fields(writer: &mut Writer<'_>, event: &Event<'_>, theme: &Theme) -> fmt::Result {
        let mut visitor = EventVisitor::default();
        event.record(&mut visitor);

        let mut sep = if let Some(msg) = visitor.message {
            write!(writer, "{}", theme.text.style(msg))?;
            " "
        } else {
            ""
        };

        for (k, v) in visitor.fields {
            write!(
                writer,
                "{}{}{}{}",
                sep,
                theme.secondary.style(k),
                theme.accent.style("="),
                theme.text.style(v)
            )?;
            sep = " ";
        }

        Ok(())
    }
    #[allow(clippy::single_call_fn)]
    fn format_spans<S, N>(
        writer: &mut Writer<'_>,
        ctx: &FmtContext<'_, S, N>,
        theme: &Theme,
        icons: &Icons,
    ) -> fmt::Result
    where
        S: Subscriber + for<'a> LookupSpan<'a>,
        N: for<'a> tracing_subscriber::fmt::FormatFields<'a> + 'static,
    {
        let Some(scope) = ctx
            .event_scope()
            .or_else(|| ctx.lookup_current().map(|s| s.scope()))
        else {
            return Ok(());
        };

        let spans: SmallVec<[_; 8]> = scope.from_root().collect();
        if spans.is_empty() {
            return Ok(());
        }

        let total = spans.len();
        let accent = theme.accent;
        let text = theme.text;

        write!(writer, " {}", accent.style("["))?;

        for (i, span) in spans.iter().enumerate() {
            if i > 0 {
                write!(writer, "{} ", accent.dimmed().style(icons.span_join))?;
            }

            let span_style = if i == total - 1 { text } else { text.dimmed() };

            write!(writer, "{}", span_style.style(span.name()))?;

            let extensions = span.extensions();
            if let Some(fields) = extensions.get::<FormattedFields<N>>() {
                let fields_str = fields.fields.as_str();
                if !fields_str.is_empty() {
                    write!(writer, " {}", span_style.style(fields_str))?;
                }
            }
        }

        write!(writer, "{}", accent.style("]"))
    }
}

impl<S, N> FormatEvent<S, N> for AnsiFormatter
where
    S: Subscriber + for<'a> LookupSpan<'a>,
    N: for<'a> tracing_subscriber::fmt::FormatFields<'a> + 'static,
{
    fn format_event(
        &self,
        ctx: &FmtContext<'_, S, N>,
        mut writer: Writer<'_>,
        event: &Event<'_>,
    ) -> fmt::Result {
        let config = self.style_config();
        let is_nerd = config.icons.is_nerd();

        let level = event.metadata().level();

        let (ls, level_label) = match *level {
            Level::ERROR => (&self.level_styles[0], config.labels.error),
            Level::WARN => (&self.level_styles[1], config.labels.warn),
            Level::INFO => (&self.level_styles[2], config.labels.info),
            Level::DEBUG => (&self.level_styles[3], config.labels.debug),
            Level::TRACE => (&self.level_styles[4], config.labels.trace),
        };

        let fg_style = if is_nerd {
            ls.bracket_fg
        } else {
            ls.bracket_bg
        };

        write!(
            writer,
            "{}",
            config.theme.accent.style(config.icons.time_bracket_open)
        )?;
        self.write_time(&mut writer, &config.theme)?;
        write!(
            writer,
            " {} ",
            config.theme.accent.dimmed().style(config.icons.separator)
        )?;

        write!(writer, "{}", fg_style.style(config.icons.bracket_open))?;
        write!(writer, "{}", ls.label.style(level_label))?;
        write!(writer, "{} ", fg_style.style(config.icons.bracket_close))?;

        write!(
            writer,
            "{} ",
            config.theme.accent.style(config.icons.time_bracket_close)
        )?;

        if self.show_path {
            self.format_path_section(&mut writer, event, &config.theme, &config.icons)?;
        }

        Self::format_fields(&mut writer, event, &config.theme)?;

        if self.show_spans {
            Self::format_spans(&mut writer, ctx, &config.theme, &config.icons)?;
        }

        writeln!(writer)
    }
}

#[derive(Default)]
struct EventVisitor {
    message: Option<String>,
    fields: SmallVec<[(String, String); 4]>,
}

impl EventVisitor {
    fn record_field(&mut self, name: &str, value: String) {
        if name == "message" || name == "msg" {
            self.message = Some(value);
        } else {
            self.fields.push((name.to_owned(), value));
        }
    }
}

impl tracing::field::Visit for EventVisitor {
    fn record_str(&mut self, field: &Field, value: &str) {
        self.record_field(field.name(), value.to_owned());
    }

    fn record_debug(&mut self, field: &Field, value: &dyn Debug) {
        self.record_field(field.name(), format!("{value:?}"));
    }
}

#[cfg(test)]
mod test;