//! Diagnostics: log level control and the exportable bug-report bundle. //! //! TRACES: UR-078 | DR-218 //! //! The app used to forget everything it did the moment it exited. A user //! reporting "the episode randomly restarted" was reporting the symptom of a //! race whose evidence had been discarded microseconds later, and the only way //! to recover it was to talk them through `adb logcat` — which is how several //! bugs in this project's history actually got diagnosed. //! //! This module is the other half of that: the log is on disk, it survives a //! crash, and the user can hand the whole thing over as one file. //! //! Everything written here has been through [`crate::utils::diagnostics::redact`] //! twice — once in the log formatter, and again on the way into the archive, so //! files written by a build that predates the formatter pass are covered too. use std::fs; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use log::{info, warn, LevelFilter}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Manager}; use crate::utils::diagnostics::{redact, redact_server_url}; /// Where an export landed, so the UI can tell the user where to find it. #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct DiagnosticsBundle { /// Absolute path to the written archive. pub path: String, pub size_bytes: u64, /// How many log files went in, excluding the environment summary. pub file_count: usize, } /// Where logs live and how verbose they currently are. #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct DiagnosticsInfo { /// Directory holding the rotating log files. pub log_dir: String, /// Active level, lowercase: "error" | "warn" | "info" | "debug" | "trace". pub level: String, /// Total bytes currently held by log files. pub total_size_bytes: u64, } /// Name of the file holding the user's chosen level, in the app config dir. const LEVEL_FILE: &str = "log-level"; /// Parse a stored/user-supplied level name. /// /// Unknown values fall back to Info rather than erroring: this is read at /// startup, and a corrupt one-line file must not stop the app from launching. pub fn parse_level(raw: &str) -> LevelFilter { match raw.trim().to_ascii_lowercase().as_str() { "error" => LevelFilter::Error, "warn" => LevelFilter::Warn, "debug" => LevelFilter::Debug, "trace" => LevelFilter::Trace, _ => LevelFilter::Info, } } /// Read the persisted level, if the user has ever set one. /// /// Persisted rather than session-only on purpose: somebody reproducing a bug /// needs debug logging to survive *the restart that reproduces it*. pub fn stored_level(config_dir: &Path) -> Option { fs::read_to_string(config_dir.join(LEVEL_FILE)) .ok() .map(|raw| parse_level(&raw)) } fn level_name(level: LevelFilter) -> &'static str { match level { LevelFilter::Off => "off", LevelFilter::Error => "error", LevelFilter::Warn => "warn", LevelFilter::Info => "info", LevelFilter::Debug => "debug", LevelFilter::Trace => "trace", } } /// Current log level and where the files are. /// /// TRACES: UR-078 | DR-218 #[tauri::command] #[specta::specta] pub async fn diagnostics_get_info(app: AppHandle) -> Result { let log_dir = app .path() .app_log_dir() .map_err(|e| format!("no log directory: {e}"))?; let total_size_bytes = log_files(&log_dir) .iter() .filter_map(|p| fs::metadata(p).ok()) .map(|m| m.len()) .sum(); Ok(DiagnosticsInfo { log_dir: log_dir.to_string_lossy().to_string(), level: level_name(log::max_level()).to_string(), total_size_bytes, }) } /// Set the log level, for this session and the next. /// /// TRACES: UR-078 | DR-218 #[tauri::command] #[specta::specta] pub async fn diagnostics_set_level(app: AppHandle, level: String) -> Result { let parsed = parse_level(&level); log::set_max_level(parsed); let config_dir = app .path() .app_config_dir() .map_err(|e| format!("no config directory: {e}"))?; fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?; fs::write(config_dir.join(LEVEL_FILE), level_name(parsed)).map_err(|e| e.to_string())?; info!("[DIAG] log level set to {}", level_name(parsed)); Ok(level_name(parsed).to_string()) } /// Collect every log file in the log directory, newest first. fn log_files(log_dir: &Path) -> Vec { let Ok(entries) = fs::read_dir(log_dir) else { return Vec::new(); }; let mut files: Vec = entries .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| p.is_file()) .filter(|p| { p.extension() .is_some_and(|ext| ext == "log" || ext == "txt") }) .collect(); files.sort(); files.reverse(); files } /// A short, non-identifying description of the environment. /// /// Deliberately excludes the token, the username, and any path outside the /// app's own directories. The server URL is reduced to scheme and host, which is /// diagnostic (https? LAN address? reverse proxy?) without being a credential. fn environment_summary(app: &AppHandle, server_url: Option<&str>) -> String { let package = app.package_info(); let mut out = String::new(); out.push_str("JellyTau diagnostics\n"); out.push_str("====================\n\n"); out.push_str(&format!("app version: {}\n", package.version)); out.push_str(&format!("tauri version: {}\n", tauri::VERSION)); out.push_str(&format!("os: {}\n", std::env::consts::OS)); out.push_str(&format!("arch: {}\n", std::env::consts::ARCH)); out.push_str(&format!("debug build: {}\n", cfg!(debug_assertions))); out.push_str(&format!( "log level: {}\n", level_name(log::max_level()) )); out.push_str(&format!( "server: {}\n", server_url.map_or("not configured".to_string(), redact_server_url) )); out.push_str("\nNo access token, password or username is included in this file.\n"); out } /// Write a redacted diagnostics archive and return where it went. /// /// # Blocking I/O /// /// This reads and rewrites every log file. It is an `async` command so it does /// not block the IPC thread, but it must never be called from a player event /// callback — see the deadlock note in CLAUDE.md. /// /// TRACES: UR-078 | DR-218 #[tauri::command] #[specta::specta] pub async fn diagnostics_export( app: AppHandle, server_url: Option, ) -> Result { let log_dir = app .path() .app_log_dir() .map_err(|e| format!("no log directory: {e}"))?; // Written into the app's own data directory. Choosing an arbitrary // user-visible location would need a file dialog on desktop and a storage // permission on Android; the UI reports the path and can reveal it. let out_dir = app .path() .app_data_dir() .map_err(|e| format!("no data directory: {e}"))?; fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?; let archive_path = out_dir.join("jellytau-diagnostics.zip"); let file = fs::File::create(&archive_path).map_err(|e| e.to_string())?; let mut zip = zip::ZipWriter::new(file); let options: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated); let files = log_files(&log_dir); let mut written = 0usize; for path in &files { let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; let mut contents = String::new(); match fs::File::open(path).and_then(|mut f| f.read_to_string(&mut contents)) { Ok(_) => {} Err(e) => { // A log we cannot read is not a reason to produce no bundle. warn!("[DIAG] skipping unreadable log {name}: {e}"); continue; } } // Second redaction pass. The formatter already cleaned anything this // build wrote; this covers files left by an older build. let cleaned: String = contents.lines().map(redact).collect::>().join("\n"); zip.start_file(name, options).map_err(|e| e.to_string())?; zip.write_all(cleaned.as_bytes()) .map_err(|e| e.to_string())?; written += 1; } zip.start_file("environment.txt", options) .map_err(|e| e.to_string())?; zip.write_all(environment_summary(&app, server_url.as_deref()).as_bytes()) .map_err(|e| e.to_string())?; zip.finish().map_err(|e| e.to_string())?; let size_bytes = fs::metadata(&archive_path) .map_err(|e| e.to_string())? .len(); info!( "[DIAG] exported {written} log file(s), {size_bytes} bytes -> {}", archive_path.display() ); Ok(DiagnosticsBundle { path: archive_path.to_string_lossy().to_string(), size_bytes, file_count: written, }) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_every_level_name_case_insensitively() { assert_eq!(parse_level("debug"), LevelFilter::Debug); assert_eq!(parse_level("DEBUG"), LevelFilter::Debug); assert_eq!(parse_level(" warn\n"), LevelFilter::Warn); assert_eq!(parse_level("error"), LevelFilter::Error); assert_eq!(parse_level("trace"), LevelFilter::Trace); } #[test] fn unknown_level_falls_back_to_info_rather_than_failing() { // This is read at startup from a file on disk. A corrupt value must not // stop the app launching. assert_eq!(parse_level("banana"), LevelFilter::Info); assert_eq!(parse_level(""), LevelFilter::Info); } #[test] fn level_names_round_trip() { for name in ["error", "warn", "info", "debug", "trace"] { assert_eq!(level_name(parse_level(name)), name); } } #[test] fn stored_level_is_none_when_never_set() { let dir = std::env::temp_dir().join("jellytau-diag-test-empty"); let _ = fs::create_dir_all(&dir); let _ = fs::remove_file(dir.join(LEVEL_FILE)); assert!(stored_level(&dir).is_none()); } #[test] fn stored_level_reads_back_what_was_written() { let dir = std::env::temp_dir().join("jellytau-diag-test-roundtrip"); fs::create_dir_all(&dir).unwrap(); fs::write(dir.join(LEVEL_FILE), "debug").unwrap(); assert_eq!(stored_level(&dir), Some(LevelFilter::Debug)); let _ = fs::remove_file(dir.join(LEVEL_FILE)); } #[test] fn log_files_ignores_non_log_files() { let dir = std::env::temp_dir().join("jellytau-diag-test-listing"); fs::create_dir_all(&dir).unwrap(); fs::write(dir.join("jellytau.log"), "x").unwrap(); fs::write(dir.join("notes.md"), "x").unwrap(); fs::write(dir.join("jellytau.zip"), "x").unwrap(); let found = log_files(&dir); let names: Vec = found .iter() .filter_map(|p| p.file_name()?.to_str().map(String::from)) .collect(); assert!(names.contains(&"jellytau.log".to_string())); // The export archive itself lives elsewhere, but never re-zip a zip. assert!(!names.contains(&"jellytau.zip".to_string())); assert!(!names.contains(&"notes.md".to_string())); let _ = fs::remove_dir_all(&dir); } }