bamboo_infrastructure/
logging.rs1use std::path::{Path, PathBuf};
29
30use tracing_appender::rolling::{RollingFileAppender, Rotation};
31use tracing_subscriber::{fmt, prelude::*, EnvFilter};
32
33pub const DEFAULT_MAX_LOG_FILES: usize = 14;
36
37#[derive(Debug, Clone)]
39pub struct LogOptions {
40 pub dir: PathBuf,
42 pub file_name_prefix: String,
45 pub max_files: usize,
47 pub default_level: String,
49}
50
51impl LogOptions {
52 pub fn new(dir: impl Into<PathBuf>) -> Self {
55 Self {
56 dir: dir.into(),
57 file_name_prefix: "bamboo".to_string(),
58 max_files: DEFAULT_MAX_LOG_FILES,
59 default_level: "info".to_string(),
60 }
61 }
62}
63
64pub fn init_logging_with_home(home: &Path, debug: bool) {
70 init_logging_with_options(options_for_home(home, debug));
71}
72
73pub fn init_server_logging_with_home(home: &Path) {
80 init_logging_with_options(options_for_server_home(home, cfg!(debug_assertions)));
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84enum LogContext {
85 Server,
86 BuildProfile,
87}
88
89fn options_for_home(home: &Path, debug: bool) -> LogOptions {
95 let mut opts = LogOptions::new(home.join("logs"));
96 opts.default_level = level_for(LogContext::BuildProfile, debug).to_string();
97 opts
98}
99
100fn options_for_server_home(home: &Path, debug_build: bool) -> LogOptions {
104 let mut opts = LogOptions::new(home.join("logs"));
105 opts.default_level = level_for(LogContext::Server, debug_build).to_string();
106 opts
107}
108
109fn build_appender(
115 opts: &LogOptions,
116) -> Result<RollingFileAppender, tracing_appender::rolling::InitError> {
117 if let Err(e) = std::fs::create_dir_all(&opts.dir) {
120 eprintln!(
121 "warning: could not create log directory {}: {e}",
122 opts.dir.display()
123 );
124 }
125
126 RollingFileAppender::builder()
127 .rotation(Rotation::DAILY)
128 .filename_prefix(&opts.file_name_prefix)
129 .filename_suffix("log")
130 .max_log_files(opts.max_files)
131 .build(&opts.dir)
132}
133
134pub fn init_logging_with_options(opts: LogOptions) {
136 let default_level = opts.default_level.clone();
138 let make_filter = move || {
139 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level.clone()))
140 };
141
142 match build_appender(&opts) {
143 Ok(file_writer) => {
144 let stdout_layer = fmt::layer().with_target(true);
147 let file_layer = fmt::layer()
148 .with_target(true)
149 .with_ansi(false)
150 .with_writer(file_writer);
151 let _ = tracing_subscriber::registry()
152 .with(make_filter())
153 .with(stdout_layer)
154 .with(file_layer)
155 .try_init();
156 }
157 Err(e) => {
158 eprintln!("warning: file logging disabled ({e}); using stdout only");
159 let _ = fmt()
160 .with_target(true)
161 .with_env_filter(make_filter())
162 .try_init();
163 }
164 }
165}
166
167pub fn init_logging(debug: bool) {
172 let _ = fmt()
173 .with_target(true)
174 .with_env_filter(
175 EnvFilter::try_from_default_env()
176 .unwrap_or_else(|_| EnvFilter::new(level_for(LogContext::BuildProfile, debug))),
177 )
178 .try_init();
179}
180
181fn level_for(context: LogContext, debug_build: bool) -> &'static str {
183 match (context, debug_build) {
184 (LogContext::Server, _) | (LogContext::BuildProfile, false) => "info",
185 (LogContext::BuildProfile, true) => "debug",
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use std::io::Write;
193 use tempfile::tempdir;
194 use tracing_subscriber::fmt::MakeWriter;
195
196 #[test]
197 fn level_for_maps_build_profile_for_embedding_apis() {
198 assert_eq!(level_for(LogContext::BuildProfile, true), "debug");
199 assert_eq!(level_for(LogContext::BuildProfile, false), "info");
200 }
201
202 #[test]
203 fn server_level_is_info_in_debug_and_release_builds() {
204 assert_eq!(level_for(LogContext::Server, true), "info");
205 assert_eq!(level_for(LogContext::Server, false), "info");
206 }
207
208 #[test]
209 fn log_options_new_uses_shared_defaults() {
210 let opts = LogOptions::new("/tmp/example");
211 assert_eq!(opts.dir, PathBuf::from("/tmp/example"));
212 assert_eq!(opts.file_name_prefix, "bamboo");
213 assert_eq!(opts.max_files, DEFAULT_MAX_LOG_FILES);
214 assert_eq!(opts.default_level, "info");
215 }
216
217 #[test]
218 fn options_for_home_places_logs_under_home_and_sets_level() {
219 let debug = options_for_home(Path::new("/srv/data"), true);
220 assert_eq!(debug.dir, PathBuf::from("/srv/data/logs"));
221 assert_eq!(debug.default_level, "debug");
222
223 let release = options_for_home(Path::new("/srv/data"), false);
224 assert_eq!(release.default_level, "info");
225 }
226
227 #[test]
228 fn server_options_are_info_in_debug_and_release_builds() {
229 for debug_build in [true, false] {
230 let opts = options_for_server_home(Path::new("/srv/data"), debug_build);
231 assert_eq!(opts.dir, PathBuf::from("/srv/data/logs"));
232 assert_eq!(opts.default_level, "info");
233 }
234 }
235
236 #[test]
237 fn build_appender_creates_dir_and_writes_dated_file() {
238 let tmp = tempdir().expect("tempdir");
239 let dir = tmp.path().join("nested").join("logs");
241 let opts = LogOptions {
242 dir: dir.clone(),
243 file_name_prefix: "unit-test".to_string(),
244 max_files: 5,
245 default_level: "info".to_string(),
246 };
247
248 let appender = build_appender(&opts).expect("appender builds");
249 assert!(dir.exists(), "log directory should be created");
250
251 {
253 let mut writer = appender.make_writer();
254 writeln!(writer, "hello-from-test").expect("write line");
255 writer.flush().expect("flush");
256 }
257 drop(appender); let entries: Vec<_> = std::fs::read_dir(&dir)
260 .expect("read log dir")
261 .filter_map(Result::ok)
262 .map(|e| e.file_name().to_string_lossy().into_owned())
263 .collect();
264
265 assert_eq!(entries.len(), 1, "exactly one log file, got {entries:?}");
266 let name = &entries[0];
267 assert!(
268 name.starts_with("unit-test.") && name.ends_with(".log"),
269 "filename should be `<prefix>.<date>.log`, got {name}"
270 );
271
272 let contents =
273 std::fs::read_to_string(dir.join(name)).expect("read back log file contents");
274 assert!(
275 contents.contains("hello-from-test"),
276 "log file should contain the written line, got: {contents:?}"
277 );
278 }
279
280 #[test]
281 fn init_logging_with_options_creates_dir_and_is_idempotent() {
282 let tmp = tempdir().expect("tempdir");
286 let dir = tmp.path().join("logs");
287 let opts = LogOptions {
288 dir: dir.clone(),
289 file_name_prefix: "idem".to_string(),
290 max_files: 2,
291 default_level: "info".to_string(),
292 };
293
294 init_logging_with_options(opts.clone());
295 init_logging_with_options(opts); assert!(dir.exists());
298 }
299}