use crate::config::Config;
use std::fs;
use std::path::{Path, PathBuf};
pub fn log_dir() -> PathBuf {
isolate_from_tests(Config::minutes_dir().join("logs"))
}
pub fn log_path() -> PathBuf {
log_dir().join("minutes.log")
}
pub(crate) fn isolate_from_tests(dir: PathBuf) -> PathBuf {
if !crate::test_support::running_under_cargo_test() {
return dir;
}
redirect_unless_temporary(dir, &std::env::temp_dir(), std::process::id())
}
fn redirect_unless_temporary(dir: PathBuf, temp_root: &Path, pid: u32) -> PathBuf {
if dir.starts_with(temp_root) {
return dir;
}
temp_root.join("minutes-test-logs").join(pid.to_string())
}
pub fn ensure_log_dir() -> std::io::Result<()> {
fs::create_dir_all(log_dir())
}
pub fn rotate_logs() -> std::io::Result<()> {
let log_dir = log_dir();
if !log_dir.exists() {
return Ok(());
}
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
let current_log = log_dir.join("minutes.log");
if current_log.exists() {
if let Ok(metadata) = current_log.metadata() {
if let Ok(modified) = metadata.modified() {
let modified_date = chrono::DateTime::<chrono::Local>::from(modified)
.format("%Y-%m-%d")
.to_string();
if modified_date != today {
let rotated = log_dir.join(format!("minutes.{}.log", modified_date));
fs::rename(¤t_log, &rotated)?;
}
}
}
}
let cutoff = chrono::Local::now() - chrono::Duration::days(7);
let cutoff_str = cutoff.format("%Y-%m-%d").to_string();
for entry in fs::read_dir(&log_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if let Some(date) = name
.strip_prefix("minutes.")
.and_then(|s| s.strip_suffix(".log"))
{
if date < cutoff_str.as_str() {
fs::remove_file(entry.path())?;
tracing::debug!(file = %name, "removed old log file");
}
}
}
Ok(())
}
pub fn append_log(entry: &serde_json::Value) -> std::io::Result<()> {
ensure_log_dir()?;
let path = log_path();
let line = serde_json::to_string(entry)? + "\n";
use std::io::Write;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
file.write_all(line.as_bytes())?;
Ok(())
}
pub fn log_step(step: &str, file: &str, duration_ms: u64, extra: serde_json::Value) {
let entry = serde_json::json!({
"ts": chrono::Local::now().to_rfc3339(),
"level": "info",
"step": step,
"file": file,
"duration_ms": duration_ms,
"extra": extra,
});
if let Err(e) = append_log(&entry) {
tracing::warn!("failed to write to log file: {}", e);
}
}
pub fn log_error(step: &str, file: &str, error: &str) {
let entry = serde_json::json!({
"ts": chrono::Local::now().to_rfc3339(),
"level": "error",
"step": step,
"file": file,
"error": error,
});
if let Err(e) = append_log(&entry) {
tracing::warn!("failed to write error to log file: {}", e);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
struct DataDirUnset(Option<OsString>);
impl DataDirUnset {
fn new() -> Self {
let previous = std::env::var_os("MINUTES_DATA_DIR");
std::env::remove_var("MINUTES_DATA_DIR");
Self(previous)
}
}
impl Drop for DataDirUnset {
fn drop(&mut self) {
if let Some(previous) = self.0.take() {
std::env::set_var("MINUTES_DATA_DIR", previous);
}
}
}
#[test]
fn test_writes_never_reach_the_real_home_log() {
let _lock = crate::test_support::home_env_lock();
let _data_dir = DataDirUnset::new();
let temp_root = std::env::temp_dir();
let runtime_dir = Config::minutes_dir().join("logs");
let marker = format!(
"logging-isolation-{}-{}",
std::process::id(),
chrono::Local::now()
.timestamp_nanos_opt()
.unwrap_or_default()
);
append_log(&serde_json::json!({ "step": "test", "marker": marker })).unwrap();
assert!(
log_path().starts_with(&temp_root),
"test log {} must live under {}",
log_path().display(),
temp_root.display()
);
let written = fs::read_to_string(log_path()).unwrap();
assert!(written.contains(&marker));
if !runtime_dir.starts_with(&temp_root) {
assert_ne!(log_dir(), runtime_dir);
let real_log = fs::read_to_string(runtime_dir.join("minutes.log")).unwrap_or_default();
assert!(
!real_log.contains(&marker),
"a test entry reached the real log at {}",
runtime_dir.display()
);
}
}
#[test]
fn a_temporary_home_keeps_its_own_log() {
crate::test_support::with_temp_home(|home| {
let _data_dir = DataDirUnset::new();
assert_eq!(log_path(), home.join(".minutes/logs/minutes.log"));
});
}
#[test]
fn redirect_keeps_temporary_dirs_and_replaces_everything_else() {
let temp = Path::new("/var/folders/xy/T");
let kept = temp.join(".tmpAbC/.minutes/logs");
assert_eq!(redirect_unless_temporary(kept.clone(), temp, 42), kept);
assert_eq!(
redirect_unless_temporary(PathBuf::from("/Users/me/.minutes/logs"), temp, 42),
temp.join("minutes-test-logs/42")
);
assert_eq!(
redirect_unless_temporary(PathBuf::from("/var/folders/xy/Tother/logs"), temp, 7),
temp.join("minutes-test-logs/7")
);
}
#[test]
fn append_log_writes_json_line_to_file() {
let dir = tempfile::TempDir::new().unwrap();
let log_file = dir.path().join("test.log");
let entry = serde_json::json!({
"ts": "2026-03-17T08:00:00",
"level": "info",
"step": "test",
"message": "unit test entry"
});
let line = serde_json::to_string(&entry).unwrap() + "\n";
std::fs::write(&log_file, &line).unwrap();
let content = std::fs::read_to_string(&log_file).unwrap();
assert!(content.contains("\"step\":\"test\""));
assert!(content.ends_with('\n'));
let parsed: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
assert_eq!(parsed["step"], "test");
}
#[test]
fn log_step_formats_correctly() {
let entry = serde_json::json!({
"ts": chrono::Local::now().to_rfc3339(),
"level": "info",
"step": "transcribe",
"file": "test.wav",
"duration_ms": 4200,
"extra": { "words": 142 },
});
let line = serde_json::to_string(&entry).unwrap();
assert!(line.contains("\"step\":\"transcribe\""));
assert!(line.contains("\"duration_ms\":4200"));
}
}