pahe-cli 0.1.8-alpha.3

pahe's cli
use crossterm::terminal::{Clear, ClearType};
use crossterm::{cursor, execute};
use owo_colors::OwoColorize;
use std::io::Write;
use std::{
    sync::atomic::{AtomicBool, AtomicUsize, Ordering},
    sync::{Arc, Once},
    time::Duration,
};
use tracing::{Event, Subscriber};
use tracing_subscriber::field::Visit;
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::prelude::*;
use tracing_subscriber::registry::Registry;

use pahe::errors::*;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum LogLevel {
    Error,
    Warn,
    Info,
    Debug,
}

impl LogLevel {
    fn parse(raw: &str) -> Option<Self> {
        match raw.trim().to_ascii_lowercase().as_str() {
            "error" => Some(Self::Error),
            "warn" | "warning" => Some(Self::Warn),
            "info" => Some(Self::Info),
            "debug" => Some(Self::Debug),
            _ => None,
        }
    }
}

#[derive(Debug)]
pub struct CliLogger {
    pub level: LogLevel,
    pub spinner_step: AtomicUsize,
    pub loading_active: AtomicBool,
    pub loading_padded: AtomicBool,
}

#[derive(Debug, Clone, Copy)]
enum LogState {
    Success,
    Failed,
    Debug,
}

impl CliLogger {
    pub fn new(level: &str) -> Self {
        Self::new_(level).unwrap_or(CliLogger {
            level: LogLevel::Info,
            spinner_step: AtomicUsize::new(0),
            loading_active: AtomicBool::new(false),
            loading_padded: AtomicBool::new(false),
        })
    }

    fn new_(level: &str) -> Result<Self> {
        let level = LogLevel::parse(level).ok_or(PaheError::Message(format!(
            "invalid log level: {level}. expected one of: error, warn, info, debug"
        )))?;

        Ok(Self {
            level,
            spinner_step: AtomicUsize::new(0),
            loading_active: AtomicBool::new(false),
            loading_padded: AtomicBool::new(false),
        })
    }

    fn log(&self, level: LogLevel, state: LogState, message: impl AsRef<str>) {
        self.clear_loading_line_if_needed();
        let icon = self.icon(state);

        if level <= self.level {
            println!("{} {}", icon, message.as_ref());
        }
    }

    pub fn loading(&self, message: impl AsRef<str>) {
        if LogLevel::Info > self.level {
            return;
        }

        self.draw_loading_frame(message.as_ref());
    }

    pub fn success(&self, message: impl AsRef<str>) {
        self.log(LogLevel::Info, LogState::Success, message);
    }

    pub fn failed(&self, message: impl AsRef<str>) {
        self.log(LogLevel::Error, LogState::Failed, message);
    }

    pub fn debug(&self, context: impl AsRef<str>, message: impl AsRef<str>) {
        self.log(
            LogLevel::Debug,
            LogState::Debug,
            format!(
                "{:>15} {}",
                context.as_ref().bold().bright_purple(),
                message.as_ref()
            ),
        );
    }

    fn icon(&self, state: LogState) -> Box<dyn std::fmt::Display> {
        match state {
            LogState::Success => Box::new("".green()),
            LogState::Failed => Box::new("".red()),
            LogState::Debug => Box::new("λ".cyan()),
        }
    }

    pub async fn while_loading<F, T>(&self, message: impl Into<String>, future: F) -> T
    where
        F: Future<Output = T>,
    {
        if LogLevel::Info > self.level {
            return future.await;
        }

        let message = message.into();
        let mut ticker = tokio::time::interval(Duration::from_millis(120));
        let mut future = Box::pin(future);
        self.loading_active.store(true, Ordering::Relaxed);

        loop {
            tokio::select! {
                result = &mut future => {
                    self.clear_loading_line_if_needed();
                    return result;
                }
                _ = ticker.tick() => {
                    self.draw_loading_frame(&message);
                }
            }
        }
    }

    fn draw_loading_frame(&self, message: &str) {
        const FRAMES: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""];
        let idx = self.spinner_step.fetch_add(1, Ordering::Relaxed);
        let frame = FRAMES[idx % FRAMES.len()].to_string();
        let frame = frame.yellow();

        let mut stdout = std::io::stdout();

        if !self.loading_padded.swap(true, Ordering::Relaxed) {
            let _ = writeln!(stdout);
        }

        self.loading_active.store(true, Ordering::Relaxed);
        let _ = execute!(
            stdout,
            cursor::MoveToColumn(0),
            Clear(ClearType::CurrentLine)
        );
        let _ = write!(stdout, "{frame} {message}");
        let _ = stdout.flush();
    }

    fn clear_loading_line_if_needed(&self) {
        if self.loading_active.swap(false, Ordering::Relaxed) {
            let mut stdout = std::io::stdout();
            let _ = execute!(
                stdout,
                cursor::MoveToColumn(0),
                Clear(ClearType::CurrentLine)
            );
            if self.loading_padded.load(Ordering::Relaxed) {
                let _ = execute!(
                    stdout,
                    cursor::MoveUp(1),
                    cursor::MoveToColumn(0),
                    Clear(ClearType::CurrentLine)
                );
            }
            let _ = stdout.flush();
            self.loading_padded.store(false, Ordering::Relaxed);
        }
    }
}

#[derive(Default)]
struct EventFieldVisitor {
    message: Option<String>,
    extras: Vec<String>,
}

impl Visit for EventFieldVisitor {
    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
        if field.name() == "message" {
            self.message = Some(format!("{value:?}").trim_matches('"').to_string());
            return;
        }

        self.extras.push(format!("{}={value:?}", field.name()));
    }
}

struct CliTracingLayer {
    logger: Arc<CliLogger>,
}

impl<S> Layer<S> for CliTracingLayer
where
    S: Subscriber,
{
    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
        let metadata = event.metadata();
        let target = metadata.target();
        if !(target.starts_with("pahe::") || target.starts_with("pahe_core::")) {
            return;
        }

        let mut visitor = EventFieldVisitor::default();
        event.record(&mut visitor);

        let mut line = String::new();
        if let Some(message) = visitor.message {
            line.push_str(&message);
        } else {
            line.push_str("trace event");
        }

        if !visitor.extras.is_empty() {
            line.push(' ');
            line.push_str(&visitor.extras.join(" "));
        }

        self.logger.debug(target, line)
    }
}

pub fn init_tracing(logger: Arc<CliLogger>) {
    static INIT: Once = Once::new();

    INIT.call_once(|| {
        let subscriber = Registry::default().with(CliTracingLayer {
            logger: Arc::clone(&logger),
        });

        if let Err(err) = tracing::subscriber::set_global_default(subscriber) {
            logger.debug(
                "logger",
                format!("failed to initialize tracing subscriber: {err}"),
            );
        }
    });
}