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 }
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}