Skip to main content

vct_core/logging/
mod.rs

1//! File-based diagnostic logging (`~/.vct/logs/vct-YYYY-MM-DD.log`).
2//!
3//! Keeps the standard `log` facade — every existing `log::warn!` / `error!` in
4//! the crate is unchanged — but swaps the output backend for a small file
5//! logger. Records land in a plain-text daily file under `~/.vct/logs`, **never**
6//! on stdout/stderr, so the interactive TUI is never corrupted. The file is
7//! created lazily on the first record, so a command that logs nothing (e.g. a
8//! successful `vct version`) leaves `~/.vct` untouched.
9//!
10//! [`init`] installs the logger (default level `warn`). Terminal-restore-on-panic
11//! is a presentation concern, so the CLI binary installs that hook separately.
12//! [`apply`] later reconfigures the level from `[logging]` in the user config and
13//! prunes stale files.
14
15use 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
25/// Subdirectory of `~/.vct` holding the daily log files.
26const LOG_DIR_NAME: &str = "logs";
27const LOG_FILE_PREFIX: &str = "vct-";
28const LOG_FILE_SUFFIX: &str = ".log";
29
30/// The process-wide file logger, installed once by [`init`].
31static LOGGER: OnceLock<FileLogger> = OnceLock::new();
32
33/// An open daily log file plus the UTC date it was opened for.
34///
35/// Tracking the date lets a process that crosses UTC midnight roll over to the
36/// next day's file instead of appending a whole multi-day session into the file
37/// named for the day it started.
38struct OpenLog {
39    date: String,
40    file: File,
41}
42
43/// A thread-safe file logger implementing [`log::Log`].
44///
45/// The open file lives behind a `Mutex` so the main thread and the (up to four)
46/// background quota workers can share it. Each record is written with a single
47/// `write_all` and no user-space buffering, so nothing is lost on a
48/// `panic = "abort"` process abort.
49struct FileLogger {
50    /// Current max level as `LevelFilter as usize`, kept in sync with
51    /// `log::set_max_level` so [`FileLogger::enabled`] stays authoritative.
52    level: AtomicUsize,
53    /// The log directory (`~/.vct/logs`), or `None` when the home directory
54    /// cannot be resolved (logging then silently no-ops).
55    dir: Option<PathBuf>,
56    /// The currently-open daily file, opened lazily on the first record and
57    /// reopened when the UTC day rolls over.
58    open: Mutex<Option<OpenLog>>,
59}
60
61impl FileLogger {
62    /// Builds a logger rooted at `dir` (test seam) with an initial max level.
63    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    /// Appends one preformatted line, opening (or rotating to) the day's file.
72    ///
73    /// Creating `~/.vct/logs` and the file only happens here, so a process that
74    /// never emits a record never creates anything on disk. The file name uses
75    /// the same UTC date as the line timestamps, and rolls over on the first
76    /// record after UTC midnight.
77    fn append(&self, line: &str) {
78        let Some(dir) = self.dir.as_deref() else {
79            return;
80        };
81        let today = utc_date();
82        // A poisoned mutex still holds a valid file handle; recover and keep logging.
83        let mut guard = self.open.lock().unwrap_or_else(|p| p.into_inner());
84        // (Re)open when there is no file yet or the UTC day has rolled over.
85        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
128/// Today's date in UTC as `YYYY-MM-DD`.
129///
130/// Deliberately UTC (not the local-day `utils::get_current_date`) so a log
131/// file's name matches the UTC timestamps of the lines inside it.
132fn utc_date() -> String {
133    Utc::now().date_naive().format("%Y-%m-%d").to_string()
134}
135
136/// Formats one log line: `<utc-nanos> <LEVEL> <target>  <message>`.
137///
138/// The crate's own `vct_core::` target prefix is shortened to `vct::`
139/// to keep lines readable.
140fn format_line(now: &str, level: Level, target: &str, msg: &str) -> String {
141    format!("{now} {:<5} {}  {msg}\n", level, normalize_target(target))
142}
143
144/// Rewrites the core crate's module-path target from `vct_core[...]` to
145/// `vct[...]`; leaves third-party targets untouched.
146fn 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
153/// Maps the user-facing [`LogLevel`] onto the `log` crate's [`LevelFilter`].
154fn 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
165/// Installs the global file logger at the default level (`warn`).
166///
167/// Called once at process start, before any subcommand runs. Safe to call more
168/// than once (subsequent calls are ignored) so tests that exercise `main` don't
169/// double-install. The level is later refined by [`apply`] once the user config
170/// is loaded. Terminal-restore-on-panic is a presentation concern, so the CLI
171/// binary installs that hook separately (the TUI setup also self-installs it).
172pub 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    // `set_logger` succeeds only on the first install for the whole process.
176    if log::set_logger(logger).is_ok() {
177        log::set_max_level(LevelFilter::Warn);
178    }
179}
180
181/// Applies the persisted `[logging]` settings: sets the max level and prunes
182/// stale daily files. Called after the user config is loaded (usage / analysis).
183pub 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
192/// Prunes daily log files older than `retention_days` from the log directory.
193fn 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
204/// Deletes every `vct-YYYY-MM-DD.log` file in `dir` whose date is strictly
205/// before `cutoff` (test seam: the cutoff is injected rather than derived from
206/// "now").
207fn 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        // Off gates out even Error; Warn admits Error+Warn but not Info.
259        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        // Nothing on disk until the first append.
275        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        // Below the gate (info < warn): dropped, and nothing is created on disk.
292        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        // At the gate (warn): written through the full log() -> format -> append path.
305        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}