#[cfg(feature = "file-logger")]
use chrono::Local;
#[cfg(feature = "file-logger")]
use log::{Level, LevelFilter, Log, Metadata, Record};
#[cfg(feature = "file-logger")]
use std::boxed::Box;
#[cfg(feature = "file-logger")]
use std::fs::{self, File, OpenOptions};
#[cfg(feature = "file-logger")]
use std::io::{BufWriter, Write};
#[cfg(feature = "file-logger")]
use std::path::PathBuf;
#[cfg(feature = "file-logger")]
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(feature = "file-logger")]
use std::sync::{Mutex, OnceLock};
#[cfg(feature = "file-logger")]
static LOGGER: OnceLock<&'static SimpleFileLogger> = OnceLock::new();
#[cfg(feature = "file-logger")]
pub struct SimpleFileLogger {
inner: Mutex<Option<BufWriter<File>>>,
file_path: PathBuf,
level: LevelFilter,
bytes_written: AtomicU64,
}
#[cfg(feature = "file-logger")]
const MAX_LOG_BYTES: u64 = 10 * 1024 * 1024;
#[cfg(feature = "file-logger")]
impl SimpleFileLogger {
pub fn init(enabled: bool, level: LevelFilter) -> Result<(), Box<dyn std::error::Error>> {
if !enabled {
log::set_max_level(LevelFilter::Off);
return Ok(());
}
let mut log_root: Option<PathBuf> = {
#[cfg(target_os = "windows")]
{
let mut root = if let Some(portable_root) = detect_portable_mode_windows() {
Some(portable_root.join("logs"))
} else {
None
};
if root.is_none() {
root = std::env::var_os("LOCALAPPDATA")
.map(|p| PathBuf::from(p).join("Marco").join("logs"));
}
if root.is_none() {
root = std::env::var_os("TEMP")
.map(|p| PathBuf::from(p).join("marco").join("logs"));
}
root
}
#[cfg(target_os = "linux")]
{
let mut root = std::env::var_os("XDG_CACHE_HOME")
.map(|p| PathBuf::from(p).join("marco").join("logs"));
if root.is_none() {
root = dirs::home_dir().map(|h| h.join(".cache").join("marco").join("logs"));
}
root
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
None
}
};
if log_root.is_none() {
log_root = dirs::cache_dir().map(|c| c.join("marco").join("logs"));
}
let log_root = log_root.unwrap_or_else(|| PathBuf::from("/tmp/marco/log"));
fs::create_dir_all(&log_root)
.map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
let month_folder = Local::now().format("%Y%m").to_string();
let month_dir = log_root.join(month_folder);
fs::create_dir_all(&month_dir)
.map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
let file_name = Local::now().format("%y%m%d.log").to_string();
let file_path = month_dir.join(file_name);
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&file_path)
.map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
let initial_size = file.metadata().map(|m| m.len()).unwrap_or(0);
let writer = BufWriter::new(file);
let boxed = Box::new(SimpleFileLogger {
inner: Mutex::new(Some(writer)),
file_path,
level,
bytes_written: AtomicU64::new(initial_size),
});
if LOGGER.get().is_some() {
log::set_max_level(level);
return Ok(());
}
let leaked: &'static SimpleFileLogger = Box::leak(boxed);
match log::set_logger(leaked) {
Ok(()) => {
let _ = LOGGER.set(leaked);
log::set_max_level(level);
Ok(())
}
Err(e) => {
unsafe {
let _ =
Box::from_raw(leaked as *const SimpleFileLogger as *mut SimpleFileLogger);
}
Err(format!("Failed to set global logger: {}", e).into())
}
}
}
fn rotate_if_needed_locked(&self, guard: &mut Option<BufWriter<File>>) {
let current = self.bytes_written.load(Ordering::Relaxed);
if current <= MAX_LOG_BYTES {
return;
}
if let Some(writer) = guard.as_mut() {
let _ = writer.flush();
}
*guard = None;
let ts = Local::now().format("%y%m%d-%H%M%S").to_string();
let rotated_path =
self.file_path
.with_file_name(format!("{}.rotated.{}.log", ts, std::process::id()));
if let Err(e) = fs::rename(&self.file_path, &rotated_path) {
eprintln!(
"[logger] rotation rename failed ({} -> {}): {}",
self.file_path.display(),
rotated_path.display(),
e
);
}
match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&self.file_path)
{
Ok(file) => {
*guard = Some(BufWriter::new(file));
self.bytes_written.store(0, Ordering::Relaxed);
}
Err(e) => {
eprintln!(
"[logger] failed to open new log file {}: {}",
self.file_path.display(),
e
);
}
}
}
}
#[cfg(feature = "file-logger")]
impl Log for SimpleFileLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= self.level.to_level().unwrap_or(Level::Trace)
}
fn log(&self, record: &Record) {
if !self.enabled(record.metadata()) {
return;
}
let ts = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let message = format!("{}", record.args());
let sanitized_message = crate::logic::utf8::sanitize_input(
message.as_bytes(),
crate::logic::utf8::InputSource::Unknown,
);
let line = format!(
"{} [{}] {}: {}\n",
ts,
record.level(),
record.target(),
sanitized_message
);
let line_len = line.len() as u64;
self.bytes_written.fetch_add(line_len, Ordering::Relaxed);
if let Ok(mut guard) = self.inner.lock() {
self.rotate_if_needed_locked(&mut guard);
if let Some(ref mut writer) = *guard {
let _ = writer.write_all(line.as_bytes());
if record.level() <= Level::Error {
let _ = writer.flush();
}
}
}
}
fn flush(&self) {
if let Ok(mut guard) = self.inner.lock() {
if let Some(ref mut writer) = *guard {
let _ = writer.flush();
}
}
}
}
#[cfg(feature = "file-logger")]
pub fn init_file_logger(
enabled: bool,
level: LevelFilter,
) -> Result<(), Box<dyn std::error::Error>> {
SimpleFileLogger::init(enabled, level).map_err(|e| format!("{}", e).into())
}
#[cfg(feature = "file-logger")]
pub fn is_file_logger_initialized() -> bool {
LOGGER.get().is_some()
}
#[cfg(feature = "file-logger")]
pub fn current_log_root_dir() -> std::path::PathBuf {
if let Some(cache_dir) = dirs::cache_dir() {
return cache_dir.join("marco").join("logs");
}
#[cfg(target_os = "windows")]
{
std::path::PathBuf::from("C:\\Temp\\marco\\logs")
}
#[cfg(target_os = "linux")]
{
std::path::PathBuf::from("/tmp/marco/logs")
}
}
#[cfg(feature = "file-logger")]
pub fn current_log_dir() -> std::path::PathBuf {
use chrono::Local;
let mut root = current_log_root_dir();
let month_folder = Local::now().format("%Y%m").to_string();
root.push(month_folder);
root
}
#[cfg(feature = "file-logger")]
pub fn current_log_file_for_today() -> std::path::PathBuf {
use chrono::Local;
let dir = current_log_dir();
let file_name = Local::now().format("%y%m%d.log").to_string();
dir.join(file_name)
}
#[cfg(feature = "file-logger")]
pub fn total_log_size_bytes() -> u64 {
use std::fs;
let root = current_log_root_dir();
let mut total: u64 = 0;
if root.exists() {
if let Ok(entries) = fs::read_dir(&root) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Ok(md) = entry.metadata() {
total += md.len();
}
} else if path.is_dir() {
if let Ok(subs) = fs::read_dir(&path) {
for s in subs.flatten() {
if let Ok(md) = s.metadata() {
if md.is_file() {
total += md.len();
}
}
}
}
}
}
}
}
total
}
#[cfg(feature = "file-logger")]
pub fn delete_all_logs() -> Result<(), Box<dyn std::error::Error>> {
use std::fs;
let root = current_log_root_dir();
if !root.exists() {
return Ok(());
}
for entry in fs::read_dir(&root)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let _ = fs::remove_file(&path);
} else if path.is_dir() {
for sub in fs::read_dir(&path)? {
let sub = sub?;
let subpath = sub.path();
if subpath.is_file() {
let _ = fs::remove_file(&subpath);
}
}
let _ = fs::remove_dir(&path);
}
}
if root.read_dir()?.next().is_none() {
let _ = fs::remove_dir(&root);
}
Ok(())
}
#[cfg(feature = "file-logger")]
impl SimpleFileLogger {
pub fn shutdown(&self) {
if let Ok(mut guard) = self.inner.lock() {
if let Some(ref mut writer) = *guard {
let _ = writer.flush();
}
*guard = None;
}
}
}
#[cfg(feature = "file-logger")]
pub fn shutdown_file_logger() {
if let Some(logger) = LOGGER.get() {
logger.shutdown();
}
}
#[inline]
pub fn safe_preview(s: &str, max_chars: usize) -> String {
s.chars().take(max_chars).collect()
}
#[macro_export]
macro_rules! safe_debug {
($fmt:expr, $text:expr, $max:expr) => {
log::debug!($fmt, $crate::logic::logger::safe_preview($text, $max))
};
($fmt:expr, $text:expr, $max:expr, $($arg:tt)*) => {
log::debug!($fmt, $crate::logic::logger::safe_preview($text, $max), $($arg)*)
};
}
#[cfg(target_os = "windows")]
fn detect_portable_mode_windows() -> Option<PathBuf> {
let exe_path = std::env::current_exe().ok()?;
let exe_dir = exe_path.parent()?;
let portable_config = exe_dir.join("config");
if is_dir_writable(&portable_config) {
return Some(exe_dir.to_path_buf());
}
if is_dir_writable(exe_dir) {
return Some(exe_dir.to_path_buf());
}
None
}
#[cfg(target_os = "windows")]
fn is_dir_writable(dir: &std::path::Path) -> bool {
use std::io::Write;
if !dir.exists() {
return false;
}
let test_file = dir.join(".marco_write_test");
std::fs::File::create(&test_file)
.and_then(|mut f| {
f.write_all(b"test")?;
f.sync_all()?;
std::fs::remove_file(&test_file)
})
.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn smoke_test_safe_preview_ascii() {
assert_eq!(safe_preview("Hello, world!", 5), "Hello");
assert_eq!(safe_preview("Hi", 100), "Hi");
assert_eq!(safe_preview("", 10), "");
}
#[test]
fn smoke_test_safe_preview_multibyte_emoji() {
let s = "😀 café";
assert_eq!(safe_preview(s, 1), "😀");
assert_eq!(safe_preview(s, 3), "😀 c");
}
#[test]
fn smoke_test_safe_preview_zero_limit() {
assert_eq!(safe_preview("anything", 0), "");
}
#[cfg(feature = "file-logger")]
#[test]
fn smoke_test_is_file_logger_initialized_returns_bool() {
let _ = is_file_logger_initialized();
}
#[cfg(feature = "file-logger")]
#[test]
fn smoke_test_log_path_helpers_return_non_empty_paths() {
let root = current_log_root_dir();
assert!(
!root.as_os_str().is_empty(),
"log root dir must not be empty"
);
let dir = current_log_dir();
assert!(!dir.as_os_str().is_empty(), "log dir must not be empty");
let file = current_log_file_for_today();
assert!(
!file.as_os_str().is_empty(),
"log file path must not be empty"
);
assert!(
dir.starts_with(&root),
"current_log_dir() should be nested inside current_log_root_dir()"
);
assert!(
file.starts_with(&dir),
"current_log_file_for_today() should be inside current_log_dir()"
);
}
#[cfg(feature = "file-logger")]
#[test]
fn smoke_test_total_log_size_bytes_does_not_panic() {
let _size = total_log_size_bytes();
}
}