elemaudio_rs/
logging.rs

1//! Framework-level file logger.
2//!
3//! Writes to `~/Library/Logs/elemaudio-rs-plugin.log` on macOS,
4//! `$XDG_DATA_HOME/elemaudio-rs-plugin.log` on Linux, or
5//! `%APPDATA%/elemaudio-rs-plugin.log` on Windows.
6//!
7//! Call [`init`] once early in the plugin lifecycle (e.g., in the CLAP
8//! `new_shared` callback). Subsequent calls are no-ops.
9//!
10//! Uses the [`log`] facade — all `log::info!`, `log::error!`, etc. macros
11//! write to the file after init.
12//!
13//! **Realtime safety:** The logger acquires a `Mutex` on every log call.
14//! Do NOT use `log::*` macros on the audio thread.
15
16use std::fs::{self, File, OpenOptions};
17use std::io::Write;
18use std::path::PathBuf;
19use std::sync::{Mutex, Once};
20use std::time::SystemTime;
21
22use log::{LevelFilter, Log, Metadata, Record};
23
24static INIT: Once = Once::new();
25
26/// Initialize the file logger. Safe to call multiple times — only the
27/// first call has any effect.
28///
29/// Log file location:
30/// - macOS: `~/Library/Logs/elemaudio-rs-plugin.log`
31/// - Linux: `$XDG_DATA_HOME/elemaudio-rs-plugin.log` (or `~/.local/share/`)
32/// - Windows: `%APPDATA%/elemaudio-rs-plugin.log`
33pub fn init() {
34    INIT.call_once(|| {
35        if let Some(path) = log_file_path() {
36            if let Some(parent) = path.parent() {
37                let _ = fs::create_dir_all(parent);
38            }
39
40            let file = OpenOptions::new().create(true).append(true).open(&path);
41
42            match file {
43                Ok(f) => {
44                    let logger = FileLogger(Mutex::new(f));
45                    // Box::leak is intentional — the logger must live for
46                    // the entire process lifetime (log crate requirement).
47                    let leaked = Box::leak(Box::new(logger));
48                    let _ = log::set_logger(leaked);
49                    log::set_max_level(LevelFilter::Debug);
50                    log::info!("elemaudio-rs logger initialized: {}", path.display());
51                }
52                Err(e) => {
53                    eprintln!(
54                        "elemaudio-rs: failed to open log file {}: {e}",
55                        path.display()
56                    );
57                }
58            }
59        }
60    });
61}
62
63fn log_file_path() -> Option<PathBuf> {
64    #[cfg(target_os = "macos")]
65    {
66        dirs::home_dir().map(|h| h.join("Library/Logs/elemaudio-rs-plugin.log"))
67    }
68    #[cfg(target_os = "linux")]
69    {
70        dirs::data_dir()
71            .or_else(dirs::home_dir)
72            .map(|d| d.join("elemaudio-rs-plugin.log"))
73    }
74    #[cfg(target_os = "windows")]
75    {
76        dirs::data_dir().map(|d| d.join("elemaudio-rs-plugin.log"))
77    }
78    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
79    {
80        dirs::home_dir().map(|h| h.join("elemaudio-rs-plugin.log"))
81    }
82}
83
84struct FileLogger(Mutex<File>);
85
86impl Log for FileLogger {
87    fn enabled(&self, _metadata: &Metadata) -> bool {
88        true
89    }
90
91    fn log(&self, record: &Record) {
92        if !self.enabled(record.metadata()) {
93            return;
94        }
95        if let Ok(mut file) = self.0.lock() {
96            let timestamp = format_timestamp();
97            let _ = writeln!(
98                file,
99                "[{timestamp}] [{level}] {msg}",
100                level = record.level(),
101                msg = record.args(),
102            );
103        }
104    }
105
106    fn flush(&self) {
107        if let Ok(mut file) = self.0.lock() {
108            let _ = file.flush();
109        }
110    }
111}
112
113fn format_timestamp() -> String {
114    match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
115        Ok(d) => {
116            let secs = d.as_secs();
117            let millis = d.subsec_millis();
118            format!("{secs}.{millis:03}")
119        }
120        Err(_) => "0.000".to_string(),
121    }
122}