rmcl 0.3.1

A fully featured Minecraft TUI launcher
// sets up the tracing subscriber stack: file logging, tui-logger widget, and
// our custom StatusLayer that feeds WARN/ERROR events into the error toast system.
// also keeps an in-memory ring buffer of log lines for the log overlay viewer.

use std::fmt;
use std::sync::{Arc, Mutex};

use tracing::field::{Field, Visit};
use tracing::{Level, Subscriber};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::layer::Context;
use tracing_subscriber::prelude::*;
use tracing_subscriber::{EnvFilter, Layer};

use std::sync::LazyLock;

use crate::tui::error_buffer::{self, ErrorEvent};

// capped at 5000 lines so it doesn't eat all the ram if something goes haywire
static APP_LOG_LINES: LazyLock<Arc<Mutex<Vec<String>>>> =
    LazyLock::new(|| Arc::new(Mutex::new(Vec::new())));

pub fn get_app_logs() -> Vec<String> {
    APP_LOG_LINES.lock().map(|l| l.clone()).unwrap_or_default()
}

fn push_app_log(line: String) {
    if let Ok(mut lines) = APP_LOG_LINES.lock() {
        lines.push(line);
        if lines.len() > 5000 {
            let drain = lines.len() - 5000;
            lines.drain(..drain);
        }
    }
}

// returns a WorkerGuard that must be held alive for the duration of the
// program, otherwise the file logging thread gets dropped immediately.
// yes, you will spend 30 minutes debugging "why aren't my logs writing"
// before you remember this. ask me how i know.
pub fn init() -> WorkerGuard {
    let log_dir = match dirs_next::cache_dir() {
        Some(d) => d.join("rmcl"),
        None => std::path::PathBuf::from("./cache"),
    };
    match std::fs::create_dir_all(&log_dir) {
        Ok(_) => {}
        Err(e) => {
            eprintln!(
                "Warning: failed to create log directory {}: {}",
                log_dir.display(),
                e
            );
        }
    }

    let now = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S");
    let file_appender = tracing_appender::rolling::never(&log_dir, format!("rmcl_{now}.log"));
    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);

    let env_filter = EnvFilter::builder()
        .with_default_directive(Level::INFO.into())
        .from_env_lossy()
        .add_directive("mc_instance=off".parse().unwrap());

    let rust_log = std::env::var("RUST_LOG").unwrap_or_default().to_lowercase();
    let tui_level = if rust_log.contains("debug") || rust_log.contains("trace") {
        log::LevelFilter::Debug
    } else {
        log::LevelFilter::Info
    };

    match tui_logger::init_logger(log::LevelFilter::Debug) {
        Ok(_) => {}
        Err(e) => {
            eprintln!("Warning: tui-logger init failed: {}", e);
        }
    }
    tui_logger::set_default_level(tui_level);

    tracing_subscriber::registry()
        .with(
            tracing_subscriber::fmt::layer()
                .with_writer(non_blocking)
                .with_ansi(false)
                .with_filter(env_filter),
        )
        .with(tui_logger::TuiTracingSubscriberLayer)
        .with(StatusLayer::new(error_buffer::ERROR_EVENTS.clone()))
        .init();

    guard
}

// custom tracing layer that intercepts all log events:
// - everything gets appended to the in-memory log buffer for the overlay viewer
// - WARN and ERROR additionally get pushed as error toasts
struct StatusLayer;

impl StatusLayer {
    fn new(_events: Arc<Mutex<std::collections::VecDeque<ErrorEvent>>>) -> Self {
        Self
    }
}

impl<S: Subscriber> Layer<S> for StatusLayer {
    fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
        let level = *event.metadata().level();
        let target = event.metadata().target();

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

        let level_str = match level {
            Level::ERROR => "ERROR",
            Level::WARN => "WARN",
            Level::INFO => "INFO",
            Level::DEBUG => "DEBUG",
            Level::TRACE => "TRACE",
        };
        let now = chrono::Local::now().format("%H:%M:%S");
        push_app_log(format!("{now}:{level_str}:{target}: {}", visitor.message));

        if level <= Level::WARN {
            error_buffer::push_error(ErrorEvent {
                id: 0,
                level,
                message: visitor.message,
                pushed_at: std::time::Instant::now(),
            });
        }
    }
}

#[derive(Default)]
struct MessageVisitor {
    message: String,
}

impl Visit for MessageVisitor {
    fn record_str(&mut self, field: &Field, value: &str) {
        if field.name() == "message" {
            self.message = value.to_string();
        }
    }

    fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
        if field.name() == "message" {
            // Debug format wraps strings in quotes, strip them so the toast
            // doesn't show "\"something went wrong\""
            let formatted = format!("{:?}", value);
            self.message = formatted
                .strip_prefix('"')
                .and_then(|s| s.strip_suffix('"'))
                .unwrap_or(&formatted)
                .to_string();
        }
    }
}