allora_core/
logging.rs

1//! Logging utilities (internal use).
2//!
3//! Provides a single helper (`init_from_dir`) that installs a `tracing` subscriber
4//! based on an optional `logging.yml` file. This is invoked automatically by the
5//! top-level builder; applications normally do not call it directly.
6//!
7//! # Configuration File: `logging.yml` (optional)
8//! Keys:
9//! * `filter` – full tracing filter expression (takes precedence over `level`)
10//! * `level`  – global fallback level (ignored if `filter` present)
11//! * `ansi`  – enable/disable colored output (default: true)
12//! * `format.with_timestamp` – show timestamps (true) or hide them (false);
13//!   omit for default (true)
14//!
15//! # Defaults (file absent or field omitted)
16//! * level: `info`
17//! * ansi: `true`
18//! * timestamp: `true`
19//!
20//! # Minimal Example
21//! ```yaml
22//! level: info
23//! ```
24//!
25//! # Filter Example
26//! ```yaml
27//! filter: info,mycrate::sub=debug
28//! ```
29//!
30//! # Hide Timestamps
31//! ```yaml
32//! filter: info
33//! format:
34//!   with_timestamp: false
35//! ```
36//!
37//! Unknown keys are ignored. Parse errors fall back to defaults. Diagnostics (successful initialization or existing subscriber) are emitted at `debug` level via the active tracing subscriber.
38//!
39//! # Diagnostics
40//! * On success, emits `debug!(...)` with details of the logging configuration.
41//! * If a subscriber is already installed, emits `debug!(...)` indicating this,
42//!   and uses the existing configuration.
43
44use serde::Deserialize;
45use std::fs;
46use std::path::{Path, PathBuf};
47use tracing::debug;
48use tracing_subscriber::EnvFilter;
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct LoggingSettings {
52    pub filter: String,
53    pub ansi: bool,
54    pub with_timestamp: bool,
55    pub source: String,
56}
57
58/// Configuration structure loaded from `logging.yml`.
59#[derive(Deserialize, Default)]
60struct LoggingConfig {
61    level: Option<String>,
62    ansi: Option<bool>,
63    filter: Option<String>,
64    format: Option<FormatConfig>,
65}
66
67#[derive(Deserialize, Default)]
68struct FormatConfig {
69    with_timestamp: Option<bool>,
70}
71
72fn select_config(preferred: &Path) -> (LoggingConfig, String) {
73    let candidate = preferred.join("logging.yml");
74    if candidate.exists() {
75        match fs::read_to_string(&candidate) {
76            Ok(txt) => (
77                serde_yaml::from_str(&txt).unwrap_or_default(),
78                candidate.display().to_string(),
79            ),
80            Err(err) => {
81                debug!(path=%candidate.display(), error=%err, "Failed to read logging.yml; using defaults");
82                (
83                    LoggingConfig::default(),
84                    format!("{} (read error, using defaults)", candidate.display()),
85                )
86            }
87        }
88    } else {
89        let cwd = PathBuf::from("logging.yml");
90        if cwd.exists() {
91            match fs::read_to_string(&cwd) {
92                Ok(txt) => (
93                    serde_yaml::from_str(&txt).unwrap_or_default(),
94                    cwd.display().to_string(),
95                ),
96                Err(err) => {
97                    debug!(path=%cwd.display(), error=%err, "Failed to read cwd logging.yml; using defaults");
98                    (
99                        LoggingConfig::default(),
100                        format!("{} (read error, using defaults)", cwd.display()),
101                    )
102                }
103            }
104        } else {
105            (LoggingConfig::default(), "default".to_string())
106        }
107    }
108}
109
110/// Load logging settings (filter, ansi, timestamp) without installing a subscriber.
111/// Public for testing.
112pub fn load_logging_settings(preferred: &Path) -> LoggingSettings {
113    let (raw_cfg, source) = select_config(preferred);
114    let filter = raw_cfg
115        .filter
116        .unwrap_or_else(|| raw_cfg.level.unwrap_or_else(|| "info".to_string()));
117    let ansi = raw_cfg.ansi.unwrap_or(true);
118    let with_timestamp = raw_cfg
119        .format
120        .as_ref()
121        .and_then(|f| f.with_timestamp)
122        .unwrap_or(true);
123    LoggingSettings {
124        filter,
125        ansi,
126        with_timestamp,
127        source,
128    }
129}
130
131/// Initialize tracing subscriber from a preferred directory or current working directory.
132///
133/// Search order:
134/// 1. `<preferred>/logging.yml`
135/// 2. `./logging.yml`
136/// 3. Defaults (info level, ANSI enabled, with timestamps)
137///
138/// # Arguments
139/// * `preferred` - Directory to search first (typically the config file's parent directory)
140///
141/// # Behavior
142/// * Uses `try_init()` - silently ignores if a subscriber is already installed
143/// * Emits a debug-level diagnostic line (via the tracing subscriber) on success or when already initialized (may be filtered)
144/// * Never panics
145pub fn init_from_dir(preferred: &Path) {
146    let settings = load_logging_settings(preferred);
147    let subscriber = tracing_subscriber::fmt()
148        .with_env_filter(EnvFilter::new(settings.filter.clone()))
149        .with_ansi(settings.ansi);
150    let result = if settings.with_timestamp {
151        subscriber.try_init()
152    } else {
153        subscriber.without_time().try_init()
154    };
155    if result.is_ok() {
156        debug!(target="allora::logging", source=%settings.source, filter=%settings.filter, timestamp=%settings.with_timestamp, ansi=%settings.ansi, "Logging initialized");
157    } else {
158        debug!(target="allora::logging", wanted_filter=%settings.filter, "Logging subscriber already set; using existing configuration");
159    }
160}