hoard 0.6.1

Hoard backups of files across your filesystem into one location.
Documentation
//! Defines the default logging behavior for Hoard, using [`tracing`].
//!
//! [`Formatter`] does the heavy lifting of formatting the output.
//!
//! [`get_subscriber`] returns a default [`SubscriberBuilder`] that can be used without any changes.

use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;

use tracing::field::{Field, Visit};
use tracing::{Event, Level, Subscriber};
use tracing_subscriber::field::RecordFields;
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::{
    FmtContext, FormatEvent, FormatFields, FormattedFields, SubscriberBuilder,
};
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::{EnvFilter, FmtSubscriber};

const LOG_ENV: &str = "HOARD_LOG";
const EMPTY_PREFIX: &str = "    ";

struct FormatterVisitor {
    message: Option<String>,
    fields: BTreeMap<String, String>,
    is_terse: bool,
}

impl FormatterVisitor {
    fn new(is_terse: bool) -> Self {
        Self {
            message: None,
            fields: BTreeMap::new(),
            is_terse,
        }
    }
}

impl Visit for FormatterVisitor {
    fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
        if self.message.is_none() && field.name() == "message" {
            self.message = Some(format!("{value:?}"));
        } else {
            let value = if self.is_terse {
                format!("{value:?}")
            } else {
                format!("{value:#?}")
            };

            self.fields.insert(field.name().to_string(), value);
        }
    }
}

/// The default log formatter for Hoard.
#[derive(Clone, Debug)]
pub struct Formatter {
    max_level: Level,
}

impl<'writer> FormatFields<'writer> for Formatter {
    fn format_fields<R: RecordFields>(
        &self,
        mut writer: Writer<'writer>,
        fields: R,
    ) -> fmt::Result {
        let is_terse = matches!(self.max_level, Level::ERROR | Level::WARN | Level::INFO);
        let mut visitor = FormatterVisitor::new(is_terse);
        fields.record(&mut visitor);

        let empty_prefix_newline = format!("\n{EMPTY_PREFIX}");
        let longest_name_len = visitor.fields.keys().map(String::len).max().unwrap_or(0);
        let fields = visitor
            .fields
            .iter()
            .map(|(name, value)| {
                let value = value
                    .split('\n')
                    .collect::<Vec<_>>()
                    .join(&empty_prefix_newline);
                if is_terse {
                    format!("{name}={value}")
                } else {
                    let padding = " ".repeat(longest_name_len - name.len());
                    format!("{padding}{name} = {value}")
                }
            })
            .collect::<Vec<_>>();

        let fields_output = if is_terse {
            fields.join(", ")
        } else {
            let fields = fields.join(&format!("\n{EMPTY_PREFIX}      "));
            if fields.is_empty() {
                fields
            } else {
                format!("\n{EMPTY_PREFIX}where {fields}")
            }
        };

        if let Some(msg) = visitor.message {
            write!(writer, "{msg}")?;
            if is_terse && !fields_output.is_empty() {
                write!(writer, ": ")?;
            }
        }

        write!(writer, "{fields_output}")
    }
}

#[allow(single_use_lifetimes)]
impl<S, N> FormatEvent<S, N> for Formatter
where
    S: Subscriber + for<'a> LookupSpan<'a>,
    N: for<'a> FormatFields<'a> + 'static,
{
    fn format_event(
        &self,
        ctx: &FmtContext<'_, S, N>,
        writer: Writer<'_>,
        event: &Event<'_>,
    ) -> fmt::Result {
        let mut writer = writer;
        let metadata = event.metadata();

        // Only show prefix if debug or higher verbosity, or warning/error
        if self.max_level >= Level::DEBUG || metadata.level() != &Level::INFO {
            write!(writer, "{} {}: ", metadata.level(), metadata.target())?;
        }

        // Write message
        // TODO: ANSIfy?
        ctx.field_format().format_fields(writer.by_ref(), event)?;
        writeln!(writer)?;

        // Format spans only if tracing verbosity
        if matches!(self.max_level, Level::TRACE | Level::DEBUG) {
            if let Some(scope) = ctx.event_scope() {
                for span in scope.from_root() {
                    write!(writer, "{}at {}", EMPTY_PREFIX, span.name())?;
                    let ext = span.extensions();
                    let fields = ext
                        .get::<FormattedFields<N>>()
                        .expect("should never be `None`")
                        .to_string();
                    if !fields.is_empty() {
                        // join string matches the above write!()
                        let fields = fields.split('\n').collect::<Vec<_>>().join("\n   ");
                        write!(writer, ": {fields}")?;
                    }
                    writeln!(writer)?;
                }
            }

            // Add extra newline for easier reading
            writeln!(writer)
        } else {
            Ok(())
        }
    }
}

/// Returns a [`SubscriberBuilder`] with the default settings for Hoard.
pub fn get_subscriber() -> SubscriberBuilder<Formatter, Formatter, EnvFilter> {
    let max_level = {
        let env_str = std::env::var(LOG_ENV).unwrap_or_else(|_| String::new());

        // Get the last item that is only a level
        let level_opt = env_str
            .split(',')
            .map(str::trim)
            .rev()
            .map(FromStr::from_str)
            .find_map(Result::ok);

        level_opt.unwrap_or({
            if cfg!(debug_assertions) {
                Level::DEBUG
            } else {
                Level::INFO
            }
        })
    };

    let env_filter = EnvFilter::try_from_env(LOG_ENV)
        .unwrap_or_else(|_| EnvFilter::default().add_directive(max_level.into()));

    FmtSubscriber::builder()
        .with_env_filter(env_filter)
        .event_format(Formatter { max_level })
        .fmt_fields(Formatter { max_level })
}