use std::fs;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::{Duration, SystemTime};
const CLEANUP_AGE: Duration = Duration::from_secs(24 * 60 * 60);
static LOG_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn log_dir() -> &'static PathBuf {
LOG_DIR.get_or_init(|| {
let dir = get_xdg_log_dir().unwrap_or_else(|| std::env::temp_dir().join("fresh-logs"));
if let Err(e) = fs::create_dir_all(&dir) {
tracing::warn!("Failed to create log directory {:?}: {}", dir, e);
return std::env::temp_dir().join("fresh-logs");
}
dir
})
}
fn get_xdg_log_dir() -> Option<PathBuf> {
if let Ok(state_home) = std::env::var("XDG_STATE_HOME") {
let path = PathBuf::from(state_home);
if path.is_absolute() {
return Some(path.join("fresh").join("logs"));
}
}
if let Some(home) = home_dir() {
return Some(home.join(".local").join("state").join("fresh").join("logs"));
}
None
}
fn home_dir() -> Option<PathBuf> {
if let Ok(home) = std::env::var("HOME") {
return Some(PathBuf::from(home));
}
#[cfg(windows)]
if let Ok(profile) = std::env::var("USERPROFILE") {
return Some(PathBuf::from(profile));
}
None
}
pub fn main_log_path() -> PathBuf {
log_dir().join(format!("fresh-{}.log", std::process::id()))
}
pub fn warnings_log_path() -> PathBuf {
log_dir().join(format!("warnings-{}.log", std::process::id()))
}
pub fn status_log_path() -> PathBuf {
log_dir().join(format!("status-{}.log", std::process::id()))
}
pub fn lsp_log_dir() -> PathBuf {
let dir = log_dir().join("lsp");
if let Err(e) = fs::create_dir_all(&dir) {
tracing::warn!("Failed to create LSP log directory {:?}: {}", dir, e);
}
dir
}
pub fn lsp_log_path(language: &str) -> PathBuf {
lsp_log_dir().join(format!("{}-{}.log", language, std::process::id()))
}
pub fn cleanup_stale_logs() {
cleanup_legacy_tmp_logs();
cleanup_stale_xdg_logs();
}
fn cleanup_legacy_tmp_logs() {
let tmp_dir = std::env::temp_dir();
let cleanup_patterns = [
"fresh-warnings-",
"fresh-lsp-",
"rust-analyzer-",
"fresh-stdin-",
"fresh.log", ];
if let Ok(entries) = fs::read_dir(&tmp_dir) {
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
let should_cleanup = cleanup_patterns
.iter()
.any(|pattern| name.starts_with(pattern));
if should_cleanup {
if entry.file_type().map(|t| t.is_file()).unwrap_or(false)
&& is_file_older_than(&entry.path(), CLEANUP_AGE)
{
if let Err(e) = fs::remove_file(entry.path()) {
tracing::debug!("Failed to clean up legacy log {:?}: {}", entry.path(), e);
} else {
tracing::info!("Cleaned up legacy log file: {:?}", entry.path());
}
}
}
}
}
}
fn cleanup_stale_xdg_logs() {
let current_pid = std::process::id();
cleanup_stale_logs_in_dir(log_dir(), current_pid);
let lsp_dir = log_dir().join("lsp");
if lsp_dir.exists() {
cleanup_stale_logs_in_dir(&lsp_dir, current_pid);
}
}
fn cleanup_stale_logs_in_dir(dir: &std::path::Path, current_pid: u32) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if !name.ends_with(".log") {
continue;
}
if let Some(pid) = extract_pid_from_filename(&name) {
if pid == current_pid {
continue;
}
if !is_process_running(pid)
&& is_file_older_than(&entry.path(), CLEANUP_AGE)
&& entry.file_type().map(|t| t.is_file()).unwrap_or(false)
{
if let Err(e) = fs::remove_file(entry.path()) {
tracing::debug!("Failed to clean up stale log {:?}: {}", entry.path(), e);
} else {
tracing::debug!("Cleaned up stale log file: {:?}", entry.path());
}
}
}
}
}
fn is_file_older_than(path: &std::path::Path, age: Duration) -> bool {
let Ok(metadata) = fs::metadata(path) else {
return false;
};
let Ok(modified) = metadata.modified() else {
return false;
};
SystemTime::now()
.duration_since(modified)
.map(|elapsed| elapsed > age)
.unwrap_or(false)
}
fn extract_pid_from_filename(name: &str) -> Option<u32> {
let without_ext = name.strip_suffix(".log")?;
let last_hyphen = without_ext.rfind('-')?;
let pid_str = &without_ext[last_hyphen + 1..];
pid_str.parse().ok()
}
fn is_process_running(pid: u32) -> bool {
#[cfg(unix)]
{
unsafe {
libc::kill(pid as libc::pid_t, 0) == 0
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
}
#[cfg(windows)]
{
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if !handle.is_null() {
CloseHandle(handle);
true
} else {
false
}
}
}
#[cfg(not(any(unix, windows)))]
{
true
}
}
pub fn print_all_paths() {
use std::io::Write;
let stdout = std::io::stdout();
let mut handle = stdout.lock();
let config_dir = dirs::config_dir()
.map(|d| d.join("fresh"))
.unwrap_or_else(|| PathBuf::from("<unavailable>"));
let data_dir = dirs::data_dir()
.map(|d| d.join("fresh"))
.unwrap_or_else(|| PathBuf::from("<unavailable>"));
let logs_dir = log_dir().clone();
writeln!(handle, "Fresh directories:").ok();
writeln!(handle).ok();
writeln!(handle, "Config: {}", config_dir.display()).ok();
writeln!(
handle,
" config.json: {}",
config_dir.join("config.json").display()
)
.ok();
writeln!(
handle,
" themes/: {}",
config_dir.join("themes").display()
)
.ok();
writeln!(
handle,
" grammars/: {}",
config_dir.join("grammars").display()
)
.ok();
writeln!(
handle,
" plugins/: {}",
config_dir.join("plugins").display()
)
.ok();
writeln!(handle).ok();
writeln!(handle, "Data: {}", data_dir.display()).ok();
writeln!(
handle,
" sessions/: {}",
data_dir.join("sessions").display()
)
.ok();
writeln!(
handle,
" recovery/: {}",
data_dir.join("recovery").display()
)
.ok();
writeln!(
handle,
" terminals/: {}",
data_dir.join("terminals").display()
)
.ok();
writeln!(handle).ok();
writeln!(handle, "Logs: {}", logs_dir.display()).ok();
writeln!(handle, " lsp/: {}", logs_dir.join("lsp").display()).ok();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_log_dir_is_absolute() {
let dir = log_dir();
assert!(dir.is_absolute(), "Log directory should be absolute");
}
#[test]
fn test_main_log_path_contains_pid() {
let path = main_log_path();
let name = path.file_name().unwrap().to_string_lossy();
assert!(name.starts_with("fresh-"), "Should start with fresh-");
assert!(name.ends_with(".log"), "Should end with .log");
assert!(
name.contains(&std::process::id().to_string()),
"Should contain PID"
);
}
#[test]
fn test_warnings_log_path_contains_pid() {
let path = warnings_log_path();
let name = path.file_name().unwrap().to_string_lossy();
assert!(name.starts_with("warnings-"), "Should start with warnings-");
assert!(name.ends_with(".log"), "Should end with .log");
}
#[test]
fn test_lsp_log_path_contains_pid() {
let path = lsp_log_path("rust");
let name = path.file_name().unwrap().to_string_lossy();
assert!(name.starts_with("rust-"), "Should start with language-");
assert!(name.ends_with(".log"), "Should end with .log");
assert!(
path.to_string_lossy().contains("lsp"),
"Should be in lsp dir"
);
}
#[test]
fn test_extract_pid_from_filename() {
assert_eq!(extract_pid_from_filename("fresh-12345.log"), Some(12345));
assert_eq!(extract_pid_from_filename("rust-99999.log"), Some(99999));
assert_eq!(extract_pid_from_filename("warnings-1.log"), Some(1));
assert_eq!(extract_pid_from_filename("no-pid.txt"), None);
assert_eq!(extract_pid_from_filename("invalid"), None);
}
#[test]
fn test_current_process_is_running() {
assert!(is_process_running(std::process::id()));
}
#[test]
fn test_nonexistent_process_not_running() {
let _ = is_process_running(99999999);
}
}