use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use anyhow::{Context, Result};
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
const DEFAULT_LOG_RETENTION_DAYS: u64 = 7;
const LOG_RETENTION_ENV: &str = "DEEPSEEK_LOG_RETENTION_DAYS";
const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
pub struct TuiLogGuard {
#[cfg(unix)]
saved_stderr_fd: Option<libc::c_int>,
#[cfg(windows)]
saved_stderr_handle: Option<windows::Win32::Foundation::HANDLE>,
#[cfg(windows)]
redirected_stderr_handle: Option<windows::Win32::Foundation::HANDLE>,
_file: File,
#[allow(dead_code)]
log_path: PathBuf,
}
impl TuiLogGuard {
#[allow(dead_code)]
#[must_use]
pub fn log_path(&self) -> &std::path::Path {
&self.log_path
}
}
#[cfg(unix)]
impl Drop for TuiLogGuard {
fn drop(&mut self) {
if let Some(saved) = self.saved_stderr_fd.take() {
unsafe {
let _ = libc::dup2(saved, libc::STDERR_FILENO);
let _ = libc::close(saved);
}
}
}
}
#[cfg(windows)]
impl Drop for TuiLogGuard {
fn drop(&mut self) {
if let Some(handle) = self.saved_stderr_handle.take() {
unsafe {
let _ = windows::Win32::System::Console::SetStdHandle(
windows::Win32::System::Console::STD_ERROR_HANDLE,
handle,
);
}
}
if let Some(dup) = self.redirected_stderr_handle.take() {
unsafe {
let _ = windows::Win32::Foundation::CloseHandle(dup);
}
}
}
}
#[cfg(not(any(unix, windows)))]
impl Drop for TuiLogGuard {
fn drop(&mut self) {}
}
pub fn init() -> Result<TuiLogGuard> {
let log_dir = log_directory().context("could not resolve TUI log directory")?;
fs::create_dir_all(&log_dir)
.with_context(|| format!("failed to create {}", log_dir.display()))?;
let _ = prune_old_logs(&log_dir, log_retention_days());
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
let log_path = log_dir.join(log_file_name(&date, std::process::id()));
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.with_context(|| format!("failed to open {}", log_path.display()))?;
let subscriber_file = file
.try_clone()
.context("failed to clone log file handle for subscriber")?;
let env_filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("info"))
.unwrap_or_else(|_| EnvFilter::new("info"));
let subscriber = tracing_subscriber::registry().with(env_filter).with(
fmt::layer()
.with_writer(move || {
subscriber_file
.try_clone()
.expect("clone log file handle for tracing writer")
})
.with_ansi(false)
.with_target(true)
.with_thread_ids(false),
);
let _ = tracing::subscriber::set_global_default(subscriber);
#[cfg(unix)]
let saved_stderr_fd = redirect_stderr_to(&file).ok();
#[cfg(windows)]
let (saved_stderr_handle, redirected_stderr_handle) = match redirect_stderr_to(&file) {
Ok((saved, dup)) => (Some(saved), Some(dup)),
Err(_) => (None, None),
};
Ok(TuiLogGuard {
#[cfg(unix)]
saved_stderr_fd,
#[cfg(windows)]
saved_stderr_handle,
#[cfg(windows)]
redirected_stderr_handle,
_file: file,
log_path,
})
}
pub(crate) fn log_directory() -> Option<PathBuf> {
let resolve = |base: PathBuf| -> Option<PathBuf> {
let primary = base.join(".codewhale").join("logs");
if primary.exists() {
return Some(primary);
}
let legacy = base.join(".deepseek").join("logs");
if legacy.exists() {
return Some(legacy);
}
Some(primary)
};
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from)
&& !home.as_os_str().is_empty()
{
return resolve(home);
}
if let Some(userprofile) = std::env::var_os("USERPROFILE").map(PathBuf::from)
&& !userprofile.as_os_str().is_empty()
{
return resolve(userprofile);
}
dirs::home_dir().and_then(resolve)
}
fn log_file_name(date: &str, pid: u32) -> String {
format!("tui-{date}-{pid}.log")
}
fn log_retention_days() -> u64 {
std::env::var(LOG_RETENTION_ENV)
.ok()
.and_then(|raw| raw.trim().parse::<u64>().ok())
.filter(|days| *days > 0)
.unwrap_or(DEFAULT_LOG_RETENTION_DAYS)
}
fn prune_old_logs(log_dir: &Path, retention_days: u64) -> std::io::Result<usize> {
let retention = Duration::from_secs(retention_days.saturating_mul(SECONDS_PER_DAY));
let cutoff = SystemTime::now()
.checked_sub(retention)
.unwrap_or(SystemTime::UNIX_EPOCH);
let mut removed = 0usize;
for entry in fs::read_dir(log_dir)? {
let entry = entry?;
if !is_tui_log_file_name(&entry.file_name()) {
continue;
}
let metadata = match entry.metadata() {
Ok(metadata) if metadata.is_file() => metadata,
_ => continue,
};
let modified = match metadata.modified() {
Ok(modified) => modified,
Err(_) => continue,
};
if modified < cutoff && fs::remove_file(entry.path()).is_ok() {
removed += 1;
}
}
Ok(removed)
}
fn is_tui_log_file_name(file_name: &std::ffi::OsStr) -> bool {
file_name
.to_str()
.is_some_and(|name| name.starts_with("tui-") && name.ends_with(".log"))
}
#[cfg(unix)]
fn redirect_stderr_to(file: &File) -> Result<libc::c_int> {
use std::os::fd::AsRawFd;
let target = file.as_raw_fd();
unsafe {
let saved = libc::dup(libc::STDERR_FILENO);
if saved < 0 {
return Err(
anyhow::Error::from(std::io::Error::last_os_error()).context("dup(STDERR_FILENO)")
);
}
if libc::dup2(target, libc::STDERR_FILENO) < 0 {
let err = std::io::Error::last_os_error();
let _ = libc::close(saved);
return Err(anyhow::Error::from(err).context("dup2(log_file, STDERR_FILENO)"));
}
Ok(saved)
}
}
#[cfg(windows)]
fn redirect_stderr_to(
file: &File,
) -> Result<(
windows::Win32::Foundation::HANDLE,
windows::Win32::Foundation::HANDLE,
)> {
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::{CloseHandle, DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE};
use windows::Win32::System::Console::{GetStdHandle, STD_ERROR_HANDLE, SetStdHandle};
use windows::Win32::System::Threading::GetCurrentProcess;
let saved =
unsafe { GetStdHandle(STD_ERROR_HANDLE) }.context("GetStdHandle(STD_ERROR_HANDLE)")?;
if saved.is_invalid() {
return Err(anyhow::anyhow!("GetStdHandle(STD_ERROR_HANDLE) failed"));
}
let raw = HANDLE(file.as_raw_handle());
let process = unsafe { GetCurrentProcess() };
let mut dup = HANDLE::default();
unsafe {
DuplicateHandle(
process,
raw,
process,
&mut dup,
0,
false,
DUPLICATE_SAME_ACCESS,
)
.context("DuplicateHandle for stderr redirect")?;
}
unsafe {
if let Err(e) = SetStdHandle(STD_ERROR_HANDLE, dup) {
let _ = CloseHandle(dup);
return Err(anyhow::anyhow!(
"SetStdHandle(STD_ERROR_HANDLE) failed: {e}"
));
}
}
Ok((saved, dup))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::FileTimes;
fn set_modified(path: &Path, modified: SystemTime) {
let file = OpenOptions::new().write(true).open(path).unwrap();
file.set_times(FileTimes::new().set_modified(modified))
.unwrap();
}
#[test]
fn log_directory_prefers_home() {
let _lock = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().unwrap();
let prev_home = std::env::var_os("HOME");
let prev_userprofile = std::env::var_os("USERPROFILE");
unsafe {
std::env::set_var("HOME", tmp.path());
std::env::set_var("USERPROFILE", "");
}
let resolved = log_directory().expect("log_directory should resolve");
assert_eq!(resolved, tmp.path().join(".codewhale").join("logs"));
unsafe {
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match prev_userprofile {
Some(v) => std::env::set_var("USERPROFILE", v),
None => std::env::remove_var("USERPROFILE"),
}
}
}
#[test]
fn log_directory_uses_existing_legacy_deepseek_logs() {
let _lock = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().unwrap();
let legacy = tmp.path().join(".deepseek").join("logs");
fs::create_dir_all(&legacy).unwrap();
let prev_home = std::env::var_os("HOME");
let prev_userprofile = std::env::var_os("USERPROFILE");
unsafe {
std::env::set_var("HOME", tmp.path());
std::env::set_var("USERPROFILE", "");
}
let resolved = log_directory().expect("log_directory should resolve");
assert_eq!(resolved, legacy);
unsafe {
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match prev_userprofile {
Some(v) => std::env::set_var("USERPROFILE", v),
None => std::env::remove_var("USERPROFILE"),
}
}
}
#[test]
fn log_file_name_includes_pid() {
assert_eq!(
log_file_name("2026-05-18", 12345),
"tui-2026-05-18-12345.log"
);
}
#[test]
fn log_retention_days_uses_positive_env_override() {
let _lock = crate::test_support::lock_test_env();
let previous = std::env::var_os(LOG_RETENTION_ENV);
unsafe {
std::env::set_var(LOG_RETENTION_ENV, "14");
}
assert_eq!(log_retention_days(), 14);
unsafe {
std::env::set_var(LOG_RETENTION_ENV, "0");
}
assert_eq!(log_retention_days(), DEFAULT_LOG_RETENTION_DAYS);
unsafe {
match previous {
Some(value) => std::env::set_var(LOG_RETENTION_ENV, value),
None => std::env::remove_var(LOG_RETENTION_ENV),
}
}
}
#[test]
fn prune_old_logs_drops_only_stale_tui_logs() {
let tmp = tempfile::TempDir::new().unwrap();
let fresh = tmp.path().join("tui-2026-05-18-1.log");
let stale = tmp.path().join("tui-2026-05-01-2.log");
let legacy_stale = tmp.path().join("tui-2026-05-01.log");
let unrelated = tmp.path().join("agent-2026-05-01.log");
fs::write(&fresh, "fresh").unwrap();
fs::write(&stale, "stale").unwrap();
fs::write(&legacy_stale, "legacy").unwrap();
fs::write(&unrelated, "other").unwrap();
let now = SystemTime::now();
let old = now - Duration::from_secs(10 * SECONDS_PER_DAY);
set_modified(&stale, old);
set_modified(&legacy_stale, old);
set_modified(&unrelated, old);
let removed = prune_old_logs(tmp.path(), 7).unwrap();
assert_eq!(removed, 2);
assert!(fresh.exists());
assert!(!stale.exists());
assert!(!legacy_stale.exists());
assert!(unrelated.exists());
}
}