Skip to main content

rusty_stack/
logging.rs

1//! Logging infrastructure for Rusty Stack.
2//!
3//! Provides a dual-layer logging setup:
4//! - **File layer**: Structured JSON logs at `~/.mlstack/logs/` with daily rotation
5//!   (max 3 files retained for storage efficiency).
6//! - **Stdout layer**: Compact human-readable progress for CLI updates.
7//!
8//! # Usage
9//!
10//! ```ignore
11//! use rusty_stack::logging::init_logging;
12//!
13//! let _guard = init_logging("update");
14//! // tracing::info!, tracing::error!, etc. now work
15//! ```
16
17use std::path::PathBuf;
18use tracing_subscriber::{
19    filter::EnvFilter,
20    fmt::{self, time::LocalTime},
21    layer::SubscriberExt,
22    util::SubscriberInitExt,
23    Layer,
24};
25
26/// Log directory under `~/.mlstack/logs/`.
27pub fn log_dir() -> PathBuf {
28    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
29    PathBuf::from(home).join(".mlstack").join("logs")
30}
31
32/// Initialize the dual-layer logging system.
33///
34/// Returns a guard that must be kept alive for the file logger to flush on drop.
35/// The `context` string is embedded in log entries to identify the operation
36/// (e.g., "update", "verify", "bench").
37///
38/// # File format
39///
40/// JSON lines with fields: `timestamp`, `level`, `span`, `message`, plus any
41/// structured fields from spans.
42///
43/// # Storage efficiency
44///
45/// Daily rotation with max 3 log files retained. Older files are pruned automatically
46/// by `tracing-appender`.
47pub fn init_logging(context: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
48    let log_path = log_dir();
49    if let Err(e) = std::fs::create_dir_all(&log_path) {
50        eprintln!(
51            "[WARN] Could not create log directory {}: {}",
52            log_path.display(),
53            e
54        );
55        // Fall back to stdout-only
56        let filter = EnvFilter::try_from_default_env()
57            .unwrap_or_else(|_| EnvFilter::new("rusty_stack=info,warn"));
58        let stdout_layer = fmt::layer().with_target(false).with_filter(filter);
59        tracing_subscriber::registry().with(stdout_layer).init();
60        return None;
61    }
62
63    // File appender: daily rotation, max 3 files, prefix with context
64    let file_appender =
65        tracing_appender::rolling::daily(&log_path, format!("{}-rusty-stack", context));
66    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
67
68    // JSON file layer — structured for machine parsing
69    let file_layer = fmt::layer()
70        .json()
71        .with_timer(LocalTime::rfc_3339())
72        .with_writer(non_blocking)
73        .with_target(true)
74        .with_span_events(fmt::format::FmtSpan::CLOSE)
75        .with_filter(
76            EnvFilter::try_from_default_env()
77                .unwrap_or_else(|_| EnvFilter::new("rusty_stack=debug,info")),
78        );
79
80    // Stdout layer — compact human-readable for CLI progress
81    let stdout_layer = fmt::layer()
82        .with_target(false)
83        .with_timer(fmt::time::LocalTime::new(
84            time::format_description::well_known::iso8601::Iso8601::DEFAULT,
85        ))
86        .with_filter(
87            EnvFilter::try_from_default_env()
88                .unwrap_or_else(|_| EnvFilter::new("rusty_stack=info,warn")),
89        );
90
91    tracing_subscriber::registry()
92        .with(file_layer)
93        .with(stdout_layer)
94        .init();
95
96    tracing::info!(
97        context = context,
98        log_dir = %log_path.display(),
99        "Logging initialized"
100    );
101
102    Some(guard)
103}
104
105/// Initialize logging for non-interactive/batch contexts.
106///
107/// Uses a simpler configuration: file logging + stderr (not stdout) so that
108/// machine-readable stdout output (JSON) is not polluted with log lines.
109pub fn init_batch_logging(context: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
110    let log_path = log_dir();
111    if let Err(e) = std::fs::create_dir_all(&log_path) {
112        eprintln!(
113            "[WARN] Could not create log directory {}: {}",
114            log_path.display(),
115            e
116        );
117        return None;
118    }
119
120    let file_appender =
121        tracing_appender::rolling::daily(&log_path, format!("{}-rusty-stack", context));
122    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
123
124    // File layer — full debug detail
125    let file_layer = fmt::layer()
126        .json()
127        .with_timer(LocalTime::rfc_3339())
128        .with_writer(non_blocking)
129        .with_target(true)
130        .with_span_events(fmt::format::FmtSpan::CLOSE)
131        .with_filter(
132            EnvFilter::try_from_default_env()
133                .unwrap_or_else(|_| EnvFilter::new("rusty_stack=debug")),
134        );
135
136    // Stderr layer — warnings and errors only (not stdout)
137    let stderr_layer = fmt::layer()
138        .with_target(false)
139        .with_writer(std::io::stderr)
140        .with_filter(
141            EnvFilter::try_from_default_env()
142                .unwrap_or_else(|_| EnvFilter::new("rusty_stack=warn")),
143        );
144
145    tracing_subscriber::registry()
146        .with(file_layer)
147        .with(stderr_layer)
148        .init();
149
150    tracing::info!(
151        context = context,
152        log_dir = %log_path.display(),
153        "Batch logging initialized"
154    );
155
156    Some(guard)
157}
158
159/// Get the path to the most recent log file for a given context.
160///
161/// Returns the log directory path if it exists, for user-facing messages.
162pub fn log_file_path(context: &str) -> PathBuf {
163    log_dir().join(format!("{}-rusty-stack", context))
164}
165
166/// Time format module for tracing-subscriber.
167mod time {
168    pub use time::format_description;
169}
170
171// ===========================================================================
172// Tests
173// ===========================================================================
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn test_log_dir_is_under_mlstack() {
181        let dir = log_dir();
182        assert!(dir.to_string_lossy().contains(".mlstack"));
183        assert!(dir.to_string_lossy().contains("logs"));
184    }
185
186    #[test]
187    fn test_log_file_path_contains_context() {
188        let path = log_file_path("update");
189        assert!(path.to_string_lossy().contains("update-rusty-stack"));
190    }
191}