use crate::config::{LogLevel, LoggingConfig};
use crate::utils::now_rfc3339_utc_nanos;
use chrono::{Duration, NaiveDate, Utc};
use log::{Level, LevelFilter, Log, Metadata, Record};
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
const LOG_DIR_NAME: &str = "logs";
const LOG_FILE_PREFIX: &str = "vct-";
const LOG_FILE_SUFFIX: &str = ".log";
static LOGGER: OnceLock<FileLogger> = OnceLock::new();
struct OpenLog {
date: String,
file: File,
}
struct FileLogger {
level: AtomicUsize,
dir: Option<PathBuf>,
open: Mutex<Option<OpenLog>>,
}
impl FileLogger {
fn new(dir: Option<PathBuf>, level: LevelFilter) -> Self {
Self {
level: AtomicUsize::new(level as usize),
dir,
open: Mutex::new(None),
}
}
fn append(&self, line: &str) {
let Some(dir) = self.dir.as_deref() else {
return;
};
let today = utc_date();
let mut guard = self.open.lock().unwrap_or_else(|p| p.into_inner());
if guard.as_ref().is_none_or(|o| o.date != today) {
if fs::create_dir_all(dir).is_err() {
return;
}
let path = dir.join(format!("{LOG_FILE_PREFIX}{today}{LOG_FILE_SUFFIX}"));
match OpenOptions::new().create(true).append(true).open(&path) {
Ok(file) => *guard = Some(OpenLog { date: today, file }),
Err(_) => return,
}
}
if let Some(open) = guard.as_mut() {
let _ = open.file.write_all(line.as_bytes());
}
}
}
impl Log for FileLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() as usize <= self.level.load(Ordering::Relaxed)
}
fn log(&self, record: &Record) {
if !self.enabled(record.metadata()) {
return;
}
let line = format_line(
&now_rfc3339_utc_nanos(),
record.level(),
record.target(),
&record.args().to_string(),
);
self.append(&line);
}
fn flush(&self) {
if let Ok(mut guard) = self.open.lock()
&& let Some(open) = guard.as_mut()
{
let _ = open.file.flush();
}
}
}
fn utc_date() -> String {
Utc::now().date_naive().format("%Y-%m-%d").to_string()
}
fn format_line(now: &str, level: Level, target: &str, msg: &str) -> String {
format!("{now} {:<5} {} {msg}\n", level, normalize_target(target))
}
fn normalize_target(target: &str) -> String {
match target.strip_prefix("vct_core") {
Some(rest) => format!("vct{rest}"),
None => target.to_string(),
}
}
fn to_level_filter(level: LogLevel) -> LevelFilter {
match level {
LogLevel::Off => LevelFilter::Off,
LogLevel::Error => LevelFilter::Error,
LogLevel::Warn => LevelFilter::Warn,
LogLevel::Info => LevelFilter::Info,
LogLevel::Debug => LevelFilter::Debug,
LogLevel::Trace => LevelFilter::Trace,
}
}
pub fn init() {
let dir = home::home_dir().map(|home| home.join(".vct").join(LOG_DIR_NAME));
let logger = LOGGER.get_or_init(|| FileLogger::new(dir, LevelFilter::Warn));
if log::set_logger(logger).is_ok() {
log::set_max_level(LevelFilter::Warn);
}
}
pub fn apply(cfg: &LoggingConfig) {
let filter = to_level_filter(cfg.level);
log::set_max_level(filter);
if let Some(logger) = LOGGER.get() {
logger.level.store(filter as usize, Ordering::Relaxed);
}
prune_old_logs(cfg.retention_days);
}
fn prune_old_logs(retention_days: u32) {
if retention_days == 0 {
return;
}
let Some(dir) = LOGGER.get().and_then(|l| l.dir.as_deref()) else {
return;
};
let cutoff = Utc::now().date_naive() - Duration::days(retention_days as i64);
prune_before(dir, cutoff);
}
fn prune_before(dir: &Path, cutoff: NaiveDate) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Some(date_str) = name
.strip_prefix(LOG_FILE_PREFIX)
.and_then(|s| s.strip_suffix(LOG_FILE_SUFFIX))
else {
continue;
};
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
&& date < cutoff
{
let _ = fs::remove_file(&path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn format_line_shapes_the_record() {
let line = format_line(
"2026-07-12T10:30:15.123456789Z",
Level::Warn,
"vct_core::quota::claude",
"quota fetch failed: HTTP 403",
);
assert_eq!(
line,
"2026-07-12T10:30:15.123456789Z WARN vct::quota::claude quota fetch failed: HTTP 403\n"
);
}
#[test]
fn normalize_target_shortens_only_our_crate() {
assert_eq!(normalize_target("vct_core"), "vct");
assert_eq!(normalize_target("vct_core::pricing"), "vct::pricing");
assert_eq!(normalize_target("hyper_util::client"), "hyper_util::client");
}
#[test]
fn level_filter_ordering_matches_log_crate() {
let warn = FileLogger::new(None, LevelFilter::Warn);
assert!(warn.enabled(&Metadata::builder().level(Level::Error).build()));
assert!(warn.enabled(&Metadata::builder().level(Level::Warn).build()));
assert!(!warn.enabled(&Metadata::builder().level(Level::Info).build()));
let off = FileLogger::new(None, LevelFilter::Off);
assert!(!off.enabled(&Metadata::builder().level(Level::Error).build()));
}
#[test]
fn append_creates_dir_lazily_and_writes() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("logs");
let logger = FileLogger::new(Some(dir.clone()), LevelFilter::Warn);
assert!(!dir.exists());
logger.append("line one\n");
logger.append("line two\n");
let file = dir.join(format!("vct-{}.log", utc_date()));
let contents = fs::read_to_string(&file).unwrap();
assert_eq!(contents, "line one\nline two\n");
}
#[test]
fn log_routes_through_facade_gated_and_formatted() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("logs");
let logger = FileLogger::new(Some(dir.clone()), LevelFilter::Warn);
logger.log(
&Record::builder()
.level(Level::Info)
.target("vct_core::x")
.args(format_args!("skip me"))
.build(),
);
assert!(
!dir.exists(),
"info is below warn, so no file/dir is created"
);
logger.log(
&Record::builder()
.level(Level::Warn)
.target("vct_core::quota")
.args(format_args!("boom"))
.build(),
);
let file = dir.join(format!("vct-{}.log", utc_date()));
let contents = fs::read_to_string(&file).unwrap();
assert!(
contents.contains(" WARN vct::quota boom\n"),
"unexpected line: {contents}"
);
assert!(
!contents.contains("skip me"),
"gated record must not appear"
);
}
#[test]
fn prune_before_removes_only_older_dated_files() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
for name in ["vct-2026-07-01.log", "vct-2026-07-10.log", "unrelated.txt"] {
fs::write(dir.join(name), "x").unwrap();
}
let cutoff = NaiveDate::from_ymd_opt(2026, 7, 5).unwrap();
prune_before(dir, cutoff);
assert!(!dir.join("vct-2026-07-01.log").exists(), "old file pruned");
assert!(dir.join("vct-2026-07-10.log").exists(), "recent file kept");
assert!(dir.join("unrelated.txt").exists(), "non-log file untouched");
}
}