1#![cfg_attr(
9 not(test),
10 deny(
11 clippy::unwrap_used,
12 clippy::unreachable,
13 clippy::expect_used,
14 clippy::panic,
15 clippy::unimplemented,
16 clippy::todo
17 )
18)]
19
20use std::ffi::OsStr;
21use std::io::Write;
22use std::path::Path;
23
24use tracing_appender::non_blocking::WorkerGuard;
25use tracing_appender::rolling;
26use tracing_subscriber::EnvFilter;
27
28const DEFAULT_TEXT_FORMAT: &str = "text";
29const DEFAULT_JSON_FORMAT: &str = "json";
30const DEFAULT_LOG_FILENAME: &str = "aster.log";
31
32#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
34pub struct LoggingConfig {
35 #[serde(default = "LoggingConfig::default_level")]
37 pub level: String,
38 #[serde(default = "LoggingConfig::default_format")]
40 pub format: String,
41 #[serde(default)]
43 pub file: String,
44 #[serde(default = "LoggingConfig::default_enable_rotation")]
46 pub enable_rotation: bool,
47 #[serde(default = "LoggingConfig::default_max_backups")]
49 pub max_backups: u32,
50}
51
52impl Default for LoggingConfig {
53 fn default() -> Self {
54 Self {
55 level: Self::default_level(),
56 format: Self::default_format(),
57 file: String::new(),
58 enable_rotation: Self::default_enable_rotation(),
59 max_backups: Self::default_max_backups(),
60 }
61 }
62}
63
64impl LoggingConfig {
65 fn default_level() -> String {
66 "info".to_string()
67 }
68
69 fn default_format() -> String {
70 DEFAULT_TEXT_FORMAT.to_string()
71 }
72
73 const fn default_enable_rotation() -> bool {
74 true
75 }
76
77 const fn default_max_backups() -> u32 {
78 5
79 }
80}
81
82pub struct LoggingInitResult {
84 pub guard: WorkerGuard,
86 pub warning: Option<String>,
88}
89
90pub fn init_logging(config: &LoggingConfig) -> LoggingInitResult {
99 let (writer, warning) = build_writer(config);
100 let (non_blocking_writer, guard) = tracing_appender::non_blocking(writer);
101
102 let mut warning = warning;
103 let filter = build_filter(&config.level, &mut warning);
104 let is_stdout = config.file.is_empty();
105
106 let builder = tracing_subscriber::fmt()
107 .with_writer(non_blocking_writer)
108 .with_env_filter(filter)
109 .with_level(true)
110 .with_ansi(is_stdout);
111
112 #[cfg(debug_assertions)]
113 let builder = builder.with_file(true).with_line_number(true);
114
115 let init_result = if config.format == DEFAULT_JSON_FORMAT {
116 builder.json().try_init()
117 } else {
118 builder.try_init()
119 };
120 if let Err(error) = init_result {
121 push_warning(
125 &mut warning,
126 format!(
127 "Global tracing subscriber was already installed; keeping the existing subscriber: {error}"
128 ),
129 );
130 }
131
132 LoggingInitResult { guard, warning }
133}
134
135fn build_writer(config: &LoggingConfig) -> (Box<dyn Write + Send + Sync>, Option<String>) {
136 if config.file.is_empty() {
137 return (Box::new(std::io::stdout()), None);
138 }
139
140 if config.enable_rotation {
141 build_rolling_writer(config)
142 } else {
143 build_file_writer(&config.file)
144 }
145}
146
147fn build_rolling_writer(config: &LoggingConfig) -> (Box<dyn Write + Send + Sync>, Option<String>) {
148 let path = Path::new(&config.file);
149 let dir = path.parent().unwrap_or_else(|| Path::new("."));
150 let filename = path
151 .file_name()
152 .unwrap_or_else(|| OsStr::new(DEFAULT_LOG_FILENAME));
153 let filename = filename.to_str().unwrap_or(DEFAULT_LOG_FILENAME);
154 let max_log_files = usize::try_from(config.max_backups).unwrap_or(usize::MAX);
155
156 match rolling::Builder::new()
157 .rotation(rolling::Rotation::DAILY)
158 .filename_prefix(filename.trim_end_matches(".log"))
159 .filename_suffix("log")
160 .max_log_files(max_log_files)
161 .build(dir)
162 {
163 Ok(appender) => (Box::new(appender), None),
164 Err(error) => (
165 Box::new(std::io::stdout()),
166 Some(format!(
167 "Failed to create rolling log appender for '{}': {}. Falling back to stdout.",
168 config.file, error
169 )),
170 ),
171 }
172}
173
174fn build_file_writer(file: &str) -> (Box<dyn Write + Send + Sync>, Option<String>) {
175 match std::fs::OpenOptions::new()
176 .create(true)
177 .append(true)
178 .open(file)
179 {
180 Ok(file) => (Box::new(file), None),
181 Err(error) => (
182 Box::new(std::io::stdout()),
183 Some(format!(
184 "Failed to open log file '{}': {}. Falling back to stdout.",
185 file, error
186 )),
187 ),
188 }
189}
190
191fn build_filter(level: &str, warning: &mut Option<String>) -> EnvFilter {
192 match std::env::var("RUST_LOG") {
196 Ok(value) => match EnvFilter::try_new(&value) {
197 Ok(filter) => {
198 push_warning(
199 warning,
200 format!(
201 "RUST_LOG environment variable detected; config.toml logging.level='{level}' is overridden by RUST_LOG"
202 ),
203 );
204 filter
205 }
206 Err(error) => {
207 push_warning(
208 warning,
209 format!(
210 "Invalid RUST_LOG value '{value}': {error}. Falling back to logging.level='{level}'."
211 ),
212 );
213 build_config_filter(level, warning)
214 }
215 },
216 Err(std::env::VarError::NotPresent) => build_config_filter(level, warning),
217 Err(std::env::VarError::NotUnicode(value)) => {
218 push_warning(
219 warning,
220 format!(
221 "Invalid RUST_LOG value '{}': not valid Unicode. Falling back to logging.level='{level}'.",
222 value.to_string_lossy()
223 ),
224 );
225 build_config_filter(level, warning)
226 }
227 }
228}
229
230fn build_config_filter(level: &str, warning: &mut Option<String>) -> EnvFilter {
231 match EnvFilter::try_new(level) {
232 Ok(filter) => filter,
233 Err(error) => {
234 push_warning(
235 warning,
236 format!("Invalid logging.level '{level}': {error}. Falling back to 'info'."),
237 );
238 EnvFilter::new("info")
239 }
240 }
241}
242
243fn push_warning(warning: &mut Option<String>, message: String) {
244 if let Some(existing) = warning.as_mut() {
245 existing.push(' ');
246 existing.push_str(&message);
247 } else {
248 *warning = Some(message);
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::{
255 DEFAULT_JSON_FORMAT, LoggingConfig, build_file_writer, build_filter, build_rolling_writer,
256 build_writer, init_logging,
257 };
258 use aster_forge_test::temp::TestTempDir;
259 use std::io::Write;
260
261 #[test]
262 fn build_writer_uses_stdout_for_empty_file() {
263 let (_writer, warning) = build_writer(&LoggingConfig {
264 file: String::new(),
265 ..LoggingConfig::default()
266 });
267
268 assert!(warning.is_none());
269 }
270
271 #[test]
272 fn logging_config_deserializes_missing_fields_with_defaults() {
273 let config: LoggingConfig =
274 serde_json::from_str("{}").expect("empty logging config should use field defaults");
275
276 assert_eq!(config, LoggingConfig::default());
277 }
278
279 #[test]
280 fn build_writer_reports_invalid_file_and_falls_back_to_stdout() {
281 let directory = TestTempDir::new("logging-invalid-file");
282 let parent_file = directory.join("not-a-directory");
283 std::fs::write(&parent_file, "fixture").expect("parent fixture should be writable");
284 let (_writer, warning) = build_writer(&LoggingConfig {
285 file: parent_file.join("aster.log").to_string_lossy().into_owned(),
286 enable_rotation: false,
287 ..LoggingConfig::default()
288 });
289
290 let warning = warning.expect("invalid file path should report warning");
291 assert!(warning.contains("Failed to open log file"));
292 assert!(warning.contains("Falling back to stdout"));
293 }
294
295 #[test]
296 fn build_file_writer_creates_file_and_appends_bytes() {
297 let directory = TestTempDir::new("logging-file-writer");
298 let path = directory.join("logs").join("aster.log");
299 std::fs::create_dir_all(path.parent().expect("test path has parent"))
300 .expect("fixture parent should be created");
301
302 let (mut writer, warning) =
303 build_file_writer(path.to_str().expect("test path should be utf-8"));
304 assert!(warning.is_none());
305 writer
306 .write_all(b"first line\n")
307 .expect("file writer should accept first write");
308 drop(writer);
309
310 let (mut writer, warning) =
311 build_file_writer(path.to_str().expect("test path should be utf-8"));
312 assert!(warning.is_none());
313 writer
314 .write_all(b"second line\n")
315 .expect("file writer should append second write");
316 drop(writer);
317
318 let contents =
319 std::fs::read_to_string(&path).expect("file writer output should be readable");
320 assert_eq!(contents, "first line\nsecond line\n");
321 }
322
323 #[test]
324 fn build_rolling_writer_creates_daily_appender_for_valid_directory() {
325 let directory = TestTempDir::new("logging-rolling-writer");
326 let file = directory.join("service.log");
327
328 let (mut writer, warning) = build_rolling_writer(&LoggingConfig {
329 file: file.to_string_lossy().into_owned(),
330 max_backups: 2,
331 ..LoggingConfig::default()
332 });
333
334 assert!(warning.is_none());
335 writer
336 .write_all(b"rolling line\n")
337 .expect("rolling writer should accept writes");
338 }
339
340 #[test]
341 fn build_rolling_writer_reports_invalid_directory_and_falls_back_to_stdout() {
342 let directory = TestTempDir::new("logging-invalid-rolling-directory");
343 let parent_file = directory.join("not-a-directory");
344 std::fs::write(&parent_file, "not a directory")
345 .expect("parent-file fixture should be writable");
346 let file = parent_file.join("aster.log");
347
348 let (_writer, warning) = build_rolling_writer(&LoggingConfig {
349 file: file.to_string_lossy().into_owned(),
350 ..LoggingConfig::default()
351 });
352
353 let warning = warning.expect("invalid rolling log path should report warning");
354 assert!(warning.contains("Failed to create rolling log appender"));
355 assert!(warning.contains("Falling back to stdout"));
356 }
357
358 #[test]
359 fn build_filter_reports_invalid_level_warning() {
360 let mut warning = None;
361 let _filter = build_filter("aster=not-a-level", &mut warning);
362
363 let warning = warning.expect("invalid level should report warning");
364 assert!(
365 warning.contains("Invalid logging.level")
366 || warning.contains("RUST_LOG environment variable detected"),
367 "{warning}"
368 );
369 }
370
371 #[test]
372 fn init_logging_warns_instead_of_panicking_when_subscriber_already_installed() {
373 let _first = init_logging(&LoggingConfig::default());
376
377 let second = init_logging(&LoggingConfig::default());
378 let warning = second
379 .warning
380 .expect("re-initializing should report a warning instead of panicking");
381 assert!(
382 warning.contains("already installed"),
383 "unexpected warning: {warning}"
384 );
385 }
386
387 #[test]
388 fn build_filter_warns_when_rust_log_is_invalid_in_child_process() {
389 if std::env::var("ASTER_FORGE_LOGGING_FILTER_CHILD").is_ok() {
390 run_build_filter_child();
391 return;
392 }
393
394 let current_exe = std::env::current_exe().expect("current test executable should resolve");
395 let output = std::process::Command::new(current_exe)
396 .arg("--exact")
397 .arg("tests::build_filter_warns_when_rust_log_is_invalid_in_child_process")
398 .arg("--nocapture")
399 .env("ASTER_FORGE_LOGGING_FILTER_CHILD", "1")
400 .env("RUST_LOG", "aster=not-a-level")
401 .output()
402 .expect("build_filter child process should run");
403
404 assert!(
405 output.status.success(),
406 "child process failed\nstdout:\n{}\nstderr:\n{}",
407 String::from_utf8_lossy(&output.stdout),
408 String::from_utf8_lossy(&output.stderr)
409 );
410 }
411
412 fn run_build_filter_child() {
413 let mut warning = None;
414 let _filter = build_filter("debug", &mut warning);
415
416 let warning = warning.expect("invalid RUST_LOG should report warning");
417 assert!(
418 warning.contains("Invalid RUST_LOG"),
419 "unexpected warning: {warning}"
420 );
421 assert!(
422 warning.contains("logging.level='debug'"),
423 "fallback should name the config level: {warning}"
424 );
425 }
426
427 #[test]
428 fn init_logging_can_initialize_global_subscriber_in_child_process() {
429 if std::env::var("ASTER_FORGE_LOGGING_INIT_CHILD").is_ok() {
430 run_init_logging_child();
431 return;
432 }
433
434 let current_exe = std::env::current_exe().expect("current test executable should resolve");
435 let output = std::process::Command::new(current_exe)
436 .arg("--exact")
437 .arg("tests::init_logging_can_initialize_global_subscriber_in_child_process")
438 .arg("--nocapture")
439 .env("ASTER_FORGE_LOGGING_INIT_CHILD", "1")
440 .env_remove("RUST_LOG")
441 .output()
442 .expect("init_logging child process should run");
443
444 assert!(
445 output.status.success(),
446 "child process failed\nstdout:\n{}\nstderr:\n{}",
447 String::from_utf8_lossy(&output.stdout),
448 String::from_utf8_lossy(&output.stderr)
449 );
450 }
451
452 fn run_init_logging_child() {
453 let directory = TestTempDir::new("logging-init-child");
454 let log_path = directory.join("logs").join("aster.log");
455 std::fs::create_dir_all(log_path.parent().expect("test path has parent"))
456 .expect("fixture parent should be created");
457
458 let result = init_logging(&LoggingConfig {
459 level: "aster=not-a-level".to_string(),
460 format: DEFAULT_JSON_FORMAT.to_string(),
461 file: log_path.to_string_lossy().into_owned(),
462 enable_rotation: false,
463 max_backups: 1,
464 });
465
466 let warning = result
467 .warning
468 .expect("invalid logging level should report startup warning");
469 assert!(warning.contains("Invalid logging.level"));
470 drop(result.guard);
471 }
472}