use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; static TRACE_FILE: OnceLock>> = OnceLock::new(); static TRACE_BYTES: OnceLock = OnceLock::new(); pub fn enabled() -> bool { TRACE_FILE.get_or_init(open_trace_file).is_some() } pub fn bytes_enabled() -> bool { *TRACE_BYTES.get_or_init(|| truthy_env("DOSH_TRACE_BYTES")) } pub fn event(name: &str, fields: &[(&str, String)]) { let Some(file) = TRACE_FILE.get_or_init(open_trace_file) else { return; }; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis()) .unwrap_or(0); let mut line = format!( "ts_ms={} pid={} event={}", now, std::process::id(), shell_escape(name) ); for (key, value) in fields { line.push(' '); line.push_str(key); line.push('='); line.push_str(&shell_escape(value)); } line.push('\n'); if let Ok(mut file) = file.lock() { let _ = file.write_all(line.as_bytes()); let _ = file.flush(); } } pub fn bytes_summary(bytes: &[u8]) -> String { let mut parts = vec![ format!("len={}", bytes.len()), format!( "esc={}", contains_byte(bytes, 0x1b) || contains_byte(bytes, 0x9b) ), format!("focus={}", has_focus_report(bytes)), format!("mouseish={}", looks_mouseish(bytes)), format!("printable={}", printable_count(bytes)), ]; if bytes_enabled() { parts.push(format!("hex={}", hex_prefix(bytes, 160))); } parts.join(",") } fn open_trace_file() -> Option> { let raw = std::env::var_os("DOSH_TRACE")?; let normalized = raw.to_string_lossy().to_ascii_lowercase(); if normalized.is_empty() || matches!(normalized.as_str(), "0" | "false" | "off") { return None; } let path = if matches!(normalized.as_str(), "1" | "true" | "on") { default_trace_path() } else { PathBuf::from(raw) }; if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } OpenOptions::new() .create(true) .append(true) .open(&path) .ok() .map(Mutex::new) } fn default_trace_path() -> PathBuf { let root = dirs::cache_dir() .unwrap_or_else(std::env::temp_dir) .join("dosh") .join("trace"); let exe = std::env::current_exe() .ok() .and_then(|path| { path.file_stem() .map(|stem| stem.to_string_lossy().to_string()) }) .unwrap_or_else(|| "dosh".to_string()); root.join(format!("{}-{}.log", exe, std::process::id())) } fn truthy_env(name: &str) -> bool { std::env::var_os(name).is_some_and(|value| { let normalized = value.to_string_lossy().to_ascii_lowercase(); !normalized.is_empty() && !matches!(normalized.as_str(), "0" | "false" | "off") }) } fn shell_escape(value: &str) -> String { if value.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b':' | b'/' | b',' | b'=') }) { return value.to_string(); } let mut out = String::from("'"); for ch in value.chars() { if ch == '\'' { out.push_str("'\\''"); } else { out.push(ch); } } out.push('\''); out } fn contains_byte(bytes: &[u8], needle: u8) -> bool { bytes.contains(&needle) } fn has_focus_report(bytes: &[u8]) -> bool { bytes .windows(3) .any(|window| matches!(window, b"\x1b[I" | b"\x1b[O")) || bytes .windows(2) .any(|window| matches!(window, b"\x9bI" | b"\x9bO")) } fn looks_mouseish(bytes: &[u8]) -> bool { bytes.windows(3).any(|window| window == b"\x1b[<") || bytes.windows(2).any(|window| window == b"\x9b<") || bytes.iter().filter(|byte| **byte == b';').take(3).count() >= 2 } fn printable_count(bytes: &[u8]) -> usize { bytes .iter() .filter(|byte| byte.is_ascii_graphic() || **byte == b' ') .count() } fn hex_prefix(bytes: &[u8], max: usize) -> String { let mut out = String::with_capacity(bytes.len().min(max) * 2); for byte in bytes.iter().take(max) { use std::fmt::Write as _; let _ = write!(&mut out, "{byte:02x}"); } if bytes.len() > max { out.push_str("..."); } out }