Skip to main content

marco_core/logic/
logger.rs

1use 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
19// Keep log files reasonably sized so editors (and VS Code) can open them
20// without trying to load hundreds of MB into memory.
21const MAX_LOG_BYTES: u64 = 10 * 1024 * 1024; // 10 MiB
22
23impl 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        // Use platform-appropriate cache directory for logs
31        // **Windows Portable Mode**: {exe_dir}\logs\
32        // **Windows Installed Mode**: %LOCALAPPDATA%\Marco\logs
33        // **Linux**: ~/.cache/marco/logs
34
35        let mut log_root: Option<PathBuf> = {
36            // Windows: portable mode + Windows-specific fallbacks.
37            #[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            // Linux: prefer XDG cache location.
59            #[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            // Other OSes: start with no platform-specific preference.
72            #[cfg(not(any(target_os = "windows", target_os = "linux")))]
73            {
74                None
75            }
76        };
77
78        // Generic (no cfg): if the OS provides a cache dir via `dirs`, use it.
79        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        // YYYYMM folder
88        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        // File name: YYMMDD.log
93        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 we already initialized our logger earlier in this process, behave idempotently
114        if LOGGER.get().is_some() {
115            // Update global max level and return success
116            log::set_max_level(level);
117            return Ok(());
118        }
119
120        // Leak the box temporarily to obtain a &'static reference required by log::set_logger.
121        // If another logger is already registered, gracefully abort and drop our boxed logger.
122        let leaked: &'static SimpleFileLogger = Box::leak(boxed);
123
124        // Attempt to register; if it fails, drop the leaked box and return Ok with a warning.
125        match log::set_logger(leaked) {
126            Ok(()) => {
127                // Successfully set our logger; record the static reference and apply level.
128                // OnceLock::set returns Err if already set, but we checked above, so this should always succeed
129                let _ = LOGGER.set(leaked);
130                log::set_max_level(level);
131                Ok(())
132            }
133            Err(e) => {
134                // Another logger is already present (e.g., env_logger). Drop our leaked box to avoid leaking memory.
135                unsafe {
136                    let _ =
137                        Box::from_raw(leaked as *const SimpleFileLogger as *mut SimpleFileLogger);
138                }
139                // Return an error to the caller so the application can decide how to surface it.
140                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        // Best-effort rotation: flush current writer, rename the file, start a new one.
152        if let Some(writer) = guard.as_mut() {
153            let _ = writer.flush();
154        }
155
156        // Drop writer so the underlying file handle is released before rename on Windows.
157        *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            // If rename fails (e.g. file missing), just continue with a new file.
166            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        // Always accept logs at the configured level or higher
198        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        // Format the log message
208        let message = format!("{}", record.args());
209
210        // Sanitize UTF-8 in log message to prevent panics from invalid slicing
211        // This protects against debug logs that slice strings at non-char boundaries
212        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        // Track size and rotate early if needed.
226        // Note: this is approximate (UTF-8 bytes). Good enough for keeping files small.
227        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                // Avoid flushing on every line (can stall UI).
236                // Flush eagerly only for high-severity events.
237                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
260/// Returns true if the file logger was successfully initialized by this library.
261pub fn is_file_logger_initialized() -> bool {
262    LOGGER.get().is_some()
263}
264
265/// Return the resolved root logs directory (no month folder). This is a
266/// non-negotiable platform-specific location using the system cache dir and
267/// the folder name `logs` per project policy.
268pub fn current_log_root_dir() -> std::path::PathBuf {
269    // Prefer OS cache dir when available, else fall back to a platform temp path
270    if let Some(cache_dir) = dirs::cache_dir() {
271        return cache_dir.join("marco").join("logs");
272    }
273
274    // Platform fallback (should be rare)
275    #[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
285/// Return the resolved log directory for the current month (YYYYMM folder).
286pub 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
294/// Convenience: return the current log file path for today (YYMMDD.log) inside
295/// the resolved log directory.
296pub 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
303/// Compute total size in bytes of all log files under the root logs directory.
304pub 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        // Walk month folders and files
310        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
334/// Delete all logs under the root logs directory.
335/// Best-effort: removes files and month folders, returns error on I/O failures.
336pub 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            // Try to remove the month folder if empty
357            let _ = fs::remove_dir(&path);
358        }
359    }
360
361    // Remove root if empty
362    if root.read_dir()?.next().is_none() {
363        let _ = fs::remove_dir(&root);
364    }
365
366    Ok(())
367}
368
369impl SimpleFileLogger {
370    /// Flush and close the inner file. After shutdown, the global LOGGER will be cleared.
371    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            // Drop the file by taking it out
377            *guard = None;
378        }
379    }
380}
381
382/// Public shutdown hook to safely flush and drop the global logger.
383pub fn shutdown_file_logger() {
384    if let Some(logger) = LOGGER.get() {
385        logger.shutdown();
386        // Note: OnceLock doesn't support clearing after initialization.
387        // The logger remains set but is shut down (file handle closed).
388        // This is acceptable for program shutdown.
389    }
390}
391
392/// Safe string preview for logging - truncates by character count, not bytes
393///
394/// This function safely truncates strings for debug logging without causing
395/// UTF-8 boundary panics. Use this instead of byte slicing in log statements.
396///
397/// # Examples
398/// ```
399/// use marco_core::logic::logger::safe_preview;
400///
401/// let text = "Hello 😀 World — test";
402/// let preview = safe_preview(text, 10); // Takes first 10 characters safely
403/// log::debug!("Parsing: {}", preview);
404/// ```
405#[inline]
406pub fn safe_preview(s: &str, max_chars: usize) -> String {
407    s.chars().take(max_chars).collect()
408}
409
410/// Macro for safe debug logging with automatic string truncation
411///
412/// Use this instead of `log::debug!()` when logging string slices that might
413/// contain multi-byte UTF-8 characters. It automatically truncates safely.
414///
415/// # Examples
416/// ```
417/// use marco_core::safe_debug;
418///
419/// let input = "Text with emoji 😀 and em dash —";
420/// safe_debug!("Parsing paragraph from: {:?}", input, 40);
421/// safe_debug!("Short preview: {:?}", input, 20);
422/// ```
423#[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// ---------------------------------------------------------------------------
434// Windows-only portable mode detection (inlined to avoid a `paths` dependency)
435// ---------------------------------------------------------------------------
436
437/// Detect Windows portable mode by checking for a writable `config/` directory
438/// next to the executable. Returns the exe directory if portable mode is active.
439#[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}