acta 0.0.1

A customizable logging library for Rust
Documentation
mod theme;

pub use theme::{Icons, LevelLabels, StyleConfig, Theme, ThemeRgb};

use arrayvec::ArrayString;
use chrono::Local;
use owo_colors::Style;
use smallvec::SmallVec;
use std::borrow::Cow;
use std::fmt;
use std::fmt::{Debug, Write};

use crate::config::ConsoleConfig;
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;

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

const PATH_BUF_SIZE: usize = 256;

#[derive(Debug)]
pub struct AnsiFormatter {
    pub time_format: String,
    pub path_width: usize,
    pub show_path: bool,
    pub show_spans: bool,
    style: StyleConfig,
}

impl Clone for AnsiFormatter {
    fn clone(&self) -> Self {
        Self {
            time_format: self.time_format.clone(),
            path_width: self.path_width,
            show_path: self.show_path,
            show_spans: self.show_spans,
            style: self.style,
        }
    }
}

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

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

    #[must_use]
    pub fn from_config(config: &ConsoleConfig) -> Self {
        let mut fmt = Self::new()
            .with_show_path(config.show_path)
            .with_show_spans(config.show_spans);
        if let Some(tf) = &config.time_format {
            fmt = fmt.with_time_format(tf.clone());
        }
        fmt
    }

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

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

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

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

    #[must_use]
    pub 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.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 get_time(&self) -> String {
        Local::now().format(&self.time_format).to_string()
    }

    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,
        )
    }

    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:>width$}", width = 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(())
    }

    fn format_fields(writer: &mut Writer<'_>, event: &Event<'_>, theme: &Theme) -> fmt::Result {
        let mut visitor = EventVisitor::default();
        event.record(&mut visitor);

        let mut need_space = false;
        if let Some(msg) = visitor.message {
            write!(writer, "{}", theme.text.style(msg))?;
            need_space = !visitor.fields.is_empty();
        }

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

    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 scope = ctx
            .event_scope()
            .or_else(|| ctx.lookup_current().map(|s| s.scope()));
        if let Some(scope) = scope {
            write!(writer, " {} ", theme.accent.style(icons.span_delimiter))?;
            for (i, span) in scope.from_root().enumerate() {
                if i > 0 {
                    write!(writer, " {} ", theme.accent.style(icons.span_join))?;
                }
                Self::write_span(
                    writer.by_ref(),
                    span.name(),
                    span.extensions().get::<FormattedFields<N>>(),
                    theme,
                )?;
            }
        }
        Ok(())
    }

    fn write_span<N>(
        mut writer: Writer<'_>,
        name: &str,
        fields: Option<&FormattedFields<N>>,
        theme: &Theme,
    ) -> fmt::Result
    where
        N: for<'a> tracing_subscriber::fmt::FormatFields<'a> + 'static,
    {
        write!(writer, "{}", theme.text_dimmed.style(name))?;

        if let Some(fields) = fields {
            let fields = fields.fields.as_str();
            if !fields.is_empty() {
                write!(writer, "{}", theme.accent_dimmed.style("{"))?;
                write!(writer, "{}", theme.text_dimmed.style(fields))?;
                write!(writer, "{}", theme.accent_dimmed.style("}"))?;
            }
        }
        Ok(())
    }
}

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 theme = &config.theme;
        let icons = &config.icons;
        let labels = &config.labels;
        let is_nerd = icons.is_nerd();

        let level = event.metadata().level();
        let time = self.get_time();

        let (lc, level_label) = match *level {
            Level::ERROR => (&theme.error, labels.error),
            Level::WARN => (&theme.warn, labels.warn),
            Level::INFO => (&theme.info, labels.info),
            Level::DEBUG => (&theme.debug, labels.debug),
            Level::TRACE => (&theme.trace, labels.trace),
        };

        let fg_style = if is_nerd {
            Style::new().truecolor(lc.rgb.0, lc.rgb.1, lc.rgb.2)
        } else {
            Style::new()
                .truecolor(lc.rgb.0, lc.rgb.1, lc.rgb.2)
                .on_truecolor(lc.rgb.0, lc.rgb.1, lc.rgb.2)
        };
        let bg_style = lc.bg;

        write!(writer, "{}", theme.accent.style(icons.time_bracket_open))?;
        write!(writer, "{}", theme.text.style(time))?;
        write!(writer, " {} ", theme.accent_dimmed.style(icons.separator))?;

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

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

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

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

        if self.show_spans {
            Self::format_spans(&mut writer, ctx, theme, 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;