Skip to main content

nex_core/
logging.rs

1use std::fs::{self, File, OpenOptions};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7pub const LOG_FILE_NAME: &str = "nex.log";
8const LEGACY_LOG_FILE_NAME: &str = "swiftfind.log";
9const MAX_LOG_BYTES: u64 = 1_000_000;
10const MAX_ARCHIVES: usize = 5;
11
12static LOGGER: OnceLock<Logger> = OnceLock::new();
13static PANIC_HOOK_INSTALLED: OnceLock<()> = OnceLock::new();
14
15struct Logger {
16    file: Mutex<File>,
17}
18
19pub fn logs_dir() -> PathBuf {
20    crate::config::stable_app_data_dir().join("logs")
21}
22
23pub fn primary_log_path() -> PathBuf {
24    logs_dir().join(LOG_FILE_NAME)
25}
26
27pub fn candidate_log_paths() -> Vec<PathBuf> {
28    let mut paths = vec![primary_log_path(), logs_dir().join(LEGACY_LOG_FILE_NAME)];
29    paths.sort();
30    paths.dedup();
31    paths
32}
33
34pub fn init() -> Result<(), std::io::Error> {
35    let log_dir = logs_dir();
36    fs::create_dir_all(&log_dir)?;
37    migrate_legacy_log_file(&log_dir)?;
38    let log_path = primary_log_path();
39    rotate_if_needed(&log_path, &log_dir)?;
40
41    let file = OpenOptions::new()
42        .create(true)
43        .append(true)
44        .open(&log_path)?;
45
46    let _ = LOGGER.set(Logger {
47        file: Mutex::new(file),
48    });
49
50    install_panic_hook();
51    Ok(())
52}
53
54pub fn info(message: &str) {
55    write_line("INFO", message);
56}
57
58pub fn warn(message: &str) {
59    write_line("WARN", message);
60}
61
62pub fn error(message: &str) {
63    write_line("ERROR", message);
64}
65
66pub fn open_logs_folder() -> Result<(), String> {
67    let dir = logs_dir();
68    fs::create_dir_all(&dir).map_err(|e| format!("failed to create logs dir: {e}"))?;
69
70    #[cfg(target_os = "windows")]
71    {
72        let target = dir.to_string_lossy().into_owned();
73        let status = std::process::Command::new("cmd")
74            .arg("/C")
75            .arg("start")
76            .arg("")
77            .arg(&target)
78            .status()
79            .map_err(|e| format!("failed to open logs folder: {e}"))?;
80        if !status.success() {
81            return Err(format!(
82                "failed to open logs folder; cmd/start exit status: {status}"
83            ));
84        }
85    }
86
87    #[cfg(not(target_os = "windows"))]
88    {
89        // Keep tests/platform-agnostic paths stable without requiring desktop integration.
90    }
91
92    Ok(())
93}
94
95fn write_line(level: &str, message: &str) {
96    let Some(logger) = LOGGER.get() else {
97        return;
98    };
99    let Ok(mut file) = logger.file.lock() else {
100        return;
101    };
102
103    let ts = now_secs();
104    let line = format!("[{ts}] [{level}] {message}\n");
105    let _ = file.write_all(line.as_bytes());
106    let _ = file.flush();
107}
108
109fn now_secs() -> u64 {
110    SystemTime::now()
111        .duration_since(UNIX_EPOCH)
112        .map(|d| d.as_secs())
113        .unwrap_or(0)
114}
115
116fn rotate_if_needed(log_path: &Path, log_dir: &Path) -> Result<(), std::io::Error> {
117    let meta = match fs::metadata(log_path) {
118        Ok(meta) => meta,
119        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
120        Err(err) => return Err(err),
121    };
122
123    if meta.len() < MAX_LOG_BYTES {
124        return Ok(());
125    }
126
127    let stamp = now_secs();
128    let archived = log_dir.join(format!("nex-{stamp}.log"));
129    fs::rename(log_path, archived)?;
130    prune_old_archives(log_dir)?;
131    Ok(())
132}
133
134fn migrate_legacy_log_file(log_dir: &Path) -> Result<(), std::io::Error> {
135    let legacy_path = log_dir.join(LEGACY_LOG_FILE_NAME);
136    let current_path = log_dir.join(LOG_FILE_NAME);
137    if !current_path.exists() && legacy_path.exists() {
138        fs::rename(legacy_path, current_path)?;
139    }
140    Ok(())
141}
142
143fn prune_old_archives(log_dir: &Path) -> Result<(), std::io::Error> {
144    let mut archives = fs::read_dir(log_dir)?
145        .filter_map(|entry| entry.ok())
146        .map(|entry| entry.path())
147        .filter(|path| {
148            path.file_name()
149                .and_then(|n| n.to_str())
150                .map(|n| n.starts_with("nex-") && n.ends_with(".log"))
151                .unwrap_or(false)
152        })
153        .collect::<Vec<_>>();
154
155    archives.sort();
156    while archives.len() > MAX_ARCHIVES {
157        if let Some(oldest) = archives.first() {
158            let _ = fs::remove_file(oldest);
159        }
160        archives.remove(0);
161    }
162    Ok(())
163}
164
165fn install_panic_hook() {
166    let _ = PANIC_HOOK_INSTALLED.get_or_init(|| {
167        let prior = std::panic::take_hook();
168        std::panic::set_hook(Box::new(move |panic_info| {
169            let location = panic_info
170                .location()
171                .map(|l| format!("{}:{}", l.file(), l.line()))
172                .unwrap_or_else(|| "unknown".to_string());
173            let payload = panic_info
174                .payload()
175                .downcast_ref::<&str>()
176                .map(|s| (*s).to_string())
177                .or_else(|| panic_info.payload().downcast_ref::<String>().cloned())
178                .unwrap_or_else(|| "panic payload unavailable".to_string());
179            error(&format!("panic at {location}: {payload}"));
180            prior(panic_info);
181        }));
182    });
183}
184
185#[cfg(test)]
186mod tests {
187    use super::logs_dir;
188
189    #[test]
190    fn logs_dir_uses_stable_app_data_layout() {
191        let dir = logs_dir();
192        let lowered = dir.to_string_lossy().to_ascii_lowercase();
193        assert!(lowered.contains("nex") || lowered.contains("swiftfind"));
194    }
195}