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/` (canonical, sudo-aware path).
27pub fn log_dir() -> PathBuf {
28    crate::platform::environment::mlstack_logs_dir()
29}
30
31/// Fixed WARN ceiling for the stderr/stdout CONSOLE layers.
32///
33/// The console (stderr/stdout) layers must NEVER let `RUST_LOG` lower the
34/// threshold below WARN, because `main.rs` emits a per-line `tracing::info!`
35/// for every installer log line — an INFO threshold floods the console and
36/// corrupts machine-readable JSON/CI output. This builds a FIXED `EnvFilter`
37/// (never reads `RUST_LOG`) so the console ceiling cannot be bypassed by
38/// `RUST_LOG=rusty_stack=info`. The FILE layer keeps env-driven filtering
39/// (full DEBUG/INFO stream for live diagnostics).
40fn console_warn_ceiling() -> EnvFilter {
41    EnvFilter::new("rusty_stack=warn")
42}
43
44/// Initialize the dual-layer logging system.
45///
46/// Returns a guard that must be kept alive for the file logger to flush on drop.
47/// The `context` string is embedded in log entries to identify the operation
48/// (e.g., "update", "verify", "bench").
49///
50/// # File format
51///
52/// JSON lines with fields: `timestamp`, `level`, `span`, `message`, plus any
53/// structured fields from spans.
54///
55/// # Storage efficiency
56///
57/// Daily rotation with max 3 log files retained. Older files are pruned automatically
58/// by `tracing-appender`.
59pub fn init_logging(context: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
60    let log_path = log_dir();
61    if let Err(e) = std::fs::create_dir_all(&log_path) {
62        eprintln!(
63            "[WARN] Could not create log directory {}: {}",
64            log_path.display(),
65            e
66        );
67        // Fall back to stdout-only — fixed WARN ceiling (RUST_LOG cannot bypass).
68        let filter = console_warn_ceiling();
69        let stdout_layer = fmt::layer().with_target(false).with_filter(filter);
70        tracing_subscriber::registry().with(stdout_layer).init();
71        return None;
72    }
73
74    // File appender: daily rotation, max 3 files, prefix with context
75    let file_appender =
76        tracing_appender::rolling::daily(&log_path, format!("{}-rusty-stack", context));
77    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
78
79    // JSON file layer — structured for machine parsing
80    let file_layer = fmt::layer()
81        .json()
82        .with_timer(LocalTime::rfc_3339())
83        .with_writer(non_blocking)
84        .with_target(true)
85        .with_span_events(fmt::format::FmtSpan::CLOSE)
86        .with_filter(
87            EnvFilter::try_from_default_env()
88                .unwrap_or_else(|_| EnvFilter::new("rusty_stack=debug,info")),
89        );
90
91    // Stdout layer — compact human-readable for CLI progress. Fixed WARN
92    // ceiling (RUST_LOG cannot bypass) so the per-line installer INFO logs
93    // never flood stdout; the file layer captures the full stream.
94    let stdout_layer = fmt::layer()
95        .with_target(false)
96        .with_timer(fmt::time::LocalTime::new(
97            time::format_description::well_known::iso8601::Iso8601::DEFAULT,
98        ))
99        .with_filter(console_warn_ceiling());
100
101    tracing_subscriber::registry()
102        .with(file_layer)
103        .with(stdout_layer)
104        .init();
105
106    tracing::info!(
107        context = context,
108        log_dir = %log_path.display(),
109        "Logging initialized"
110    );
111
112    Some(guard)
113}
114
115/// Initialize logging for non-interactive/batch contexts.
116///
117/// Uses a simpler configuration: file logging + stderr (not stdout) so that
118/// machine-readable stdout output (JSON) is not polluted with log lines.
119pub fn init_batch_logging(context: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
120    let log_path = log_dir();
121    if let Err(e) = std::fs::create_dir_all(&log_path) {
122        eprintln!(
123            "[WARN] Could not create log directory {}: {}",
124            log_path.display(),
125            e
126        );
127        return None;
128    }
129
130    let file_appender =
131        tracing_appender::rolling::daily(&log_path, format!("{}-rusty-stack", context));
132    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
133
134    // File layer — full debug detail
135    let file_layer = fmt::layer()
136        .json()
137        .with_timer(LocalTime::rfc_3339())
138        .with_writer(non_blocking)
139        .with_target(true)
140        .with_span_events(fmt::format::FmtSpan::CLOSE)
141        .with_filter(
142            EnvFilter::try_from_default_env()
143                .unwrap_or_else(|_| EnvFilter::new("rusty_stack=debug")),
144        );
145
146    // Stderr layer — warnings and errors only (not stdout). FIXED WARN ceiling:
147    // main.rs emits a per-line `tracing::info!` for every installer log line, so
148    // an INFO threshold (whether via the default or `RUST_LOG=rusty_stack=info`)
149    // floods stderr on JSON/CI runs and corrupts machine-readable output.
150    // `console_warn_ceiling()` builds a fixed filter that `RUST_LOG` cannot
151    // bypass. The file layer captures the full INFO+/DEBUG+ stream.
152    let stderr_layer = fmt::layer()
153        .with_target(false)
154        .with_writer(std::io::stderr)
155        .with_filter(console_warn_ceiling());
156
157    tracing_subscriber::registry()
158        .with(file_layer)
159        .with(stderr_layer)
160        .init();
161
162    tracing::info!(
163        context = context,
164        log_dir = %log_path.display(),
165        "Batch logging initialized"
166    );
167
168    Some(guard)
169}
170
171/// Initialize logging for interactive contexts (the `update` command's inline
172/// selection + animated apply panel).
173///
174/// Stdout stays free of log lines (the ratatui `Viewport::Inline` panel owns
175/// it). Stderr is **WARN+ only** — the per-line `tracing::info!(log = …)` that
176/// the installer emits for every pip/git line goes to the FILE (full debug),
177/// not the terminal, so it can't flood around the panel. Without this, the
178/// interactive run reproduces the raw-log flood on stderr.
179pub fn init_interactive_logging(
180    context: &str,
181) -> Option<tracing_appender::non_blocking::WorkerGuard> {
182    let log_path = log_dir();
183    if let Err(e) = std::fs::create_dir_all(&log_path) {
184        eprintln!(
185            "[WARN] Could not create log directory {}: {}",
186            log_path.display(),
187            e
188        );
189        return None;
190    }
191
192    let file_appender =
193        tracing_appender::rolling::daily(&log_path, format!("{}-rusty-stack", context));
194    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
195
196    // File layer — full debug detail (every installer log line is captured here).
197    let file_layer = fmt::layer()
198        .json()
199        .with_timer(LocalTime::rfc_3339())
200        .with_writer(non_blocking)
201        .with_target(true)
202        .with_span_events(fmt::format::FmtSpan::CLOSE)
203        .with_filter(
204            EnvFilter::try_from_default_env()
205                .unwrap_or_else(|_| EnvFilter::new("rusty_stack=debug")),
206        );
207
208    // Stderr layer — WARN+ only (FIXED ceiling: `RUST_LOG` cannot bypass). The
209    // terminal is owned by the inline panel; the torrent of INFO installer-log
210    // lines must not bleed onto it.
211    let stderr_layer = fmt::layer()
212        .with_target(false)
213        .with_writer(std::io::stderr)
214        .with_filter(console_warn_ceiling());
215
216    tracing_subscriber::registry()
217        .with(file_layer)
218        .with(stderr_layer)
219        .init();
220
221    tracing::info!(
222        context = context,
223        log_dir = %log_path.display(),
224        "Interactive logging initialized"
225    );
226
227    Some(guard)
228}
229
230/// Get the path to the most recent log file for a given context.
231///
232/// Returns the log directory path if it exists, for user-facing messages.
233pub fn log_file_path(context: &str) -> PathBuf {
234    log_dir().join(format!("{}-rusty-stack", context))
235}
236
237/// Time format module for tracing-subscriber.
238mod time {
239    pub use time::format_description;
240}
241
242// ===========================================================================
243// Tests
244// ===========================================================================
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn test_log_dir_is_under_mlstack() {
252        let dir = log_dir();
253        assert!(dir.to_string_lossy().contains(".mlstack"));
254        assert!(dir.to_string_lossy().contains("logs"));
255    }
256
257    #[test]
258    fn test_log_file_path_contains_context() {
259        let path = log_file_path("update");
260        assert!(path.to_string_lossy().contains("update-rusty-stack"));
261    }
262}