Skip to main content

bhtune_cli/
logging.rs

1//! Structured logging (`cli-logging`), matching `opcda-bridge-gateway`'s own `tracing`
2//! stack and `log.*` configuration conventions (level/directory/format/rotation, resolved
3//! with the same `CLI flag > env var > config file > default` precedence as every other
4//! bhtune setting) -- see `crate::config::LogConfig`/`resolve_log_settings`.
5//!
6//! **Deliberately never writes to stdout.** `bhtune tune`/`simulate --output json` prints a
7//! single machine-readable JSON object to stdout as its whole documented contract (see
8//! AGENTS.md's "Automation" section); mirroring diagnostic log lines onto that same stream
9//! (as `opcda-bridge-gateway`'s equivalent does, safely, since it owns stdout outright) would
10//! risk interleaving free-form log text into a stream a scheduler parses as JSON. Log lines
11//! go to the rotating file always, and to **stderr** (never stdout) when a console is
12//! attached -- stderr can never corrupt stdout's contract, so mirroring there is free.
13//!
14//! This is diagnostic/operational logging only: the CLI's actual product output (the tune
15//! summary, `history`/`export` listings) stays exactly what it already was, plain `println!`
16//! calls in `commands::*` -- unaffected by, and independent of, whatever this module does.
17
18use 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/// Log file format.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum LogFormat {
28    /// Human-readable, ANSI-free (log files aren't a terminal).
29    Pretty,
30    /// Newline-delimited JSON, for log shippers.
31    Json,
32}
33
34/// Parse the configured log format. Defaults to `Pretty` for `None` or any unrecognized
35/// value -- a config typo in `log.format` should degrade gracefully rather than stop a tune
36/// from running.
37pub 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
44/// Parse the configured rotation policy. Defaults to `DAILY` for `None` or any unrecognized
45/// value, for the same reason as [`parse_log_format`].
46pub 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
54/// Build an `EnvFilter` from an explicit level/directive spec (e.g. `"debug"` or
55/// `"bhtune_cli=debug,sqlx=warn"`), falling back to `info` if `level` is absent or fails to
56/// parse. Logging misconfiguration should degrade gracefully rather than stop a tune from
57/// running.
58pub 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/// Resolved logging settings, after applying `CLI flag > env var > config file > default`
65/// precedence to each individual field.
66#[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
74/// Resolve every logging setting. `cli_level` already has `RUST_LOG` folded in by clap's
75/// `env` attribute on `Cli::log_level`; `dir`/`format`/`rotation` have no env var, matching
76/// the rest of the config surface (see `crate::config`).
77pub 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
99/// Initialize the process-global tracing subscriber: a non-blocking rolling file writer
100/// under `settings.dir`, plus a stderr writer when a console is actually attached (a
101/// scheduled/cron invocation typically has none). Never touches stdout -- see the module
102/// doc comment.
103///
104/// Returns the `WorkerGuard`, which the caller **must** hold for the process lifetime --
105/// dropping it early silently truncates buffered log lines that haven't yet been flushed to
106/// disk on exit. Best-effort: setup failing (e.g. an unwritable log directory) is
107/// intentionally not fatal to the CLI's actual job (running a tune and printing its result),
108/// so callers other than this module's own tests should ignore the returned `Err` rather
109/// than propagate it -- see `lib.rs::run`.
110pub 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
115/// Same as [`init_tracing`], but with "is a console attached" passed in explicitly rather
116/// than detected, so tests can exercise both the stderr-attached and stderr-detached layer
117/// wiring deterministically.
118fn 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        // `EnvFilter` has no public equality check, so assert indirectly via Debug output,
207        // which includes the directive spec.
208        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        // "level=notalevel" isn't one of the recognized level names/numbers, so this is a
215        // genuine parse failure (unlike e.g. "not a valid directive!!", which `EnvFilter`
216        // happily accepts as a target-name filter with an implicit "trace" level).
217        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    // `tracing_subscriber`'s global subscriber can only be installed once per process, and
282    // `cargo test` runs every unit test in this crate in one shared process across multiple
283    // threads. Exactly one call to `try_init()` anywhere in this binary can succeed; every
284    // other call (in this module or any other) observes an error. `run_with_cli` (unlike
285    // `opcda-bridge-gateway`'s `run_gateway`) deliberately never calls `init_tracing` itself
286    // -- only `run()` does, which has no direct unit test of its own (see `lib.rs`) -- so
287    // these are the *only* in-process calls to `init_tracing`/`init_tracing_with_stderr` in
288    // the whole `cargo test` binary. The tests below therefore never assert `Ok`/`Err` on the
289    // *outcome* of installing -- only that every line up to and including that call actually
290    // runs, which is all the 100%-line-coverage gate requires.
291
292    #[test]
293    fn init_tracing_with_stderr_covers_json_and_pretty_layers() {
294        let dir = tempfile::tempdir().unwrap();
295        // "off" rather than e.g. "debug": whichever of these calls wins the one-per-process
296        // global-install race stays installed for the rest of this shared test binary's run,
297        // so a permissive level here would otherwise leak unrelated crates' (e.g. `sqlx`'s)
298        // own debug/info-level tracing spans onto stderr for every later test -- harmless to
299        // correctness, but noisy. "off" exercises the exact same layer-construction code
300        // paths while guaranteeing this test can never become a noisy winner.
301        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        // Exercise both the JSON+stderr-attached and Pretty+stderr-detached combinations so
314        // every layer-construction branch runs regardless of which call (if any) wins the
315        // global-install race.
316        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        // See the "off" rationale on `init_tracing_with_stderr_covers_json_and_pretty_layers`.
324        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}