1use crate::config::{LogLevel, LoggingConfig};
16use crate::utils::now_rfc3339_utc_nanos;
17use chrono::{Duration, NaiveDate, Utc};
18use log::{Level, LevelFilter, Log, Metadata, Record};
19use std::fs::{self, File, OpenOptions};
20use std::io::Write;
21use std::path::{Path, PathBuf};
22use std::sync::atomic::{AtomicUsize, Ordering};
23use std::sync::{Mutex, OnceLock};
24
25const LOG_DIR_NAME: &str = "logs";
27const LOG_FILE_PREFIX: &str = "vct-";
28const LOG_FILE_SUFFIX: &str = ".log";
29
30static LOGGER: OnceLock<FileLogger> = OnceLock::new();
32
33struct OpenLog {
39 date: String,
40 file: File,
41}
42
43struct FileLogger {
50 level: AtomicUsize,
53 dir: Option<PathBuf>,
56 open: Mutex<Option<OpenLog>>,
59}
60
61impl FileLogger {
62 fn new(dir: Option<PathBuf>, level: LevelFilter) -> Self {
64 Self {
65 level: AtomicUsize::new(level as usize),
66 dir,
67 open: Mutex::new(None),
68 }
69 }
70
71 fn append(&self, line: &str) {
78 let Some(dir) = self.dir.as_deref() else {
79 return;
80 };
81 let today = utc_date();
82 let mut guard = self.open.lock().unwrap_or_else(|p| p.into_inner());
84 if guard.as_ref().is_none_or(|o| o.date != today) {
86 if fs::create_dir_all(dir).is_err() {
87 return;
88 }
89 let path = dir.join(format!("{LOG_FILE_PREFIX}{today}{LOG_FILE_SUFFIX}"));
90 match OpenOptions::new().create(true).append(true).open(&path) {
91 Ok(file) => *guard = Some(OpenLog { date: today, file }),
92 Err(_) => return,
93 }
94 }
95 if let Some(open) = guard.as_mut() {
96 let _ = open.file.write_all(line.as_bytes());
97 }
98 }
99}
100
101impl Log for FileLogger {
102 fn enabled(&self, metadata: &Metadata) -> bool {
103 metadata.level() as usize <= self.level.load(Ordering::Relaxed)
104 }
105
106 fn log(&self, record: &Record) {
107 if !self.enabled(record.metadata()) {
108 return;
109 }
110 let line = format_line(
111 &now_rfc3339_utc_nanos(),
112 record.level(),
113 record.target(),
114 &record.args().to_string(),
115 );
116 self.append(&line);
117 }
118
119 fn flush(&self) {
120 if let Ok(mut guard) = self.open.lock()
121 && let Some(open) = guard.as_mut()
122 {
123 let _ = open.file.flush();
124 }
125 }
126}
127
128fn utc_date() -> String {
133 Utc::now().date_naive().format("%Y-%m-%d").to_string()
134}
135
136fn format_line(now: &str, level: Level, target: &str, msg: &str) -> String {
141 format!("{now} {:<5} {} {msg}\n", level, normalize_target(target))
142}
143
144fn normalize_target(target: &str) -> String {
147 match target.strip_prefix("vct_core") {
148 Some(rest) => format!("vct{rest}"),
149 None => target.to_string(),
150 }
151}
152
153fn to_level_filter(level: LogLevel) -> LevelFilter {
155 match level {
156 LogLevel::Off => LevelFilter::Off,
157 LogLevel::Error => LevelFilter::Error,
158 LogLevel::Warn => LevelFilter::Warn,
159 LogLevel::Info => LevelFilter::Info,
160 LogLevel::Debug => LevelFilter::Debug,
161 LogLevel::Trace => LevelFilter::Trace,
162 }
163}
164
165pub fn init() {
173 let dir = home::home_dir().map(|home| home.join(".vct").join(LOG_DIR_NAME));
174 let logger = LOGGER.get_or_init(|| FileLogger::new(dir, LevelFilter::Warn));
175 if log::set_logger(logger).is_ok() {
177 log::set_max_level(LevelFilter::Warn);
178 }
179}
180
181pub fn apply(cfg: &LoggingConfig) {
184 let filter = to_level_filter(cfg.level);
185 log::set_max_level(filter);
186 if let Some(logger) = LOGGER.get() {
187 logger.level.store(filter as usize, Ordering::Relaxed);
188 }
189 prune_old_logs(cfg.retention_days);
190}
191
192fn prune_old_logs(retention_days: u32) {
194 if retention_days == 0 {
195 return;
196 }
197 let Some(dir) = LOGGER.get().and_then(|l| l.dir.as_deref()) else {
198 return;
199 };
200 let cutoff = Utc::now().date_naive() - Duration::days(retention_days as i64);
201 prune_before(dir, cutoff);
202}
203
204fn prune_before(dir: &Path, cutoff: NaiveDate) {
208 let Ok(entries) = fs::read_dir(dir) else {
209 return;
210 };
211 for entry in entries.flatten() {
212 let path = entry.path();
213 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
214 continue;
215 };
216 let Some(date_str) = name
217 .strip_prefix(LOG_FILE_PREFIX)
218 .and_then(|s| s.strip_suffix(LOG_FILE_SUFFIX))
219 else {
220 continue;
221 };
222 if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
223 && date < cutoff
224 {
225 let _ = fs::remove_file(&path);
226 }
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use tempfile::TempDir;
234
235 #[test]
236 fn format_line_shapes_the_record() {
237 let line = format_line(
238 "2026-07-12T10:30:15.123456789Z",
239 Level::Warn,
240 "vct_core::quota::claude",
241 "quota fetch failed: HTTP 403",
242 );
243 assert_eq!(
244 line,
245 "2026-07-12T10:30:15.123456789Z WARN vct::quota::claude quota fetch failed: HTTP 403\n"
246 );
247 }
248
249 #[test]
250 fn normalize_target_shortens_only_our_crate() {
251 assert_eq!(normalize_target("vct_core"), "vct");
252 assert_eq!(normalize_target("vct_core::pricing"), "vct::pricing");
253 assert_eq!(normalize_target("hyper_util::client"), "hyper_util::client");
254 }
255
256 #[test]
257 fn level_filter_ordering_matches_log_crate() {
258 let warn = FileLogger::new(None, LevelFilter::Warn);
260 assert!(warn.enabled(&Metadata::builder().level(Level::Error).build()));
261 assert!(warn.enabled(&Metadata::builder().level(Level::Warn).build()));
262 assert!(!warn.enabled(&Metadata::builder().level(Level::Info).build()));
263
264 let off = FileLogger::new(None, LevelFilter::Off);
265 assert!(!off.enabled(&Metadata::builder().level(Level::Error).build()));
266 }
267
268 #[test]
269 fn append_creates_dir_lazily_and_writes() {
270 let tmp = TempDir::new().unwrap();
271 let dir = tmp.path().join("logs");
272 let logger = FileLogger::new(Some(dir.clone()), LevelFilter::Warn);
273
274 assert!(!dir.exists());
276
277 logger.append("line one\n");
278 logger.append("line two\n");
279
280 let file = dir.join(format!("vct-{}.log", utc_date()));
281 let contents = fs::read_to_string(&file).unwrap();
282 assert_eq!(contents, "line one\nline two\n");
283 }
284
285 #[test]
286 fn log_routes_through_facade_gated_and_formatted() {
287 let tmp = TempDir::new().unwrap();
288 let dir = tmp.path().join("logs");
289 let logger = FileLogger::new(Some(dir.clone()), LevelFilter::Warn);
290
291 logger.log(
293 &Record::builder()
294 .level(Level::Info)
295 .target("vct_core::x")
296 .args(format_args!("skip me"))
297 .build(),
298 );
299 assert!(
300 !dir.exists(),
301 "info is below warn, so no file/dir is created"
302 );
303
304 logger.log(
306 &Record::builder()
307 .level(Level::Warn)
308 .target("vct_core::quota")
309 .args(format_args!("boom"))
310 .build(),
311 );
312 let file = dir.join(format!("vct-{}.log", utc_date()));
313 let contents = fs::read_to_string(&file).unwrap();
314 assert!(
315 contents.contains(" WARN vct::quota boom\n"),
316 "unexpected line: {contents}"
317 );
318 assert!(
319 !contents.contains("skip me"),
320 "gated record must not appear"
321 );
322 }
323
324 #[test]
325 fn prune_before_removes_only_older_dated_files() {
326 let tmp = TempDir::new().unwrap();
327 let dir = tmp.path();
328 for name in ["vct-2026-07-01.log", "vct-2026-07-10.log", "unrelated.txt"] {
329 fs::write(dir.join(name), "x").unwrap();
330 }
331 let cutoff = NaiveDate::from_ymd_opt(2026, 7, 5).unwrap();
332 prune_before(dir, cutoff);
333
334 assert!(!dir.join("vct-2026-07-01.log").exists(), "old file pruned");
335 assert!(dir.join("vct-2026-07-10.log").exists(), "recent file kept");
336 assert!(dir.join("unrelated.txt").exists(), "non-log file untouched");
337 }
338}