marco_core/logic/
logger.rs1#[cfg(feature = "file-logger")]
2use chrono::Local;
3#[cfg(feature = "file-logger")]
4use log::{Level, LevelFilter, Log, Metadata, Record};
5#[cfg(feature = "file-logger")]
6use std::boxed::Box;
7#[cfg(feature = "file-logger")]
8use std::fs::{self, File, OpenOptions};
9#[cfg(feature = "file-logger")]
10use std::io::{BufWriter, Write};
11#[cfg(feature = "file-logger")]
12use std::path::PathBuf;
13#[cfg(feature = "file-logger")]
14use std::sync::atomic::{AtomicU64, Ordering};
15#[cfg(feature = "file-logger")]
16use std::sync::{Mutex, OnceLock};
17
18#[cfg(feature = "file-logger")]
19static LOGGER: OnceLock<&'static SimpleFileLogger> = OnceLock::new();
20
21#[cfg(feature = "file-logger")]
23pub struct SimpleFileLogger {
24 inner: Mutex<Option<BufWriter<File>>>,
25 file_path: PathBuf,
26 level: LevelFilter,
27 bytes_written: AtomicU64,
28}
29
30#[cfg(feature = "file-logger")]
33const MAX_LOG_BYTES: u64 = 10 * 1024 * 1024; #[cfg(feature = "file-logger")]
36impl SimpleFileLogger {
37 pub fn init(enabled: bool, level: LevelFilter) -> Result<(), Box<dyn std::error::Error>> {
39 if !enabled {
40 log::set_max_level(LevelFilter::Off);
41 return Ok(());
42 }
43
44 let mut log_root: Option<PathBuf> = {
50 #[cfg(target_os = "windows")]
52 {
53 let mut root = if let Some(portable_root) = detect_portable_mode_windows() {
54 Some(portable_root.join("logs"))
55 } else {
56 None
57 };
58
59 if root.is_none() {
60 root = std::env::var_os("LOCALAPPDATA")
61 .map(|p| PathBuf::from(p).join("Marco").join("logs"));
62 }
63
64 if root.is_none() {
65 root = std::env::var_os("TEMP")
66 .map(|p| PathBuf::from(p).join("marco").join("logs"));
67 }
68
69 root
70 }
71
72 #[cfg(target_os = "linux")]
74 {
75 let mut root = std::env::var_os("XDG_CACHE_HOME")
76 .map(|p| PathBuf::from(p).join("marco").join("logs"));
77
78 if root.is_none() {
79 root = dirs::home_dir().map(|h| h.join(".cache").join("marco").join("logs"));
80 }
81
82 root
83 }
84
85 #[cfg(not(any(target_os = "windows", target_os = "linux")))]
87 {
88 None
89 }
90 };
91
92 if log_root.is_none() {
94 log_root = dirs::cache_dir().map(|c| c.join("marco").join("logs"));
95 }
96
97 let log_root = log_root.unwrap_or_else(|| PathBuf::from("/tmp/marco/log"));
98 fs::create_dir_all(&log_root)
99 .map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
100
101 let month_folder = Local::now().format("%Y%m").to_string();
103 let month_dir = log_root.join(month_folder);
104 fs::create_dir_all(&month_dir)
105 .map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
106 let file_name = Local::now().format("%y%m%d.log").to_string();
108 let file_path = month_dir.join(file_name);
109
110 let file = OpenOptions::new()
111 .create(true)
112 .append(true)
113 .open(&file_path)
114 .map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
115
116 let initial_size = file.metadata().map(|m| m.len()).unwrap_or(0);
117
118 let writer = BufWriter::new(file);
119
120 let boxed = Box::new(SimpleFileLogger {
121 inner: Mutex::new(Some(writer)),
122 file_path,
123 level,
124 bytes_written: AtomicU64::new(initial_size),
125 });
126
127 if LOGGER.get().is_some() {
129 log::set_max_level(level);
131 return Ok(());
132 }
133
134 let leaked: &'static SimpleFileLogger = Box::leak(boxed);
137
138 match log::set_logger(leaked) {
140 Ok(()) => {
141 let _ = LOGGER.set(leaked);
144 log::set_max_level(level);
145 Ok(())
146 }
147 Err(e) => {
148 unsafe {
150 let _ =
151 Box::from_raw(leaked as *const SimpleFileLogger as *mut SimpleFileLogger);
152 }
153 Err(format!("Failed to set global logger: {}", e).into())
155 }
156 }
157 }
158
159 fn rotate_if_needed_locked(&self, guard: &mut Option<BufWriter<File>>) {
160 let current = self.bytes_written.load(Ordering::Relaxed);
161 if current <= MAX_LOG_BYTES {
162 return;
163 }
164
165 if let Some(writer) = guard.as_mut() {
167 let _ = writer.flush();
168 }
169
170 *guard = None;
172
173 let ts = Local::now().format("%y%m%d-%H%M%S").to_string();
174 let rotated_path =
175 self.file_path
176 .with_file_name(format!("{}.rotated.{}.log", ts, std::process::id()));
177
178 if let Err(e) = fs::rename(&self.file_path, &rotated_path) {
179 eprintln!(
181 "[logger] rotation rename failed ({} -> {}): {}",
182 self.file_path.display(),
183 rotated_path.display(),
184 e
185 );
186 }
187
188 match OpenOptions::new()
189 .create(true)
190 .write(true)
191 .truncate(true)
192 .open(&self.file_path)
193 {
194 Ok(file) => {
195 *guard = Some(BufWriter::new(file));
196 self.bytes_written.store(0, Ordering::Relaxed);
197 }
198 Err(e) => {
199 eprintln!(
200 "[logger] failed to open new log file {}: {}",
201 self.file_path.display(),
202 e
203 );
204 }
205 }
206 }
207}
208
209#[cfg(feature = "file-logger")]
210impl Log for SimpleFileLogger {
211 fn enabled(&self, metadata: &Metadata) -> bool {
212 metadata.level() <= self.level.to_level().unwrap_or(Level::Trace)
214 }
215
216 fn log(&self, record: &Record) {
217 if !self.enabled(record.metadata()) {
218 return;
219 }
220 let ts = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
221
222 let message = format!("{}", record.args());
224
225 let sanitized_message = crate::logic::utf8::sanitize_input(
228 message.as_bytes(),
229 crate::logic::utf8::InputSource::Unknown,
230 );
231
232 let line = format!(
233 "{} [{}] {}: {}\n",
234 ts,
235 record.level(),
236 record.target(),
237 sanitized_message
238 );
239
240 let line_len = line.len() as u64;
243 self.bytes_written.fetch_add(line_len, Ordering::Relaxed);
244
245 if let Ok(mut guard) = self.inner.lock() {
246 self.rotate_if_needed_locked(&mut guard);
247 if let Some(ref mut writer) = *guard {
248 let _ = writer.write_all(line.as_bytes());
249
250 if record.level() <= Level::Error {
253 let _ = writer.flush();
254 }
255 }
256 }
257 }
258
259 fn flush(&self) {
260 if let Ok(mut guard) = self.inner.lock() {
261 if let Some(ref mut writer) = *guard {
262 let _ = writer.flush();
263 }
264 }
265 }
266}
267
268#[cfg(feature = "file-logger")]
270pub fn init_file_logger(
271 enabled: bool,
272 level: LevelFilter,
273) -> Result<(), Box<dyn std::error::Error>> {
274 SimpleFileLogger::init(enabled, level).map_err(|e| format!("{}", e).into())
275}
276
277#[cfg(feature = "file-logger")]
279pub fn is_file_logger_initialized() -> bool {
280 LOGGER.get().is_some()
281}
282
283#[cfg(feature = "file-logger")]
287pub fn current_log_root_dir() -> std::path::PathBuf {
288 if let Some(cache_dir) = dirs::cache_dir() {
290 return cache_dir.join("marco").join("logs");
291 }
292
293 #[cfg(target_os = "windows")]
295 {
296 std::path::PathBuf::from("C:\\Temp\\marco\\logs")
297 }
298 #[cfg(target_os = "linux")]
299 {
300 std::path::PathBuf::from("/tmp/marco/logs")
301 }
302}
303
304#[cfg(feature = "file-logger")]
306pub fn current_log_dir() -> std::path::PathBuf {
307 use chrono::Local;
308 let mut root = current_log_root_dir();
309 let month_folder = Local::now().format("%Y%m").to_string();
310 root.push(month_folder);
311 root
312}
313
314#[cfg(feature = "file-logger")]
317pub fn current_log_file_for_today() -> std::path::PathBuf {
318 use chrono::Local;
319 let dir = current_log_dir();
320 let file_name = Local::now().format("%y%m%d.log").to_string();
321 dir.join(file_name)
322}
323
324#[cfg(feature = "file-logger")]
326pub fn total_log_size_bytes() -> u64 {
327 use std::fs;
328 let root = current_log_root_dir();
329 let mut total: u64 = 0;
330 if root.exists() {
331 if let Ok(entries) = fs::read_dir(&root) {
333 for entry in entries.flatten() {
334 let path = entry.path();
335 if path.is_file() {
336 if let Ok(md) = entry.metadata() {
337 total += md.len();
338 }
339 } else if path.is_dir() {
340 if let Ok(subs) = fs::read_dir(&path) {
341 for s in subs.flatten() {
342 if let Ok(md) = s.metadata() {
343 if md.is_file() {
344 total += md.len();
345 }
346 }
347 }
348 }
349 }
350 }
351 }
352 }
353 total
354}
355
356#[cfg(feature = "file-logger")]
359pub fn delete_all_logs() -> Result<(), Box<dyn std::error::Error>> {
360 use std::fs;
361 let root = current_log_root_dir();
362 if !root.exists() {
363 return Ok(());
364 }
365
366 for entry in fs::read_dir(&root)? {
367 let entry = entry?;
368 let path = entry.path();
369 if path.is_file() {
370 let _ = fs::remove_file(&path);
371 } else if path.is_dir() {
372 for sub in fs::read_dir(&path)? {
373 let sub = sub?;
374 let subpath = sub.path();
375 if subpath.is_file() {
376 let _ = fs::remove_file(&subpath);
377 }
378 }
379 let _ = fs::remove_dir(&path);
381 }
382 }
383
384 if root.read_dir()?.next().is_none() {
386 let _ = fs::remove_dir(&root);
387 }
388
389 Ok(())
390}
391
392#[cfg(feature = "file-logger")]
393impl SimpleFileLogger {
394 pub fn shutdown(&self) {
396 if let Ok(mut guard) = self.inner.lock() {
397 if let Some(ref mut writer) = *guard {
398 let _ = writer.flush();
399 }
400 *guard = None;
402 }
403 }
404}
405
406#[cfg(feature = "file-logger")]
408pub fn shutdown_file_logger() {
409 if let Some(logger) = LOGGER.get() {
410 logger.shutdown();
411 }
415}
416
417#[inline]
431pub fn safe_preview(s: &str, max_chars: usize) -> String {
432 s.chars().take(max_chars).collect()
433}
434
435#[macro_export]
449macro_rules! safe_debug {
450 ($fmt:expr, $text:expr, $max:expr) => {
451 log::debug!($fmt, $crate::logic::logger::safe_preview($text, $max))
452 };
453 ($fmt:expr, $text:expr, $max:expr, $($arg:tt)*) => {
454 log::debug!($fmt, $crate::logic::logger::safe_preview($text, $max), $($arg)*)
455 };
456}
457
458#[cfg(target_os = "windows")]
465fn detect_portable_mode_windows() -> Option<PathBuf> {
466 let exe_path = std::env::current_exe().ok()?;
467 let exe_dir = exe_path.parent()?;
468
469 let portable_config = exe_dir.join("config");
470 if is_dir_writable(&portable_config) {
471 return Some(exe_dir.to_path_buf());
472 }
473 if is_dir_writable(exe_dir) {
474 return Some(exe_dir.to_path_buf());
475 }
476 None
477}
478
479#[cfg(target_os = "windows")]
480fn is_dir_writable(dir: &std::path::Path) -> bool {
481 use std::io::Write;
482 if !dir.exists() {
483 return false;
484 }
485 let test_file = dir.join(".marco_write_test");
486 std::fs::File::create(&test_file)
487 .and_then(|mut f| {
488 f.write_all(b"test")?;
489 f.sync_all()?;
490 std::fs::remove_file(&test_file)
491 })
492 .is_ok()
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 #[test]
500 fn smoke_test_safe_preview_ascii() {
501 assert_eq!(safe_preview("Hello, world!", 5), "Hello");
502 assert_eq!(safe_preview("Hi", 100), "Hi");
503 assert_eq!(safe_preview("", 10), "");
504 }
505
506 #[test]
507 fn smoke_test_safe_preview_multibyte_emoji() {
508 let s = "😀 café";
510 assert_eq!(safe_preview(s, 1), "😀");
511 assert_eq!(safe_preview(s, 3), "😀 c");
512 }
513
514 #[test]
515 fn smoke_test_safe_preview_zero_limit() {
516 assert_eq!(safe_preview("anything", 0), "");
517 }
518
519 #[cfg(feature = "file-logger")]
520 #[test]
521 fn smoke_test_is_file_logger_initialized_returns_bool() {
522 let _ = is_file_logger_initialized();
525 }
526
527 #[cfg(feature = "file-logger")]
528 #[test]
529 fn smoke_test_log_path_helpers_return_non_empty_paths() {
530 let root = current_log_root_dir();
532 assert!(
533 !root.as_os_str().is_empty(),
534 "log root dir must not be empty"
535 );
536
537 let dir = current_log_dir();
538 assert!(!dir.as_os_str().is_empty(), "log dir must not be empty");
539
540 let file = current_log_file_for_today();
541 assert!(
542 !file.as_os_str().is_empty(),
543 "log file path must not be empty"
544 );
545
546 assert!(
548 dir.starts_with(&root),
549 "current_log_dir() should be nested inside current_log_root_dir()"
550 );
551
552 assert!(
554 file.starts_with(&dir),
555 "current_log_file_for_today() should be inside current_log_dir()"
556 );
557 }
558
559 #[cfg(feature = "file-logger")]
560 #[test]
561 fn smoke_test_total_log_size_bytes_does_not_panic() {
562 let _size = total_log_size_bytes();
564 }
565}