use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
static TRACE_FILE: OnceLock>> = OnceLock::new();
static HEALTH_FILE: OnceLock >> = OnceLock::new();
static TRACE_BYTES: OnceLock = OnceLock::new();
const HEALTH_LOG_MAX_BYTES: u64 = 1024 * 1024;
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;
};
write_event(file, name, fields);
}
pub fn health_event(name: &str, fields: &[(&str, String)]) {
let Some(file) = HEALTH_FILE.get_or_init(open_health_file) else {
return;
};
write_event(file, name, fields);
}
pub fn default_health_log_path() -> PathBuf {
trace_root().join("health.log")
}
fn write_event(file: &Mutex, name: &str, fields: &[(&str, String)]) {
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 open_health_file() -> Option> {
let path = health_log_path_from_env()?;
rotate_health_log_if_needed(&path);
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 health_log_path_from_env() -> Option {
match std::env::var_os("DOSH_HEALTH_LOG") {
Some(raw) => {
let normalized = raw.to_string_lossy().to_ascii_lowercase();
if normalized.is_empty() || matches!(normalized.as_str(), "0" | "false" | "off") {
None
} else if matches!(normalized.as_str(), "1" | "true" | "on") {
Some(default_health_log_path())
} else {
Some(PathBuf::from(raw))
}
}
None => Some(default_health_log_path()),
}
}
fn rotate_health_log_if_needed(path: &Path) {
let Ok(metadata) = std::fs::metadata(path) else {
return;
};
if metadata.len() <= HEALTH_LOG_MAX_BYTES {
return;
}
let rotated = path.with_extension("log.1");
let _ = std::fs::remove_file(&rotated);
let _ = std::fs::rename(path, rotated);
}
fn default_trace_path() -> PathBuf {
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());
trace_root().join(format!("{}-{}.log", exe, std::process::id()))
}
fn trace_root() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(std::env::temp_dir)
.join("dosh")
.join("trace")
}
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
}