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().unwrap_or_else(|_| EnvFilter::new(level_for(debug))),
150        )
151        .try_init();
152}
153
154/// Default level string for a build profile when `RUST_LOG` is unset.
155fn level_for(debug: bool) -> &'static str {
156    if debug {
157        "debug"
158    } else {
159        "info"
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use std::io::Write;
167    use tempfile::tempdir;
168    use tracing_subscriber::fmt::MakeWriter;
169
170    #[test]
171    fn level_for_maps_build_profile() {
172        assert_eq!(level_for(true), "debug");
173        assert_eq!(level_for(false), "info");
174    }
175
176    #[test]
177    fn log_options_new_uses_shared_defaults() {
178        let opts = LogOptions::new("/tmp/example");
179        assert_eq!(opts.dir, PathBuf::from("/tmp/example"));
180        assert_eq!(opts.file_name_prefix, "bamboo");
181        assert_eq!(opts.max_files, DEFAULT_MAX_LOG_FILES);
182        assert_eq!(opts.default_level, "info");
183    }
184
185    #[test]
186    fn options_for_home_places_logs_under_home_and_sets_level() {
187        let debug = options_for_home(Path::new("/srv/data"), true);
188        assert_eq!(debug.dir, PathBuf::from("/srv/data/logs"));
189        assert_eq!(debug.default_level, "debug");
190
191        let release = options_for_home(Path::new("/srv/data"), false);
192        assert_eq!(release.default_level, "info");
193    }
194
195    #[test]
196    fn build_appender_creates_dir_and_writes_dated_file() {
197        let tmp = tempdir().expect("tempdir");
198        // Nested path that does not exist yet, to prove directories are created.
199        let dir = tmp.path().join("nested").join("logs");
200        let opts = LogOptions {
201            dir: dir.clone(),
202            file_name_prefix: "unit-test".to_string(),
203            max_files: 5,
204            default_level: "info".to_string(),
205        };
206
207        let appender = build_appender(&opts).expect("appender builds");
208        assert!(dir.exists(), "log directory should be created");
209
210        // Write through the appender the same way the fmt layer does.
211        {
212            let mut writer = appender.make_writer();
213            writeln!(writer, "hello-from-test").expect("write line");
214            writer.flush().expect("flush");
215        }
216        drop(appender); // ensure the file handle is released before reading
217
218        let entries: Vec<_> = std::fs::read_dir(&dir)
219            .expect("read log dir")
220            .filter_map(Result::ok)
221            .map(|e| e.file_name().to_string_lossy().into_owned())
222            .collect();
223
224        assert_eq!(entries.len(), 1, "exactly one log file, got {entries:?}");
225        let name = &entries[0];
226        assert!(
227            name.starts_with("unit-test.") && name.ends_with(".log"),
228            "filename should be `<prefix>.<date>.log`, got {name}"
229        );
230
231        let contents =
232            std::fs::read_to_string(dir.join(name)).expect("read back log file contents");
233        assert!(
234            contents.contains("hello-from-test"),
235            "log file should contain the written line, got: {contents:?}"
236        );
237    }
238
239    #[test]
240    fn init_logging_with_options_creates_dir_and_is_idempotent() {
241        // Exercises the real entry point. The global subscriber can only be set
242        // once per test binary, so we assert only on the deterministic side
243        // effect (directory creation) and that a repeat call does not panic.
244        let tmp = tempdir().expect("tempdir");
245        let dir = tmp.path().join("logs");
246        let opts = LogOptions {
247            dir: dir.clone(),
248            file_name_prefix: "idem".to_string(),
249            max_files: 2,
250            default_level: "info".to_string(),
251        };
252
253        init_logging_with_options(opts.clone());
254        init_logging_with_options(opts); // must be a no-op, not a panic
255
256        assert!(dir.exists());
257    }
258}