Skip to main content

marco_core/logic/
logger.rs

1#[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/// File-backed logger implementation used by this crate.
22#[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// Keep log files reasonably sized so editors (and VS Code) can open them
31// without trying to load hundreds of MB into memory.
32#[cfg(feature = "file-logger")]
33const MAX_LOG_BYTES: u64 = 10 * 1024 * 1024; // 10 MiB
34
35#[cfg(feature = "file-logger")]
36impl SimpleFileLogger {
37    /// Initialize the global file logger.
38    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        // Use platform-appropriate cache directories for logs.
45        // **Windows Portable Mode**: {exe_dir}\logs\
46        // **Windows Installed Mode**: %LOCALAPPDATA%\<editor>\logs
47        // **Linux**: ~/.cache/<editor>/logs
48
49        let mut log_root: Option<PathBuf> = {
50            // Windows: portable mode + Windows-specific fallbacks.
51            #[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            // Linux: prefer XDG cache location.
73            #[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            // Other OSes: start with no platform-specific preference.
86            #[cfg(not(any(target_os = "windows", target_os = "linux")))]
87            {
88                None
89            }
90        };
91
92        // Generic (no cfg): if the OS provides a cache dir via `dirs`, use it.
93        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        // YYYYMM folder
102        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        // File name: YYMMDD.log
107        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 we already initialized our logger earlier in this process, behave idempotently
128        if LOGGER.get().is_some() {
129            // Update global max level and return success
130            log::set_max_level(level);
131            return Ok(());
132        }
133
134        // Leak the box temporarily to obtain a &'static reference required by log::set_logger.
135        // If another logger is already registered, gracefully abort and drop our boxed logger.
136        let leaked: &'static SimpleFileLogger = Box::leak(boxed);
137
138        // Attempt to register; if it fails, drop the leaked box and return Ok with a warning.
139        match log::set_logger(leaked) {
140            Ok(()) => {
141                // Successfully set our logger; record the static reference and apply level.
142                // OnceLock::set returns Err if already set, but we checked above, so this should always succeed
143                let _ = LOGGER.set(leaked);
144                log::set_max_level(level);
145                Ok(())
146            }
147            Err(e) => {
148                // Another logger is already present (e.g., env_logger). Drop our leaked box to avoid leaking memory.
149                unsafe {
150                    let _ =
151                        Box::from_raw(leaked as *const SimpleFileLogger as *mut SimpleFileLogger);
152                }
153                // Return an error to the caller so the application can decide how to surface it.
154                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        // Best-effort rotation: flush current writer, rename the file, start a new one.
166        if let Some(writer) = guard.as_mut() {
167            let _ = writer.flush();
168        }
169
170        // Drop writer so the underlying file handle is released before rename on Windows.
171        *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            // If rename fails (e.g. file missing), just continue with a new file.
180            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        // Always accept logs at the configured level or higher
213        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        // Format the log message
223        let message = format!("{}", record.args());
224
225        // Sanitize UTF-8 in log message to prevent panics from invalid slicing
226        // This protects against debug logs that slice strings at non-char boundaries
227        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        // Track size and rotate early if needed.
241        // Note: this is approximate (UTF-8 bytes). Good enough for keeping files small.
242        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                // Avoid flushing on every line (can stall UI).
251                // Flush eagerly only for high-severity events.
252                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/// Initialize file logging with enable flag and maximum log level.
269#[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/// Returns true if the file logger was successfully initialized by this library.
278#[cfg(feature = "file-logger")]
279pub fn is_file_logger_initialized() -> bool {
280    LOGGER.get().is_some()
281}
282
283/// Return the resolved root logs directory (no month folder). This is a
284/// non-negotiable platform-specific location using the system cache dir and
285/// the folder name `logs` per project policy.
286#[cfg(feature = "file-logger")]
287pub fn current_log_root_dir() -> std::path::PathBuf {
288    // Prefer OS cache dir when available, else fall back to a platform temp path
289    if let Some(cache_dir) = dirs::cache_dir() {
290        return cache_dir.join("marco").join("logs");
291    }
292
293    // Platform fallback (should be rare)
294    #[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/// Return the resolved log directory for the current month (YYYYMM folder).
305#[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/// Convenience: return the current log file path for today (YYMMDD.log) inside
315/// the resolved log directory.
316#[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/// Compute total size in bytes of all log files under the root logs directory.
325#[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        // Walk month folders and files
332        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/// Delete all logs under the root logs directory.
357/// Best-effort: removes files and month folders, returns error on I/O failures.
358#[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            // Try to remove the month folder if empty
380            let _ = fs::remove_dir(&path);
381        }
382    }
383
384    // Remove root if empty
385    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    /// Flush and close the inner file. After shutdown, the global LOGGER will be cleared.
395    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            // Drop the file by taking it out
401            *guard = None;
402        }
403    }
404}
405
406/// Public shutdown hook to safely flush and drop the global logger.
407#[cfg(feature = "file-logger")]
408pub fn shutdown_file_logger() {
409    if let Some(logger) = LOGGER.get() {
410        logger.shutdown();
411        // Note: OnceLock doesn't support clearing after initialization.
412        // The logger remains set but is shut down (file handle closed).
413        // This is acceptable for program shutdown.
414    }
415}
416
417/// Safe string preview for logging - truncates by character count, not bytes
418///
419/// This function safely truncates strings for debug logging without causing
420/// UTF-8 boundary panics. Use this instead of byte slicing in log statements.
421///
422/// # Examples
423/// ```
424/// use marco_core::logic::logger::safe_preview;
425///
426/// let text = "Hello 😀 World — test";
427/// let preview = safe_preview(text, 10); // Takes first 10 characters safely
428/// log::debug!("Parsing: {}", preview);
429/// ```
430#[inline]
431pub fn safe_preview(s: &str, max_chars: usize) -> String {
432    s.chars().take(max_chars).collect()
433}
434
435/// Macro for safe debug logging with automatic string truncation
436///
437/// Use this instead of `log::debug!()` when logging string slices that might
438/// contain multi-byte UTF-8 characters. It automatically truncates safely.
439///
440/// # Examples
441/// ```
442/// use marco_core::safe_debug;
443///
444/// let input = "Text with emoji 😀 and em dash —";
445/// safe_debug!("Parsing paragraph from: {:?}", input, 40);
446/// safe_debug!("Short preview: {:?}", input, 20);
447/// ```
448#[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// ---------------------------------------------------------------------------
459// Windows-only portable mode detection (inlined to avoid a `paths` dependency)
460// ---------------------------------------------------------------------------
461
462/// Detect Windows portable mode by checking for a writable `config/` directory
463/// next to the executable. Returns the exe directory if portable mode is active.
464#[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        // "😀" is a 4-byte character; safe_preview counts chars, not bytes
509        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        // Before any init call in this test process the result is either
523        // true (another test initialised it) or false — it must not panic.
524        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        // These functions compute deterministic paths and must not panic.
531        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        // log dir should be a subdirectory of root
547        assert!(
548            dir.starts_with(&root),
549            "current_log_dir() should be nested inside current_log_root_dir()"
550        );
551
552        // today's file should be inside the log dir
553        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        // May return 0 if no log files exist; must not panic regardless.
563        let _size = total_log_size_bytes();
564    }
565}