Skip to main content

#
jellytau_lib/commands/
diagnostics.rs

    1//! Diagnostics: log level control and the exportable bug-report bundle.
    2//!
    3//! TRACES: UR-078 | DR-218
    4//!
    5//! The app used to forget everything it did the moment it exited. A user
    6//! reporting "the episode randomly restarted" was reporting the symptom of a
    7//! race whose evidence had been discarded microseconds later, and the only way
    8//! to recover it was to talk them through `adb logcat` — which is how several
    9//! bugs in this project's history actually got diagnosed.
    10//!
    11//! This module is the other half of that: the log is on disk, it survives a
    12//! crash, and the user can hand the whole thing over as one file.
    13//!
    14//! Everything written here has been through [`crate::utils::diagnostics::redact`]
    15//! twice — once in the log formatter, and again on the way into the archive, so
    16//! files written by a build that predates the formatter pass are covered too.
    17
    18use std::fs;
    19use std::io::{Read, Write};
    20use std::path::{Path, PathBuf};
    21
    22use log::{info, warn, LevelFilter};
    23use serde::{Deserialize, Serialize};
    24use tauri::{AppHandle, Manager};
    25
    26use crate::utils::diagnostics::{redact, redact_server_url};
    27
    28/// Where an export landed, so the UI can tell the user where to find it.
    29#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
    30#[serde(rename_all = "camelCase")]
    31pub struct DiagnosticsBundle {
    32    /// Absolute path to the written archive.
    33    pub path: String,
    34    pub size_bytes: u64,
    35    /// How many log files went in, excluding the environment summary.
    36    pub file_count: usize,
    37}
    38
    39/// Where logs live and how verbose they currently are.
    40#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
    41#[serde(rename_all = "camelCase")]
    42pub struct DiagnosticsInfo {
    43    /// Directory holding the rotating log files.
    44    pub log_dir: String,
    45    /// Active level, lowercase: "error" | "warn" | "info" | "debug" | "trace".
    46    pub level: String,
    47    /// Total bytes currently held by log files.
    48    pub total_size_bytes: u64,
    49}
    50
    51/// Name of the file holding the user's chosen level, in the app config dir.
    52const LEVEL_FILE: &str = "log-level";
    53
    54/// Parse a stored/user-supplied level name.
    55///
    56/// Unknown values fall back to Info rather than erroring: this is read at
    57/// startup, and a corrupt one-line file must not stop the app from launching.
    58pub fn parse_level(raw: &str) -> LevelFilter {
    59    match raw.trim().to_ascii_lowercase().as_str() {
    60        "error" => LevelFilter::Error,
    61        "warn" => LevelFilter::Warn,
    62        "debug" => LevelFilter::Debug,
    63        "trace" => LevelFilter::Trace,
    64        _ => LevelFilter::Info,
    65    }
    66}
    67
    68/// Read the persisted level, if the user has ever set one.
    69///
    70/// Persisted rather than session-only on purpose: somebody reproducing a bug
    71/// needs debug logging to survive *the restart that reproduces it*.
    72pub fn stored_level(config_dir: &Path) -> Option<LevelFilter> {
    73    fs::read_to_string(config_dir.join(LEVEL_FILE))
    74        .ok()
    75        .map(|raw| parse_level(&raw))
    76}
    77
    78fn level_name(level: LevelFilter) -> &'static str {
    79    match level {
    80        LevelFilter::Off => "off",
    81        LevelFilter::Error => "error",
    82        LevelFilter::Warn => "warn",
    83        LevelFilter::Info => "info",
    84        LevelFilter::Debug => "debug",
    85        LevelFilter::Trace => "trace",
    86    }
    87}
    88
    89/// Current log level and where the files are.
    90///
    91/// TRACES: UR-078 | DR-218
    92#[tauri::command]
    93#[specta::specta]
    94pub async fn diagnostics_get_info(app: AppHandle) -> Result<DiagnosticsInfo, String> {
    95    let log_dir = app
    96        .path()
    97        .app_log_dir()
    98        .map_err(|e| format!("no log directory: {e}"))?;
    99
    100    let total_size_bytes = log_files(&log_dir)
    101        .iter()
    102        .filter_map(|p| fs::metadata(p).ok())
    103        .map(|m| m.len())
    104        .sum();
    105
    106    Ok(DiagnosticsInfo {
    107        log_dir: log_dir.to_string_lossy().to_string(),
    108        level: level_name(log::max_level()).to_string(),
    109        total_size_bytes,
    110    })
    111}
    112
    113/// Set the log level, for this session and the next.
    114///
    115/// TRACES: UR-078 | DR-218
    116#[tauri::command]
    117#[specta::specta]
    118pub async fn diagnostics_set_level(app: AppHandle, level: String) -> Result<String, String> {
    119    let parsed = parse_level(&level);
    120    log::set_max_level(parsed);
    121
    122    let config_dir = app
    123        .path()
    124        .app_config_dir()
    125        .map_err(|e| format!("no config directory: {e}"))?;
    126    fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?;
    127    fs::write(config_dir.join(LEVEL_FILE), level_name(parsed)).map_err(|e| e.to_string())?;
    128
    129    info!("[DIAG] log level set to {}", level_name(parsed));
    130    Ok(level_name(parsed).to_string())
    131}
    132
    133/// Collect every log file in the log directory, newest first.
    134fn log_files(log_dir: &Path) -> Vec<PathBuf> {
    135    let Ok(entries) = fs::read_dir(log_dir) else {
    136        return Vec::new();
    137    };
    138    let mut files: Vec<PathBuf> = entries
    139        .filter_map(|e| e.ok())
    140        .map(|e| e.path())
    141        .filter(|p| p.is_file())
    142        .filter(|p| {
    143            p.extension()
    144                .is_some_and(|ext| ext == "log" || ext == "txt")
    145        })
    146        .collect();
    147    files.sort();
    148    files.reverse();
    149    files
    150}
    151
    152/// A short, non-identifying description of the environment.
    153///
    154/// Deliberately excludes the token, the username, and any path outside the
    155/// app's own directories. The server URL is reduced to scheme and host, which is
    156/// diagnostic (https? LAN address? reverse proxy?) without being a credential.
    157fn environment_summary(app: &AppHandle, server_url: Option<&str>) -> String {
    158    let package = app.package_info();
    159    let mut out = String::new();
    160    out.push_str("JellyTau diagnostics\n");
    161    out.push_str("====================\n\n");
    162    out.push_str(&format!("app version:   {}\n", package.version));
    163    out.push_str(&format!("tauri version: {}\n", tauri::VERSION));
    164    out.push_str(&format!("os:            {}\n", std::env::consts::OS));
    165    out.push_str(&format!("arch:          {}\n", std::env::consts::ARCH));
    166    out.push_str(&format!("debug build:   {}\n", cfg!(debug_assertions)));
    167    out.push_str(&format!(
    168        "log level:     {}\n",
    169        level_name(log::max_level())
    170    ));
    171    out.push_str(&format!(
    172        "server:        {}\n",
    173        server_url.map_or("not configured".to_string(), redact_server_url)
    174    ));
    175    out.push_str("\nNo access token, password or username is included in this file.\n");
    176    out
    177}
    178
    179/// Write a redacted diagnostics archive and return where it went.
    180///
    181/// # Blocking I/O
    182///
    183/// This reads and rewrites every log file. It is an `async` command so it does
    184/// not block the IPC thread, but it must never be called from a player event
    185/// callback — see the deadlock note in CLAUDE.md.
    186///
    187/// TRACES: UR-078 | DR-218
    188#[tauri::command]
    189#[specta::specta]
    190pub async fn diagnostics_export(
    191    app: AppHandle,
    192    server_url: Option<String>,
    193) -> Result<DiagnosticsBundle, String> {
    194    let log_dir = app
    195        .path()
    196        .app_log_dir()
    197        .map_err(|e| format!("no log directory: {e}"))?;
    198
    199    // Written into the app's own data directory. Choosing an arbitrary
    200    // user-visible location would need a file dialog on desktop and a storage
    201    // permission on Android; the UI reports the path and can reveal it.
    202    let out_dir = app
    203        .path()
    204        .app_data_dir()
    205        .map_err(|e| format!("no data directory: {e}"))?;
    206    fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
    207
    208    let archive_path = out_dir.join("jellytau-diagnostics.zip");
    209    let file = fs::File::create(&archive_path).map_err(|e| e.to_string())?;
    210    let mut zip = zip::ZipWriter::new(file);
    211    let options: zip::write::FileOptions<'_, ()> =
    212        zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
    213
    214    let files = log_files(&log_dir);
    215    let mut written = 0usize;
    216
    217    for path in &files {
    218        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
    219            continue;
    220        };
    221
    222        let mut contents = String::new();
    223        match fs::File::open(path).and_then(|mut f| f.read_to_string(&mut contents)) {
    224            Ok(_) => {}
    225            Err(e) => {
    226                // A log we cannot read is not a reason to produce no bundle.
    227                warn!("[DIAG] skipping unreadable log {name}: {e}");
    228                continue;
    229            }
    230        }
    231
    232        // Second redaction pass. The formatter already cleaned anything this
    233        // build wrote; this covers files left by an older build.
    234        let cleaned: String = contents.lines().map(redact).collect::<Vec<_>>().join("\n");
    235
    236        zip.start_file(name, options).map_err(|e| e.to_string())?;
    237        zip.write_all(cleaned.as_bytes())
    238            .map_err(|e| e.to_string())?;
    239        written += 1;
    240    }
    241
    242    zip.start_file("environment.txt", options)
    243        .map_err(|e| e.to_string())?;
    244    zip.write_all(environment_summary(&app, server_url.as_deref()).as_bytes())
    245        .map_err(|e| e.to_string())?;
    246
    247    zip.finish().map_err(|e| e.to_string())?;
    248
    249    let size_bytes = fs::metadata(&archive_path)
    250        .map_err(|e| e.to_string())?
    251        .len();
    252    info!(
    253        "[DIAG] exported {written} log file(s), {size_bytes} bytes -> {}",
    254        archive_path.display()
    255    );
    256
    257    Ok(DiagnosticsBundle {
    258        path: archive_path.to_string_lossy().to_string(),
    259        size_bytes,
    260        file_count: written,
    261    })
    262}
    263
    264#[cfg(test)]
    265mod tests {
    266    use super::*;
    267
    268    #[test]
    269    fn parses_every_level_name_case_insensitively() {
    270        assert_eq!(parse_level("debug"), LevelFilter::Debug);
    271        assert_eq!(parse_level("DEBUG"), LevelFilter::Debug);
    272        assert_eq!(parse_level("  warn\n"), LevelFilter::Warn);
    273        assert_eq!(parse_level("error"), LevelFilter::Error);
    274        assert_eq!(parse_level("trace"), LevelFilter::Trace);
    275    }
    276
    277    #[test]
    278    fn unknown_level_falls_back_to_info_rather_than_failing() {
    279        // This is read at startup from a file on disk. A corrupt value must not
    280        // stop the app launching.
    281        assert_eq!(parse_level("banana"), LevelFilter::Info);
    282        assert_eq!(parse_level(""), LevelFilter::Info);
    283    }
    284
    285    #[test]
    286    fn level_names_round_trip() {
    287        for name in ["error", "warn", "info", "debug", "trace"] {
    288            assert_eq!(level_name(parse_level(name)), name);
    289        }
    290    }
    291
    292    #[test]
    293    fn stored_level_is_none_when_never_set() {
    294        let dir = std::env::temp_dir().join("jellytau-diag-test-empty");
    295        let _ = fs::create_dir_all(&dir);
    296        let _ = fs::remove_file(dir.join(LEVEL_FILE));
    297        assert!(stored_level(&dir).is_none());
    298    }
    299
    300    #[test]
    301    fn stored_level_reads_back_what_was_written() {
    302        let dir = std::env::temp_dir().join("jellytau-diag-test-roundtrip");
    303        fs::create_dir_all(&dir).unwrap();
    304        fs::write(dir.join(LEVEL_FILE), "debug").unwrap();
    305        assert_eq!(stored_level(&dir), Some(LevelFilter::Debug));
    306        let _ = fs::remove_file(dir.join(LEVEL_FILE));
    307    }
    308
    309    #[test]
    310    fn log_files_ignores_non_log_files() {
    311        let dir = std::env::temp_dir().join("jellytau-diag-test-listing");
    312        fs::create_dir_all(&dir).unwrap();
    313        fs::write(dir.join("jellytau.log"), "x").unwrap();
    314        fs::write(dir.join("notes.md"), "x").unwrap();
    315        fs::write(dir.join("jellytau.zip"), "x").unwrap();
    316
    317        let found = log_files(&dir);
    318        let names: Vec<String> = found
    319            .iter()
    320            .filter_map(|p| p.file_name()?.to_str().map(String::from))
    321            .collect();
    322
    323        assert!(names.contains(&"jellytau.log".to_string()));
    324        // The export archive itself lives elsewhere, but never re-zip a zip.
    325        assert!(!names.contains(&"jellytau.zip".to_string()));
    326        assert!(!names.contains(&"notes.md".to_string()));
    327
    328        let _ = fs::remove_dir_all(&dir);
    329    }
    330}