jellytau_lib/commands/
diagnostics.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
30#[serde(rename_all = "camelCase")]
31pub struct DiagnosticsBundle {
32 pub path: String,
34 pub size_bytes: u64,
35 pub file_count: usize,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
41#[serde(rename_all = "camelCase")]
42pub struct DiagnosticsInfo {
43 pub log_dir: String,
45 pub level: String,
47 pub total_size_bytes: u64,
49}
50
51const LEVEL_FILE: &str = "log-level";
53
54pub 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
68pub 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#[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#[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
133fn 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
152fn 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#[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 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 warn!("[DIAG] skipping unreadable log {name}: {e}");
228 continue;
229 }
230 }
231
232 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 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 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}