marco_core/logic/
logger.rs1use chrono::Local;
2use log::{Level, LevelFilter, Log, Metadata, Record};
3use std::boxed::Box;
4use std::fs::{self, File, OpenOptions};
5use std::io::{BufWriter, Write};
6use std::path::PathBuf;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::{Mutex, OnceLock};
9
10static LOGGER: OnceLock<&'static SimpleFileLogger> = OnceLock::new();
11
12pub struct SimpleFileLogger {
13 inner: Mutex<Option<BufWriter<File>>>,
14 file_path: PathBuf,
15 level: LevelFilter,
16 bytes_written: AtomicU64,
17}
18
19const MAX_LOG_BYTES: u64 = 10 * 1024 * 1024; impl SimpleFileLogger {
24 pub fn init(enabled: bool, level: LevelFilter) -> Result<(), Box<dyn std::error::Error>> {
25 if !enabled {
26 log::set_max_level(LevelFilter::Off);
27 return Ok(());
28 }
29
30 let mut log_root: Option<PathBuf> = {
36 #[cfg(target_os = "windows")]
38 {
39 let mut root = if let Some(portable_root) = detect_portable_mode_windows() {
40 Some(portable_root.join("logs"))
41 } else {
42 None
43 };
44
45 if root.is_none() {
46 root = std::env::var_os("LOCALAPPDATA")
47 .map(|p| PathBuf::from(p).join("Marco").join("logs"));
48 }
49
50 if root.is_none() {
51 root = std::env::var_os("TEMP")
52 .map(|p| PathBuf::from(p).join("marco").join("logs"));
53 }
54
55 root
56 }
57
58 #[cfg(target_os = "linux")]
60 {
61 let mut root = std::env::var_os("XDG_CACHE_HOME")
62 .map(|p| PathBuf::from(p).join("marco").join("logs"));
63
64 if root.is_none() {
65 root = dirs::home_dir().map(|h| h.join(".cache").join("marco").join("logs"));
66 }
67
68 root
69 }
70
71 #[cfg(not(any(target_os = "windows", target_os = "linux")))]
73 {
74 None
75 }
76 };
77
78 if log_root.is_none() {
80 log_root = dirs::cache_dir().map(|c| c.join("marco").join("logs"));
81 }
82
83 let log_root = log_root.unwrap_or_else(|| PathBuf::from("/tmp/marco/log"));
84 fs::create_dir_all(&log_root)
85 .map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
86
87 let month_folder = Local::now().format("%Y%m").to_string();
89 let month_dir = log_root.join(month_folder);
90 fs::create_dir_all(&month_dir)
91 .map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
92 let file_name = Local::now().format("%y%m%d.log").to_string();
94 let file_path = month_dir.join(file_name);
95
96 let file = OpenOptions::new()
97 .create(true)
98 .append(true)
99 .open(&file_path)
100 .map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
101
102 let initial_size = file.metadata().map(|m| m.len()).unwrap_or(0);
103
104 let writer = BufWriter::new(file);
105
106 let boxed = Box::new(SimpleFileLogger {
107 inner: Mutex::new(Some(writer)),
108 file_path,
109 level,
110 bytes_written: AtomicU64::new(initial_size),
111 });
112
113 if LOGGER.get().is_some() {
115 log::set_max_level(level);
117 return Ok(());
118 }
119
120 let leaked: &'static SimpleFileLogger = Box::leak(boxed);
123
124 match log::set_logger(leaked) {
126 Ok(()) => {
127 let _ = LOGGER.set(leaked);
130 log::set_max_level(level);
131 Ok(())
132 }
133 Err(e) => {
134 unsafe {
136 let _ =
137 Box::from_raw(leaked as *const SimpleFileLogger as *mut SimpleFileLogger);
138 }
139 Err(format!("Failed to set global logger: {}", e).into())
141 }
142 }
143 }
144
145 fn rotate_if_needed_locked(&self, guard: &mut Option<BufWriter<File>>) {
146 let current = self.bytes_written.load(Ordering::Relaxed);
147 if current <= MAX_LOG_BYTES {
148 return;
149 }
150
151 if let Some(writer) = guard.as_mut() {
153 let _ = writer.flush();
154 }
155
156 *guard = None;
158
159 let ts = Local::now().format("%y%m%d-%H%M%S").to_string();
160 let rotated_path =
161 self.file_path
162 .with_file_name(format!("{}.rotated.{}.log", ts, std::process::id()));
163
164 if let Err(e) = fs::rename(&self.file_path, &rotated_path) {
165 eprintln!(
167 "[logger] rotation rename failed ({} -> {}): {}",
168 self.file_path.display(),
169 rotated_path.display(),
170 e
171 );
172 }
173
174 match OpenOptions::new()
175 .create(true)
176 .write(true)
177 .truncate(true)
178 .open(&self.file_path)
179 {
180 Ok(file) => {
181 *guard = Some(BufWriter::new(file));
182 self.bytes_written.store(0, Ordering::Relaxed);
183 }
184 Err(e) => {
185 eprintln!(
186 "[logger] failed to open new log file {}: {}",
187 self.file_path.display(),
188 e
189 );
190 }
191 }
192 }
193}
194
195impl Log for SimpleFileLogger {
196 fn enabled(&self, metadata: &Metadata) -> bool {
197 metadata.level() <= self.level.to_level().unwrap_or(Level::Trace)
199 }
200
201 fn log(&self, record: &Record) {
202 if !self.enabled(record.metadata()) {
203 return;
204 }
205 let ts = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
206
207 let message = format!("{}", record.args());
209
210 let sanitized_message = crate::logic::utf8::sanitize_input(
213 message.as_bytes(),
214 crate::logic::utf8::InputSource::Unknown,
215 );
216
217 let line = format!(
218 "{} [{}] {}: {}\n",
219 ts,
220 record.level(),
221 record.target(),
222 sanitized_message
223 );
224
225 let line_len = line.len() as u64;
228 self.bytes_written.fetch_add(line_len, Ordering::Relaxed);
229
230 if let Ok(mut guard) = self.inner.lock() {
231 self.rotate_if_needed_locked(&mut guard);
232 if let Some(ref mut writer) = *guard {
233 let _ = writer.write_all(line.as_bytes());
234
235 if record.level() <= Level::Error {
238 let _ = writer.flush();
239 }
240 }
241 }
242 }
243
244 fn flush(&self) {
245 if let Ok(mut guard) = self.inner.lock() {
246 if let Some(ref mut writer) = *guard {
247 let _ = writer.flush();
248 }
249 }
250 }
251}
252
253pub fn init_file_logger(
254 enabled: bool,
255 level: LevelFilter,
256) -> Result<(), Box<dyn std::error::Error>> {
257 SimpleFileLogger::init(enabled, level).map_err(|e| format!("{}", e).into())
258}
259
260pub fn is_file_logger_initialized() -> bool {
262 LOGGER.get().is_some()
263}
264
265pub fn current_log_root_dir() -> std::path::PathBuf {
269 if let Some(cache_dir) = dirs::cache_dir() {
271 return cache_dir.join("marco").join("logs");
272 }
273
274 #[cfg(target_os = "windows")]
276 {
277 std::path::PathBuf::from("C:\\Temp\\marco\\logs")
278 }
279 #[cfg(target_os = "linux")]
280 {
281 std::path::PathBuf::from("/tmp/marco/logs")
282 }
283}
284
285pub fn current_log_dir() -> std::path::PathBuf {
287 use chrono::Local;
288 let mut root = current_log_root_dir();
289 let month_folder = Local::now().format("%Y%m").to_string();
290 root.push(month_folder);
291 root
292}
293
294pub fn current_log_file_for_today() -> std::path::PathBuf {
297 use chrono::Local;
298 let dir = current_log_dir();
299 let file_name = Local::now().format("%y%m%d.log").to_string();
300 dir.join(file_name)
301}
302
303pub fn total_log_size_bytes() -> u64 {
305 use std::fs;
306 let root = current_log_root_dir();
307 let mut total: u64 = 0;
308 if root.exists() {
309 if let Ok(entries) = fs::read_dir(&root) {
311 for entry in entries.flatten() {
312 let path = entry.path();
313 if path.is_file() {
314 if let Ok(md) = entry.metadata() {
315 total += md.len();
316 }
317 } else if path.is_dir() {
318 if let Ok(subs) = fs::read_dir(&path) {
319 for s in subs.flatten() {
320 if let Ok(md) = s.metadata() {
321 if md.is_file() {
322 total += md.len();
323 }
324 }
325 }
326 }
327 }
328 }
329 }
330 }
331 total
332}
333
334pub fn delete_all_logs() -> Result<(), Box<dyn std::error::Error>> {
337 use std::fs;
338 let root = current_log_root_dir();
339 if !root.exists() {
340 return Ok(());
341 }
342
343 for entry in fs::read_dir(&root)? {
344 let entry = entry?;
345 let path = entry.path();
346 if path.is_file() {
347 let _ = fs::remove_file(&path);
348 } else if path.is_dir() {
349 for sub in fs::read_dir(&path)? {
350 let sub = sub?;
351 let subpath = sub.path();
352 if subpath.is_file() {
353 let _ = fs::remove_file(&subpath);
354 }
355 }
356 let _ = fs::remove_dir(&path);
358 }
359 }
360
361 if root.read_dir()?.next().is_none() {
363 let _ = fs::remove_dir(&root);
364 }
365
366 Ok(())
367}
368
369impl SimpleFileLogger {
370 pub fn shutdown(&self) {
372 if let Ok(mut guard) = self.inner.lock() {
373 if let Some(ref mut writer) = *guard {
374 let _ = writer.flush();
375 }
376 *guard = None;
378 }
379 }
380}
381
382pub fn shutdown_file_logger() {
384 if let Some(logger) = LOGGER.get() {
385 logger.shutdown();
386 }
390}
391
392#[inline]
406pub fn safe_preview(s: &str, max_chars: usize) -> String {
407 s.chars().take(max_chars).collect()
408}
409
410#[macro_export]
424macro_rules! safe_debug {
425 ($fmt:expr, $text:expr, $max:expr) => {
426 log::debug!($fmt, $crate::logic::logger::safe_preview($text, $max))
427 };
428 ($fmt:expr, $text:expr, $max:expr, $($arg:tt)*) => {
429 log::debug!($fmt, $crate::logic::logger::safe_preview($text, $max), $($arg)*)
430 };
431}
432
433#[cfg(target_os = "windows")]
440fn detect_portable_mode_windows() -> Option<PathBuf> {
441 let exe_path = std::env::current_exe().ok()?;
442 let exe_dir = exe_path.parent()?;
443
444 let portable_config = exe_dir.join("config");
445 if is_dir_writable(&portable_config) {
446 return Some(exe_dir.to_path_buf());
447 }
448 if is_dir_writable(exe_dir) {
449 return Some(exe_dir.to_path_buf());
450 }
451 None
452}
453
454#[cfg(target_os = "windows")]
455fn is_dir_writable(dir: &std::path::Path) -> bool {
456 use std::io::Write;
457 if !dir.exists() {
458 return false;
459 }
460 let test_file = dir.join(".marco_write_test");
461 std::fs::File::create(&test_file)
462 .and_then(|mut f| {
463 f.write_all(b"test")?;
464 f.sync_all()?;
465 std::fs::remove_file(&test_file)
466 })
467 .is_ok()
468}