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}