Skip to main content

mold_server/
logging.rs

1use std::path::{Path, PathBuf};
2
3use mold_core::LoggingConfig;
4use tracing_appender::non_blocking::WorkerGuard;
5use tracing_subscriber::prelude::*;
6use tracing_subscriber::EnvFilter;
7
8/// Holds the non-blocking file writer guard. Must live for the duration of the program.
9pub struct LogGuard {
10    _file_guard: Option<WorkerGuard>,
11}
12
13/// Initialize tracing with optional file logging and stderr output.
14pub fn init_tracing(
15    log_file: bool,
16    json: bool,
17    config: &LoggingConfig,
18    default_level: &str,
19    log_dir: PathBuf,
20) -> LogGuard {
21    let filter_str = resolve_filter(config, default_level);
22    let file_enabled = log_file || config.file;
23
24    let mut file_guard: Option<WorkerGuard> = None;
25
26    if file_enabled {
27        let _ = std::fs::create_dir_all(&log_dir);
28        cleanup_old_logs(&log_dir, config.max_days);
29        let appender = match tracing_appender::rolling::RollingFileAppender::builder()
30            .rotation(tracing_appender::rolling::Rotation::DAILY)
31            .filename_prefix("mold-server")
32            .filename_suffix("log")
33            .build(&log_dir)
34        {
35            Ok(a) => a,
36            Err(e) => {
37                eprintln!("warning: failed to create log file appender: {e}");
38                eprintln!("warning: file logging disabled, continuing with stderr only");
39                let filter = make_filter(&filter_str, default_level);
40                tracing_subscriber::fmt()
41                    .with_env_filter(filter)
42                    .with_writer(std::io::stderr)
43                    .init();
44                return LogGuard { _file_guard: None };
45            }
46        };
47        let (non_blocking, guard) = tracing_appender::non_blocking(appender);
48        file_guard = Some(guard);
49
50        let stderr_filter = make_filter(&filter_str, default_level);
51        let file_filter = make_filter(&filter_str, default_level);
52
53        let stderr_layer = tracing_subscriber::fmt::layer().with_writer(std::io::stderr);
54        let file_layer = tracing_subscriber::fmt::layer()
55            .with_writer(non_blocking)
56            .with_ansi(false);
57
58        tracing_subscriber::registry()
59            .with(stderr_layer.with_filter(stderr_filter))
60            .with(file_layer.with_filter(file_filter))
61            .init();
62    } else if json {
63        let filter = make_filter(&filter_str, default_level);
64        tracing_subscriber::fmt()
65            .with_env_filter(filter)
66            .with_writer(std::io::stderr)
67            .json()
68            .init();
69    } else {
70        let filter = make_filter(&filter_str, default_level);
71        tracing_subscriber::fmt()
72            .with_env_filter(filter)
73            .with_writer(std::io::stderr)
74            .init();
75    }
76
77    LogGuard {
78        _file_guard: file_guard,
79    }
80}
81
82/// Initialize tracing with file-only output (no stderr).
83///
84/// Used by the TUI which owns the terminal and cannot have tracing
85/// output on stderr.
86pub fn init_tracing_file_only(
87    config: &LoggingConfig,
88    default_level: &str,
89    log_dir: PathBuf,
90) -> LogGuard {
91    let filter_str = resolve_filter(config, default_level);
92
93    let _ = std::fs::create_dir_all(&log_dir);
94    cleanup_old_logs(&log_dir, config.max_days);
95    let appender = match tracing_appender::rolling::RollingFileAppender::builder()
96        .rotation(tracing_appender::rolling::Rotation::DAILY)
97        .filename_prefix("mold-tui")
98        .filename_suffix("log")
99        .build(&log_dir)
100    {
101        Ok(a) => a,
102        Err(e) => {
103            eprintln!("warning: failed to create TUI log file appender: {e}");
104            // Register a no-op subscriber so tracing macros are silently
105            // discarded rather than leaving the global subscriber unset.
106            let _ = tracing::subscriber::set_global_default(tracing_subscriber::registry());
107            return LogGuard { _file_guard: None };
108        }
109    };
110    let (non_blocking, guard) = tracing_appender::non_blocking(appender);
111
112    let filter = make_filter(&filter_str, default_level);
113    tracing_subscriber::fmt()
114        .with_env_filter(filter)
115        .with_writer(non_blocking)
116        .with_ansi(false)
117        .init();
118
119    LogGuard {
120        _file_guard: Some(guard),
121    }
122}
123
124pub(crate) fn resolve_filter(config: &LoggingConfig, default_level: &str) -> String {
125    std::env::var("MOLD_LOG").unwrap_or_else(|_| {
126        if config.level.is_empty() {
127            default_level.to_string()
128        } else {
129            config.level.clone()
130        }
131    })
132}
133
134pub(crate) fn make_filter(filter_str: &str, default_level: &str) -> EnvFilter {
135    EnvFilter::try_new(filter_str).unwrap_or_else(|_| EnvFilter::new(default_level))
136}
137
138/// Delete log files older than `max_days` from the log directory.
139pub(crate) fn cleanup_old_logs(log_dir: &Path, max_days: u32) {
140    let now = std::time::SystemTime::now();
141    let max_age = std::time::Duration::from_secs(max_days as u64 * 86400);
142
143    let entries = match std::fs::read_dir(log_dir) {
144        Ok(e) => e,
145        Err(_) => return,
146    };
147
148    for entry in entries.filter_map(|e| e.ok()) {
149        let path = entry.path();
150        if !path.is_file() {
151            continue;
152        }
153
154        let filename = path
155            .file_name()
156            .and_then(|f| f.to_str())
157            .unwrap_or_default();
158
159        // Match both old (no .log) and new (.log suffix) patterns
160        let is_mold_log = filename.starts_with("mold-server.") || filename.starts_with("mold-tui.");
161        if !is_mold_log {
162            continue;
163        }
164
165        if let Ok(metadata) = path.metadata() {
166            if let Ok(modified) = metadata.modified() {
167                if let Ok(age) = now.duration_since(modified) {
168                    if age > max_age {
169                        let _ = std::fs::remove_file(&path);
170                    }
171                }
172            }
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use std::fs;
181    use std::time::{Duration, SystemTime};
182
183    // Process-global lock so the three resolve_filter cases below don't
184    // race each other on `MOLD_LOG` when cargo test runs them in parallel.
185    // Single bundled test walks the env→config→default precedence chain;
186    // splitting into separate tests would expose the env-var mutation to
187    // interleaving inside the same process.
188    #[test]
189    fn resolve_filter_precedence_chain() {
190        let prev = std::env::var("MOLD_LOG").ok();
191
192        // 1. MOLD_LOG env var wins over both config and default.
193        std::env::set_var("MOLD_LOG", "trace");
194        let cfg = LoggingConfig {
195            level: "warn".to_string(),
196            file: false,
197            dir: None,
198            max_days: 7,
199        };
200        assert_eq!(resolve_filter(&cfg, "info"), "trace");
201
202        // 2. With env unset, config.level is used.
203        std::env::remove_var("MOLD_LOG");
204        let cfg = LoggingConfig {
205            level: "debug".to_string(),
206            file: false,
207            dir: None,
208            max_days: 7,
209        };
210        assert_eq!(resolve_filter(&cfg, "info"), "debug");
211
212        // 3. With env unset and config.level empty, the default fires.
213        let cfg = LoggingConfig {
214            level: String::new(),
215            file: false,
216            dir: None,
217            max_days: 7,
218        };
219        assert_eq!(resolve_filter(&cfg, "info"), "info");
220
221        match prev {
222            Some(v) => std::env::set_var("MOLD_LOG", v),
223            None => std::env::remove_var("MOLD_LOG"),
224        }
225    }
226
227    #[test]
228    fn make_filter_accepts_both_valid_and_invalid_input_without_panicking() {
229        // Valid directive — try_new succeeds, no fallback.
230        let _ = make_filter("mold=trace,hyper=warn", "info");
231        // EnvFilter is forgiving, but truly malformed input still fails
232        // try_new — exercising the unwrap_or_else branch.
233        let _ = make_filter("@@@@@@", "warn");
234        let _ = make_filter("invalid=garbage=syntax", "info");
235    }
236
237    #[test]
238    fn cleanup_old_logs_deletes_aged_mold_files_and_keeps_fresh_ones() {
239        let dir = tempfile::tempdir().expect("tempdir");
240        let dir_path = dir.path();
241
242        let aged = dir_path.join("mold-server.2020-01-01");
243        let aged_with_suffix = dir_path.join("mold-tui.2020-01-01.log");
244        let fresh = dir_path.join("mold-server.fresh.log");
245        let unrelated = dir_path.join("not-our-business.log");
246
247        for path in [&aged, &aged_with_suffix, &fresh, &unrelated] {
248            fs::write(path, b"x").expect("write fixture");
249        }
250
251        // Backdate the two "old" files so they exceed max_days.
252        let ancient = SystemTime::now() - Duration::from_secs(60 * 60 * 24 * 30); // 30 days ago
253        for path in [&aged, &aged_with_suffix] {
254            let f = fs::File::open(path).expect("open fixture");
255            f.set_modified(ancient).expect("backdate fixture");
256        }
257
258        cleanup_old_logs(dir_path, 7);
259
260        assert!(!aged.exists(), "30-day-old mold-server log must be removed");
261        assert!(
262            !aged_with_suffix.exists(),
263            "30-day-old mold-tui log must be removed",
264        );
265        assert!(fresh.exists(), "fresh mold-server log must be retained");
266        assert!(
267            unrelated.exists(),
268            "unrelated *.log file must be left untouched",
269        );
270    }
271
272    #[test]
273    fn cleanup_old_logs_returns_silently_for_missing_dir() {
274        let dir = tempfile::tempdir().expect("tempdir");
275        let missing = dir.path().join("does-not-exist");
276        // Should not panic — read_dir errors are absorbed by an early return.
277        cleanup_old_logs(&missing, 1);
278    }
279
280    #[test]
281    fn cleanup_old_logs_skips_subdirectories() {
282        let dir = tempfile::tempdir().expect("tempdir");
283        let dir_path = dir.path();
284        let subdir = dir_path.join("mold-server.subdir");
285        fs::create_dir(&subdir).expect("create subdir");
286        // Backdate so the age check would otherwise match.
287        let ancient = SystemTime::now() - Duration::from_secs(60 * 60 * 24 * 30);
288        let f = fs::File::open(&subdir).expect("open subdir");
289        // set_modified on a directory may fail on some filesystems — best-effort.
290        let _ = f.set_modified(ancient);
291
292        cleanup_old_logs(dir_path, 7);
293        assert!(
294            subdir.exists(),
295            "subdirectories must be skipped even when they pattern-match",
296        );
297    }
298}