init.rs (6741B)
1 use std::fs; 2 use std::sync::OnceLock; 3 4 use tracing::info; 5 use tracing_appender::rolling::{RollingFileAppender, Rotation}; 6 use tracing_subscriber::prelude::*; 7 use tracing_subscriber::{EnvFilter, fmt}; 8 9 use crate::Result; 10 use crate::options::{LogFileLayout, LoggingOptions}; 11 12 static GUARD: OnceLock<tracing_appender::non_blocking::WorkerGuard> = OnceLock::new(); 13 static INIT: OnceLock<()> = OnceLock::new(); 14 15 pub fn init_logging(opts: LoggingOptions) -> Result<()> { 16 if INIT.get().is_some() { 17 return Ok(()); 18 } 19 20 let writer = if let Some(dir) = &opts.dir { 21 fs::create_dir_all(dir)?; 22 let file_appender = build_file_appender(dir, &opts)?; 23 let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); 24 let _ = GUARD.set(guard); 25 Some(non_blocking) 26 } else { 27 None 28 }; 29 30 let env = resolve_env_filter(opts.default_level.as_deref()); 31 let fmt_layer_file = writer.as_ref().map(|w| { 32 fmt::layer() 33 .with_writer(w.clone()) 34 .with_ansi(false) 35 .with_target(false) 36 }); 37 let fmt_layer_stdout = if opts.also_stdout() { 38 Some(fmt::layer().with_writer(std::io::stdout).with_target(false)) 39 } else { 40 None 41 }; 42 43 let subscriber = tracing_subscriber::registry() 44 .with(env) 45 .with(fmt_layer_file) 46 .with(fmt_layer_stdout); 47 48 subscriber.try_init()?; 49 let _ = INIT.set(()); 50 info!( 51 "logging initialized (file: {}, stdout: {})", 52 opts.resolved_current_log_file_path() 53 .map(|path| path.display().to_string()) 54 .unwrap_or_else(|| "<disabled>".into()), 55 opts.also_stdout() 56 ); 57 Ok(()) 58 } 59 60 fn resolve_env_filter(default_level: Option<&str>) -> EnvFilter { 61 match default_level { 62 Some(level) => EnvFilter::new(level), 63 None => EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), 64 } 65 } 66 67 pub fn init_stdout() -> Result<()> { 68 init_logging(LoggingOptions { 69 dir: None, 70 file_name: "radroots.log".into(), 71 stdout: true, 72 default_level: None, 73 file_layout: LogFileLayout::PrefixedDate, 74 }) 75 } 76 77 fn build_file_appender( 78 dir: &std::path::Path, 79 opts: &LoggingOptions, 80 ) -> Result<RollingFileAppender> { 81 let builder = RollingFileAppender::builder().rotation(Rotation::DAILY); 82 let builder = match opts.file_layout { 83 LogFileLayout::PrefixedDate => builder.filename_prefix(opts.file_name.as_str()), 84 LogFileLayout::DatedFileName => builder.filename_suffix(opts.file_name.as_str()), 85 }; 86 Ok(builder.build(dir)?) 87 } 88 89 #[cfg(test)] 90 mod tests { 91 use super::{build_file_appender, init_logging, resolve_env_filter}; 92 use crate::{LogFileLayout, LoggingOptions}; 93 use std::path::PathBuf; 94 use std::time::{SystemTime, UNIX_EPOCH}; 95 use tracing_subscriber::fmt::MakeWriter; 96 97 fn temp_log_dir(name: &str) -> PathBuf { 98 let nanos = SystemTime::now() 99 .duration_since(UNIX_EPOCH) 100 .expect("system time") 101 .as_nanos(); 102 let dir = std::env::temp_dir().join(format!("radroots_log-{name}-{nanos}")); 103 let _ = std::fs::remove_dir_all(&dir); 104 dir 105 } 106 107 #[test] 108 fn prefixed_date_layout_keeps_existing_filename_shape() { 109 let dir = temp_log_dir("prefixed-date"); 110 let appender = build_file_appender( 111 dir.as_path(), 112 &LoggingOptions { 113 dir: Some(dir.clone()), 114 file_name: "myc.log".to_owned(), 115 stdout: false, 116 default_level: Some("info".to_owned()), 117 file_layout: LogFileLayout::PrefixedDate, 118 }, 119 ) 120 .expect("appender"); 121 122 let writer = appender.make_writer(); 123 drop(writer); 124 125 let names: Vec<String> = std::fs::read_dir(&dir) 126 .expect("read dir") 127 .map(|entry| { 128 entry 129 .expect("entry") 130 .file_name() 131 .to_string_lossy() 132 .to_string() 133 }) 134 .collect(); 135 assert_eq!(names.len(), 1); 136 assert!(names[0].starts_with("myc.log.")); 137 let _ = std::fs::remove_dir_all(&dir); 138 } 139 140 #[test] 141 fn dated_file_name_layout_writes_date_named_log_files() { 142 let dir = temp_log_dir("dated-file-name"); 143 let appender = build_file_appender( 144 dir.as_path(), 145 &LoggingOptions { 146 dir: Some(dir.clone()), 147 file_name: "log".to_owned(), 148 stdout: false, 149 default_level: Some("info".to_owned()), 150 file_layout: LogFileLayout::DatedFileName, 151 }, 152 ) 153 .expect("appender"); 154 155 let writer = appender.make_writer(); 156 drop(writer); 157 158 let names: Vec<String> = std::fs::read_dir(&dir) 159 .expect("read dir") 160 .map(|entry| { 161 entry 162 .expect("entry") 163 .file_name() 164 .to_string_lossy() 165 .to_string() 166 }) 167 .collect(); 168 assert_eq!(names.len(), 1); 169 assert!(names[0].ends_with(".log")); 170 assert_eq!(names[0].matches('.').count(), 1); 171 let _ = std::fs::remove_dir_all(&dir); 172 } 173 174 #[test] 175 fn init_paths_cover_layout_options() { 176 let dir = temp_log_dir("init-paths"); 177 std::fs::create_dir_all(&dir).expect("create dir"); 178 let invalid = dir.join("not-a-dir"); 179 std::fs::write(&invalid, "file").expect("write invalid path"); 180 let err_path = init_logging(LoggingOptions { 181 dir: Some(invalid), 182 file_name: "x.log".to_string(), 183 stdout: false, 184 default_level: None, 185 file_layout: LogFileLayout::PrefixedDate, 186 }); 187 assert!(err_path.is_err()); 188 189 let first = build_file_appender( 190 &dir, 191 &LoggingOptions { 192 dir: Some(dir.clone()), 193 file_name: "service".to_string(), 194 stdout: false, 195 default_level: Some("info".to_string()), 196 file_layout: LogFileLayout::DatedFileName, 197 }, 198 ); 199 assert!(first.is_ok()); 200 let _ = std::fs::remove_dir_all(&dir); 201 } 202 203 #[test] 204 fn explicit_default_level_wins_over_ambient_rust_log() { 205 // Callers that pass an explicit service filter should not inherit the shell's RUST_LOG. 206 let env = resolve_env_filter(Some("info,myc=info")); 207 let rendered = env.to_string(); 208 assert!(rendered.contains("info")); 209 assert!(rendered.contains("myc=info")); 210 } 211 }