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