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}