Skip to main content

bamboo_infrastructure/
logging.rs

1//! Centralized logging/tracing initialization.
2//!
3//! This lives in the infrastructure layer (next to `config::paths`) so every
4//! consumer shares one logging policy: the standalone `bamboo serve` binary, the
5//! CLI/TUI, and embedded hosts such as the Bodhi Tauri app. The policy fixes the
6//! problems the old per-app setups had:
7//!
8//! - **Logs survive restarts.** Output goes to a date-stamped file under
9//!   `{home}/logs` that is appended to, not truncated, so a restart on the same
10//!   day continues the same file and earlier days are left intact.
11//! - **Rotation is by date, not size.** Files roll once per day (`Rotation::DAILY`),
12//!   so a single run's logs are never split mid-stream on a byte threshold.
13//! - **Old files are purged.** At most [`DEFAULT_MAX_LOG_FILES`] dated files are
14//!   kept; the appender deletes the oldest beyond that on rollover.
15//! - **Level matches the build profile.** Debug builds default to `debug`, release
16//!   builds to `info`, and `RUST_LOG` always overrides both.
17//!
18//! All initializers are best-effort and idempotent: they use `try_init`, so a
19//! second call (or a call after some other subscriber is installed) is a no-op
20//! rather than a panic. Because the global subscriber is a process-wide side
21//! effect, call these once from a binary's entry point — not from library code.
22//!
23//! `tracing-subscriber`'s default `tracing-log` feature installs a `log` →
24//! `tracing` bridge as part of `try_init`, so existing `log::info!`-style calls
25//! (which Bodhi uses heavily) are captured without any code changes.
26
27use std::path::{Path, PathBuf};
28
29use tracing_appender::rolling::{RollingFileAppender, Rotation};
30use tracing_subscriber::{fmt, prelude::*, EnvFilter};
31
32/// Number of dated log files to retain before the oldest are purged on rollover.
33/// With daily rotation this is roughly two weeks of history.
34pub const DEFAULT_MAX_LOG_FILES: usize = 14;
35
36/// Tuning knobs for [`init_logging_with_options`].
37#[derive(Debug, Clone)]
38pub struct LogOptions {
39    /// Directory the log files are written to (created if missing).
40    pub dir: PathBuf,
41    /// Filename prefix; the date and a `.log` suffix are appended by the appender
42    /// (e.g. `bamboo.2026-05-31.log`). Lets co-located apps keep separate files.
43    pub file_name_prefix: String,
44    /// Maximum number of dated files to keep; older ones are deleted on rollover.
45    pub max_files: usize,
46    /// Level filter used when `RUST_LOG` is not set (e.g. `"info"` or `"debug"`).
47    pub default_level: String,
48}
49
50impl LogOptions {
51    /// Options writing to `dir` with the shared defaults (`bamboo` prefix,
52    /// [`DEFAULT_MAX_LOG_FILES`] retention, `info` level).
53    pub fn new(dir: impl Into<PathBuf>) -> Self {
54        Self {
55            dir: dir.into(),
56            file_name_prefix: "bamboo".to_string(),
57            max_files: DEFAULT_MAX_LOG_FILES,
58            default_level: "info".to_string(),
59        }
60    }
61}
62
63/// Initialize file + stdout logging for a process rooted at `home`.
64///
65/// Logs are written under `{home}/logs`. Pass `debug = true` (typically
66/// `cfg!(debug_assertions)`) to default to the `debug` level; otherwise `info`.
67/// This is the entry point both the `bamboo` binary and the Bodhi app call.
68pub fn init_logging_with_home(home: &Path, debug: bool) {
69    init_logging_with_options(options_for_home(home, debug));
70}
71
72/// Build the [`LogOptions`] used by [`init_logging_with_home`]: logs under
73/// `{home}/logs`, level by build profile, shared defaults otherwise.
74///
75/// Split out from the initializer so the path/level composition can be unit
76/// tested without installing a process-global subscriber.
77fn options_for_home(home: &Path, debug: bool) -> LogOptions {
78    let mut opts = LogOptions::new(home.join("logs"));
79    opts.default_level = level_for(debug).to_string();
80    opts
81}
82
83/// Create the log directory and a daily-rotating file appender for `opts`.
84///
85/// Separated from [`init_logging_with_options`] so the file-side behavior
86/// (directory creation, filename shape, rotation/retention config) is testable
87/// without touching the global subscriber, which can only be set once per process.
88fn build_appender(
89    opts: &LogOptions,
90) -> Result<RollingFileAppender, tracing_appender::rolling::InitError> {
91    // Best-effort: a missing directory shouldn't abort startup. If creation
92    // fails we still try the appender (and the caller falls back to stdout).
93    if let Err(e) = std::fs::create_dir_all(&opts.dir) {
94        eprintln!(
95            "warning: could not create log directory {}: {e}",
96            opts.dir.display()
97        );
98    }
99
100    RollingFileAppender::builder()
101        .rotation(Rotation::DAILY)
102        .filename_prefix(&opts.file_name_prefix)
103        .filename_suffix("log")
104        .max_log_files(opts.max_files)
105        .build(&opts.dir)
106}
107
108/// Initialize file + stdout logging from explicit [`LogOptions`].
109pub fn init_logging_with_options(opts: LogOptions) {
110    // EnvFilter is not `Clone`, so build a fresh one wherever it's needed.
111    let default_level = opts.default_level.clone();
112    let make_filter = move || {
113        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level.clone()))
114    };
115
116    match build_appender(&opts) {
117        Ok(file_writer) => {
118            // `RollingFileAppender` implements `MakeWriter`, so it drives the file
119            // layer directly — no background worker, hence no guard to keep alive.
120            let stdout_layer = fmt::layer().with_target(true);
121            let file_layer = fmt::layer()
122                .with_target(true)
123                .with_ansi(false)
124                .with_writer(file_writer);
125            let _ = tracing_subscriber::registry()
126                .with(make_filter())
127                .with(stdout_layer)
128                .with(file_layer)
129                .try_init();
130        }
131        Err(e) => {
132            eprintln!("warning: file logging disabled ({e}); using stdout only");
133            let _ = fmt()
134                .with_target(true)
135                .with_env_filter(make_filter())
136                .try_init();
137        }
138    }
139}
140
141/// Initialize stdout-only logging.
142///
143/// For contexts without a stable data directory (e.g. the `bamboo config`
144/// subcommand). Prefer [`init_logging_with_home`] when a `{home}/logs` dir exists.
145pub fn init_logging(debug: bool) {
146    let _ = fmt()
147        .with_target(true)
148        .with_env_filter(
149            EnvFilter::try_from_default_env()
150                .unwrap_or_else(|_| EnvFilter::new(level_for(debug))),
151        )
152        .try_init();
153}
154
155/// Default level string for a build profile when `RUST_LOG` is unset.
156fn level_for(debug: bool) -> &'static str {
157    if debug {
158        "debug"
159    } else {
160        "info"
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use std::io::Write;
168    use tempfile::tempdir;
169    use tracing_subscriber::fmt::MakeWriter;
170
171    #[test]
172    fn level_for_maps_build_profile() {
173        assert_eq!(level_for(true), "debug");
174        assert_eq!(level_for(false), "info");
175    }
176
177    #[test]
178    fn log_options_new_uses_shared_defaults() {
179        let opts = LogOptions::new("/tmp/example");
180        assert_eq!(opts.dir, PathBuf::from("/tmp/example"));
181        assert_eq!(opts.file_name_prefix, "bamboo");
182        assert_eq!(opts.max_files, DEFAULT_MAX_LOG_FILES);
183        assert_eq!(opts.default_level, "info");
184    }
185
186    #[test]
187    fn options_for_home_places_logs_under_home_and_sets_level() {
188        let debug = options_for_home(Path::new("/srv/data"), true);
189        assert_eq!(debug.dir, PathBuf::from("/srv/data/logs"));
190        assert_eq!(debug.default_level, "debug");
191
192        let release = options_for_home(Path::new("/srv/data"), false);
193        assert_eq!(release.default_level, "info");
194    }
195
196    #[test]
197    fn build_appender_creates_dir_and_writes_dated_file() {
198        let tmp = tempdir().expect("tempdir");
199        // Nested path that does not exist yet, to prove directories are created.
200        let dir = tmp.path().join("nested").join("logs");
201        let opts = LogOptions {
202            dir: dir.clone(),
203            file_name_prefix: "unit-test".to_string(),
204            max_files: 5,
205            default_level: "info".to_string(),
206        };
207
208        let appender = build_appender(&opts).expect("appender builds");
209        assert!(dir.exists(), "log directory should be created");
210
211        // Write through the appender the same way the fmt layer does.
212        {
213            let mut writer = appender.make_writer();
214            writeln!(writer, "hello-from-test").expect("write line");
215            writer.flush().expect("flush");
216        }
217        drop(appender); // ensure the file handle is released before reading
218
219        let entries: Vec<_> = std::fs::read_dir(&dir)
220            .expect("read log dir")
221            .filter_map(Result::ok)
222            .map(|e| e.file_name().to_string_lossy().into_owned())
223            .collect();
224
225        assert_eq!(entries.len(), 1, "exactly one log file, got {entries:?}");
226        let name = &entries[0];
227        assert!(
228            name.starts_with("unit-test.") && name.ends_with(".log"),
229            "filename should be `<prefix>.<date>.log`, got {name}"
230        );
231
232        let contents =
233            std::fs::read_to_string(dir.join(name)).expect("read back log file contents");
234        assert!(
235            contents.contains("hello-from-test"),
236            "log file should contain the written line, got: {contents:?}"
237        );
238    }
239
240    #[test]
241    fn init_logging_with_options_creates_dir_and_is_idempotent() {
242        // Exercises the real entry point. The global subscriber can only be set
243        // once per test binary, so we assert only on the deterministic side
244        // effect (directory creation) and that a repeat call does not panic.
245        let tmp = tempdir().expect("tempdir");
246        let dir = tmp.path().join("logs");
247        let opts = LogOptions {
248            dir: dir.clone(),
249            file_name_prefix: "idem".to_string(),
250            max_files: 2,
251            default_level: "info".to_string(),
252        };
253
254        init_logging_with_options(opts.clone());
255        init_logging_with_options(opts); // must be a no-op, not a panic
256
257        assert!(dir.exists());
258    }
259}