finery 0.19.0

Keyboard-first Jira backlog and Composer with optional MCP access
Documentation
use std::{
    backtrace::Backtrace,
    error::Error,
    fs,
    io::Write,
    panic,
    path::{Path, PathBuf},
    sync::{Arc, Mutex, OnceLock},
};

use tracing_appender::rolling::{RollingFileAppender, Rotation};

const LOG_FILE_PREFIX: &str = "finery";
const MAX_LOG_FILES: usize = 15;
const PROCESS_MODE_ENV: &str = "FINERY_PROCESS_MODE";

struct DiagnosticLog {
    appender: Mutex<RollingFileAppender>,
    mode: String,
    pid: u32,
}

static LOG: OnceLock<Arc<DiagnosticLog>> = OnceLock::new();

pub fn install() {
    if LOG.get().is_some() {
        return;
    }

    let Some(directory) = log_directory() else {
        eprintln!("Finery diagnostics disabled: user state directory is unavailable");
        return;
    };
    if let Err(error) = create_log_directory(&directory) {
        eprintln!(
            "Finery diagnostics disabled: could not create {}: {error}",
            directory.display()
        );
        return;
    }

    let appender = match RollingFileAppender::builder()
        .rotation(Rotation::DAILY)
        .filename_prefix(LOG_FILE_PREFIX)
        .filename_suffix("log")
        .max_log_files(MAX_LOG_FILES)
        .build(&directory)
    {
        Ok(appender) => appender,
        Err(error) => {
            eprintln!(
                "Finery diagnostics disabled: could not open {}: {error}",
                directory.display()
            );
            return;
        }
    };

    let log = Arc::new(DiagnosticLog {
        appender: Mutex::new(appender),
        mode: process_mode(),
        pid: std::process::id(),
    });
    if LOG.set(Arc::clone(&log)).is_err() {
        return;
    }

    let previous = panic::take_hook();
    panic::set_hook(Box::new(move |panic_info| {
        write_entry(
            &log,
            "PANIC",
            &format!("{panic_info}\n\n{}", Backtrace::force_capture()),
        );
        previous(panic_info);
    }));
}

pub fn record_error(context: &str, error: &dyn Error) {
    let Some(log) = LOG.get() else {
        return;
    };
    write_entry(log, "ERROR", &format!("{context}: {error}"));
}

fn write_entry(log: &DiagnosticLog, level: &str, message: &str) {
    let Ok(mut appender) = log.appender.lock() else {
        return;
    };
    let timestamp = chrono::Utc::now().to_rfc3339();
    let _ = writeln!(
        appender,
        "[{timestamp}] {level} pid={} mode={} {message}\n",
        log.pid, log.mode
    );
    let _ = appender.flush();
}

fn process_mode() -> String {
    if let Ok(mode) = std::env::var(PROCESS_MODE_ENV) {
        return mode;
    }
    match std::env::args().nth(1).as_deref() {
        None => "tui",
        Some("dev") => "dev",
        Some("mcp") => "mcp-stdio",
        Some("serve") => "mcp-http",
        Some("service") => "service-control",
        Some(_) => "cli",
    }
    .into()
}

fn log_directory() -> Option<PathBuf> {
    dirs::state_dir()
        .or_else(dirs::data_local_dir)
        .map(|directory| directory.join("finery").join("logs"))
}

fn create_log_directory(directory: &Path) -> std::io::Result<()> {
    fs::create_dir_all(directory)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;

        fs::set_permissions(directory, fs::Permissions::from_mode(0o700))?;
    }
    Ok(())
}