1use std::path::{Path, PathBuf};
19use tracing_appender::non_blocking::WorkerGuard;
20use tracing_appender::rolling::{RollingFileAppender, Rotation};
21use tracing_subscriber::EnvFilter;
22
23use crate::config::LogConfig;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum LogFormat {
28 Pretty,
30 Json,
32}
33
34pub fn parse_log_format(value: Option<&str>) -> LogFormat {
38 match value {
39 Some(v) if v.eq_ignore_ascii_case("json") => LogFormat::Json,
40 _ => LogFormat::Pretty,
41 }
42}
43
44pub fn parse_rotation(value: Option<&str>) -> Rotation {
47 match value {
48 Some(v) if v.eq_ignore_ascii_case("hourly") => Rotation::HOURLY,
49 Some(v) if v.eq_ignore_ascii_case("never") => Rotation::NEVER,
50 _ => Rotation::DAILY,
51 }
52}
53
54pub fn build_env_filter(level: Option<&str>) -> EnvFilter {
59 level
60 .and_then(|spec| EnvFilter::try_new(spec).ok())
61 .unwrap_or_else(|| EnvFilter::new("info"))
62}
63
64#[derive(Debug, Clone, PartialEq)]
67pub struct LogSettings {
68 pub level: Option<String>,
69 pub dir: PathBuf,
70 pub format: LogFormat,
71 pub rotation: Rotation,
72}
73
74pub fn resolve_log_settings(
78 cli_level: Option<String>,
79 cli_dir: Option<PathBuf>,
80 cli_format: Option<String>,
81 cli_rotation: Option<String>,
82 config: &LogConfig,
83 default_dir: &Path,
84) -> LogSettings {
85 let level = cli_level.or_else(|| config.level.clone());
86 let dir = cli_dir
87 .or_else(|| config.dir.clone().map(PathBuf::from))
88 .unwrap_or_else(|| default_dir.to_path_buf());
89 let format = parse_log_format(cli_format.as_deref().or(config.format.as_deref()));
90 let rotation = parse_rotation(cli_rotation.as_deref().or(config.rotation.as_deref()));
91 LogSettings {
92 level,
93 dir,
94 format,
95 rotation,
96 }
97}
98
99pub fn init_tracing(settings: &LogSettings) -> anyhow::Result<WorkerGuard> {
111 use std::io::IsTerminal;
112 init_tracing_with_stderr(settings, std::io::stderr().is_terminal())
113}
114
115fn init_tracing_with_stderr(
119 settings: &LogSettings,
120 attach_stderr: bool,
121) -> anyhow::Result<WorkerGuard> {
122 use tracing_subscriber::Layer;
123 use tracing_subscriber::layer::SubscriberExt;
124 use tracing_subscriber::util::SubscriberInitExt;
125
126 std::fs::create_dir_all(&settings.dir)?;
127 let file_appender =
128 RollingFileAppender::new(settings.rotation.clone(), &settings.dir, "bhtune.log");
129 let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
130 let filter = build_env_filter(settings.level.as_deref());
131
132 let file_layer = match settings.format {
133 LogFormat::Json => tracing_subscriber::fmt::layer()
134 .json()
135 .with_writer(non_blocking)
136 .boxed(),
137 LogFormat::Pretty => tracing_subscriber::fmt::layer()
138 .with_ansi(false)
139 .with_writer(non_blocking)
140 .boxed(),
141 };
142 let stderr_layer =
143 attach_stderr.then(|| tracing_subscriber::fmt::layer().with_writer(std::io::stderr));
144
145 tracing_subscriber::registry()
146 .with(filter)
147 .with(file_layer)
148 .with(stderr_layer)
149 .try_init()?;
150 Ok(guard)
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn parse_log_format_json() {
159 assert_eq!(parse_log_format(Some("json")), LogFormat::Json);
160 assert_eq!(parse_log_format(Some("JSON")), LogFormat::Json);
161 }
162
163 #[test]
164 fn parse_log_format_pretty() {
165 assert_eq!(parse_log_format(Some("pretty")), LogFormat::Pretty);
166 }
167
168 #[test]
169 fn parse_log_format_unknown_defaults_to_pretty() {
170 assert_eq!(parse_log_format(Some("yaml")), LogFormat::Pretty);
171 }
172
173 #[test]
174 fn parse_log_format_none_defaults_to_pretty() {
175 assert_eq!(parse_log_format(None), LogFormat::Pretty);
176 }
177
178 #[test]
179 fn parse_rotation_hourly() {
180 assert_eq!(parse_rotation(Some("hourly")), Rotation::HOURLY);
181 assert_eq!(parse_rotation(Some("HOURLY")), Rotation::HOURLY);
182 }
183
184 #[test]
185 fn parse_rotation_never() {
186 assert_eq!(parse_rotation(Some("never")), Rotation::NEVER);
187 }
188
189 #[test]
190 fn parse_rotation_daily() {
191 assert_eq!(parse_rotation(Some("daily")), Rotation::DAILY);
192 }
193
194 #[test]
195 fn parse_rotation_unknown_defaults_to_daily() {
196 assert_eq!(parse_rotation(Some("weekly")), Rotation::DAILY);
197 }
198
199 #[test]
200 fn parse_rotation_none_defaults_to_daily() {
201 assert_eq!(parse_rotation(None), Rotation::DAILY);
202 }
203
204 #[test]
205 fn build_env_filter_explicit_level() {
206 let filter = build_env_filter(Some("debug"));
209 assert!(format!("{filter}").contains("debug"));
210 }
211
212 #[test]
213 fn build_env_filter_invalid_falls_back_to_info() {
214 let filter = build_env_filter(Some("level=notalevel"));
218 assert!(format!("{filter}").contains("info"));
219 }
220
221 #[test]
222 fn build_env_filter_none_defaults_to_info() {
223 let filter = build_env_filter(None);
224 assert!(format!("{filter}").contains("info"));
225 }
226
227 #[test]
228 fn resolve_log_settings_cli_wins() {
229 let config = LogConfig {
230 level: Some("warn".to_string()),
231 dir: Some("/config/dir".to_string()),
232 format: Some("json".to_string()),
233 rotation: Some("hourly".to_string()),
234 };
235 let settings = resolve_log_settings(
236 Some("debug".to_string()),
237 Some(PathBuf::from("/cli/dir")),
238 Some("pretty".to_string()),
239 Some("never".to_string()),
240 &config,
241 Path::new("/default/dir"),
242 );
243 assert_eq!(settings.level, Some("debug".to_string()));
244 assert_eq!(settings.dir, PathBuf::from("/cli/dir"));
245 assert_eq!(settings.format, LogFormat::Pretty);
246 assert_eq!(settings.rotation, Rotation::NEVER);
247 }
248
249 #[test]
250 fn resolve_log_settings_config_wins_over_default() {
251 let config = LogConfig {
252 level: Some("warn".to_string()),
253 dir: Some("/config/dir".to_string()),
254 format: Some("json".to_string()),
255 rotation: Some("hourly".to_string()),
256 };
257 let settings =
258 resolve_log_settings(None, None, None, None, &config, Path::new("/default/dir"));
259 assert_eq!(settings.level, Some("warn".to_string()));
260 assert_eq!(settings.dir, PathBuf::from("/config/dir"));
261 assert_eq!(settings.format, LogFormat::Json);
262 assert_eq!(settings.rotation, Rotation::HOURLY);
263 }
264
265 #[test]
266 fn resolve_log_settings_defaults() {
267 let settings = resolve_log_settings(
268 None,
269 None,
270 None,
271 None,
272 &LogConfig::default(),
273 Path::new("/default/dir"),
274 );
275 assert_eq!(settings.level, None);
276 assert_eq!(settings.dir, PathBuf::from("/default/dir"));
277 assert_eq!(settings.format, LogFormat::Pretty);
278 assert_eq!(settings.rotation, Rotation::DAILY);
279 }
280
281 #[test]
293 fn init_tracing_with_stderr_covers_json_and_pretty_layers() {
294 let dir = tempfile::tempdir().unwrap();
295 let json_settings = LogSettings {
302 level: Some("off".to_string()),
303 dir: dir.path().to_path_buf(),
304 format: LogFormat::Json,
305 rotation: Rotation::NEVER,
306 };
307 let pretty_settings = LogSettings {
308 level: Some("off".to_string()),
309 dir: dir.path().to_path_buf(),
310 format: LogFormat::Pretty,
311 rotation: Rotation::DAILY,
312 };
313 let _ = init_tracing_with_stderr(&json_settings, true);
317 let _ = init_tracing_with_stderr(&pretty_settings, false);
318 }
319
320 #[test]
321 fn init_tracing_wrapper_detects_terminal() {
322 let dir = tempfile::tempdir().unwrap();
323 let settings = LogSettings {
325 level: Some("off".to_string()),
326 dir: dir.path().to_path_buf(),
327 format: LogFormat::Pretty,
328 rotation: Rotation::NEVER,
329 };
330 let _ = init_tracing(&settings);
331 }
332}