diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..18873f3 --- /dev/null +++ b/src/log.rs @@ -0,0 +1,155 @@ +use std::fs::{self, File}; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Local}; +use tracing::Level; +use tracing_subscriber::filter::{EnvFilter, filter_fn}; +use tracing_subscriber::prelude::*; + +use crate::error::{Hint, render}; + +/// Number of run logs kept on disk, including the current one. +const KEEP: usize = 10; + +static START: OnceLock = OnceLock::new(); + +/// Time since logging was set up, i.e. roughly since the process started. +pub fn since_start() -> Duration { + START.get_or_init(Instant::now).elapsed() +} + +/// `$XDG_STATE_HOME/valw/logs`, falling back to `~/.local/state/valw/logs`. +pub fn default_dir() -> PathBuf { + std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| crate::config::home().join(".local/state")) + .join("valw/logs") +} + +/// Sets up tracing: this run's log file at `VALW_LOG` level (default info), +/// plus warnings and errors on stderr. Returns the log file path. +/// +/// If the log file can't be created, logging falls back to stderr only and +/// the returned path is `None`; a broken log dir must not block a screenshot. +pub fn init(dir: &Path, now: DateTime) -> Option { + START.get_or_init(Instant::now); + let file = open_run_log(dir, now); + + // Errors reach stderr through error::render, so only warnings go here. + let stderr = tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_ansi(std::io::stderr().is_terminal()) + .without_time() + .with_target(false) + .with_filter(filter_fn(|m| *m.level() == Level::WARN)); + + let (file_layer, path, failure) = match file { + Ok((file, path)) => { + let filter = + EnvFilter::try_from_env("VALW_LOG").unwrap_or_else(|_| EnvFilter::new("info")); + let layer = tracing_subscriber::fmt::layer() + .with_writer(Mutex::new(file)) + .with_ansi(false) + .with_filter(filter); + (Some(layer), Some(path), None) + } + Err(e) => (None, None, Some(e)), + }; + + tracing_subscriber::registry() + .with(stderr) + .with(file_layer) + .init(); + + if let Some(e) = failure { + tracing::warn!("logging to stderr only: {e:#}"); + } + path +} + +fn open_run_log(dir: &Path, now: DateTime) -> Result<(File, PathBuf)> { + fs::create_dir_all(dir).with_context(|| format!("could not create {}", dir.display()))?; + let path = dir.join(log_name(now)); + let file = + File::create(&path).with_context(|| format!("could not create {}", path.display()))?; + prune(dir, KEEP)?; + Ok((file, path)) +} + +/// Log names sort chronologically, so pruning can compare names. +fn log_name(now: DateTime) -> String { + now.format("%Y-%m-%dT%H-%M-%S%.3f.log").to_string() +} + +/// Deletes all but the `keep` newest `.log` files in `dir`. +fn prune(dir: &Path, keep: usize) -> Result<()> { + let mut logs: Vec = fs::read_dir(dir)? + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| x == "log")) + .collect(); + logs.sort(); + let excess = logs.len().saturating_sub(keep); + for old in &logs[..excess] { + fs::remove_file(old)?; + } + Ok(()) +} + +/// Sends panics to the log and prints them in the usual error format. +pub fn install_panic_hook(log: Option) { + std::panic::set_hook(Box::new(move |info| { + let backtrace = std::backtrace::Backtrace::force_capture(); + tracing::error!("panic: {info}\n{backtrace}"); + let err = anyhow::anyhow!("{info}").context(Hint( + "this is a bug in valw; please report it with the log".into(), + )); + eprint!("{}", render(&err, log.as_deref())); + })); +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn log_names_sort_by_time() { + let a = Local.with_ymd_and_hms(2026, 9, 29, 9, 5, 1).unwrap(); + let b = Local.with_ymd_and_hms(2026, 9, 29, 19, 0, 0).unwrap(); + assert_eq!(log_name(a), "2026-09-29T09-05-01.000.log"); + assert!(log_name(a) < log_name(b)); + } + + #[test] + fn prune_keeps_newest() { + let dir = tempfile::tempdir().unwrap(); + for i in 0..12 { + File::create(dir.path().join(format!("2026-09-29T10-00-{i:02}.000.log"))).unwrap(); + } + File::create(dir.path().join("notes.txt")).unwrap(); + + prune(dir.path(), 10).unwrap(); + + let mut left: Vec = fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .collect(); + left.sort(); + assert_eq!(left.len(), 11); + assert_eq!(left[0], "2026-09-29T10-00-02.000.log"); + assert!(left.contains(&"notes.txt".to_string())); + } + + #[test] + fn open_run_log_fails_on_unwritable_dir() { + let dir = tempfile::tempdir().unwrap(); + let blocker = dir.path().join("file"); + File::create(&blocker).unwrap(); + // A regular file where the log dir should be. + assert!(open_run_log(&blocker, Local::now()).is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 6427c34..d3b6d90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod config; mod error; +mod log; use clap::Parser;