Skip to main content

ssh_mcp/
logging.rs

1//! Logging initialization and configuration
2//!
3//! This module provides functions to initialize logging based on CLI arguments.
4//! It supports multiple log levels, file logging with rotation, and different
5//! output formats (text or JSON).
6
7use std::fs::{self, OpenOptions};
8use std::path::Path;
9
10use tracing::info;
11use tracing_appender::{non_blocking, non_blocking::WorkerGuard, rolling};
12use tracing_subscriber::{EnvFilter, Registry, fmt, layer::SubscriberExt, util::SubscriberInitExt};
13
14use crate::config::Args;
15use crate::error::{Result, SshMcpError};
16
17/// Initialize logging based on configuration arguments.
18///
19/// This function sets up tracing subscribers with:
20/// - Environment-based log level filtering (falls back to `args.log_level`)
21/// - Stderr output in text format (stdout is reserved for MCP protocol)
22/// - Optional file logging with rotation support
23///
24/// # Returns
25///
26/// - `Ok(Some(WorkerGuard))` - File logging enabled, guard must be kept alive
27/// - `Ok(None)` - No file logging configured
28/// - `Err(SshMcpError)` - Configuration or IO error
29///
30/// # Note
31///
32/// The `WorkerGuard` must be stored for the entire program lifetime when file
33/// logging is enabled, or the background worker will be dropped and logs may be lost.
34pub fn init_logging(args: &Args) -> Result<Option<WorkerGuard>> {
35    // Create env filter (respect RUST_LOG, fall back to args.log_level)
36    let env_filter =
37        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level));
38
39    // Set up file layer if log_file is specified
40    let (file_writer, guard) = if let Some(log_file) = &args.log_file {
41        let (writer, worker_guard) = setup_file_logging(log_file, &args.log_rotation)?;
42        (Some(writer), Some(worker_guard))
43    } else {
44        (None, None)
45    };
46
47    // Build subscriber with stderr (text) and optionally file (text or JSON)
48    let registry = Registry::default().with(env_filter);
49
50    // Always add stderr layer (text format only - stdout reserved for MCP protocol)
51    let stderr_layer = fmt::layer().with_target(false).with_writer(std::io::stderr);
52
53    match (file_writer, args.log_format.as_str()) {
54        (Some(writer), "json") => {
55            // File with JSON format
56            let file_layer = fmt::layer().json().with_target(false).with_writer(writer);
57            registry.with(stderr_layer).with(file_layer).init();
58        }
59        (Some(writer), _) => {
60            // File with text format
61            let file_layer = fmt::layer().with_target(false).with_writer(writer);
62            registry.with(stderr_layer).with(file_layer).init();
63        }
64        (None, _) => {
65            // No file logging
66            registry.with(stderr_layer).init();
67        }
68    }
69
70    // Emit startup log message explaining configuration and file naming
71    if let Some(log_file) = &args.log_file {
72        let rotation_note = match args.log_rotation.as_str() {
73            "daily" => format!(" (actual file: {}.YYYY-MM-DD)", log_file.display()),
74            "hourly" => format!(" (actual file: {}.YYYY-MM-DD-HH)", log_file.display()),
75            _ => String::new(),
76        };
77        info!(
78            "Logging initialized: file={}, format={}, rotation={}{}",
79            log_file.display(),
80            args.log_format,
81            args.log_rotation,
82            rotation_note
83        );
84    } else {
85        info!("Logging initialized: stderr only, level={}", args.log_level);
86    }
87
88    Ok(guard)
89}
90
91/// Set up file logging with rotation configuration.
92///
93/// Returns a tuple of (non_blocking_writer, worker_guard) where:
94/// - `non_blocking_writer`: The non-blocking writer for the log file
95/// - `worker_guard`: Must be kept alive for the duration of the program
96fn setup_file_logging(
97    log_file: &Path,
98    rotation: &str,
99) -> Result<(non_blocking::NonBlocking, WorkerGuard)> {
100    // Determine directory and file name for rolling logs
101    let log_dir = log_file.parent().unwrap_or(Path::new(".")).to_path_buf();
102
103    // Ensure parent directory exists for file logging
104    if !log_dir.as_os_str().is_empty() && !log_dir.exists() {
105        fs::create_dir_all(&log_dir).map_err(|e| {
106            SshMcpError::Config(format!(
107                "Failed to create log directory {}: {}",
108                log_dir.display(),
109                e
110            ))
111        })?;
112    }
113
114    let log_name = log_file
115        .file_name()
116        .ok_or_else(|| SshMcpError::Config("Log file path has no file name".to_string()))?
117        .to_str()
118        .ok_or_else(|| SshMcpError::Config("Log file name is not valid UTF-8".to_string()))?;
119
120    // Create the appropriate appender and guard based on rotation strategy
121    match rotation {
122        "hourly" => {
123            let appender = rolling::hourly(&log_dir, log_name);
124            Ok(non_blocking(appender))
125        }
126        "never" => {
127            // Use OpenOptions with append(true) to avoid truncation and ensure file creation
128            let file = OpenOptions::new()
129                .create(true)
130                .append(true)
131                .open(log_file)
132                .map_err(|e| {
133                    SshMcpError::Config(format!(
134                        "Failed to open log file {}: {}",
135                        log_file.display(),
136                        e
137                    ))
138                })?;
139            Ok(non_blocking(file))
140        }
141        _ => {
142            // daily (default)
143            let appender = rolling::daily(&log_dir, log_name);
144            Ok(non_blocking(appender))
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use tempfile::TempDir;
153
154    #[test]
155    fn test_setup_file_logging_creates_dirs() {
156        let temp_dir = TempDir::new().unwrap();
157        let log_file = temp_dir.path().join("a").join("b").join("test.log");
158
159        let result = setup_file_logging(&log_file, "never");
160        assert!(result.is_ok());
161
162        assert!(log_file.parent().unwrap().exists());
163        assert!(log_file.exists());
164    }
165
166    #[test]
167    fn test_setup_file_logging_append_mode() {
168        let temp_dir = TempDir::new().unwrap();
169        let log_file = temp_dir.path().join("test.log");
170
171        // Create file with some content
172        std::fs::write(&log_file, "initial content\n").unwrap();
173
174        {
175            let (_writer, _guard) = setup_file_logging(&log_file, "never").unwrap();
176            // Writer is non-blocking, but we just want to check if it truncated
177        }
178
179        let contents = std::fs::read_to_string(&log_file).unwrap();
180        assert_eq!(contents, "initial content\n");
181    }
182}