use std::path::PathBuf;
use tracing::Level;
use tracing_subscriber::fmt::time::FormatTime;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
struct LocalTime;
impl FormatTime for LocalTime {
fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {
let now = chrono::Local::now();
write!(w, "{}", now.format("%Y-%m-%dT%H:%M:%S%.6f%:z"))
}
}
pub const DEFAULT_LOG_PREFIX: &str = "opencrabs";
#[derive(Debug, Clone)]
pub struct LogConfig {
pub debug_mode: bool,
pub log_dir: PathBuf,
pub log_level: Level,
pub console_output: bool,
pub log_prefix: String,
pub max_age_days: u64,
}
impl Default for LogConfig {
fn default() -> Self {
Self {
debug_mode: false,
log_dir: crate::config::opencrabs_home().join("logs"),
log_level: Level::INFO,
console_output: false,
log_prefix: DEFAULT_LOG_PREFIX.to_string(),
max_age_days: 7,
}
}
}
impl LogConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_debug_mode(mut self, enabled: bool) -> Self {
self.debug_mode = enabled;
if enabled {
self.log_level = Level::DEBUG;
}
self
}
pub fn with_log_dir(mut self, dir: PathBuf) -> Self {
self.log_dir = dir;
self
}
pub fn with_log_level(mut self, level: Level) -> Self {
self.log_level = level;
self
}
pub fn with_console_output(mut self, enabled: bool) -> Self {
self.console_output = enabled;
self
}
pub fn with_log_prefix(mut self, prefix: String) -> Self {
self.log_prefix = prefix;
self
}
}
pub struct LoggerGuard;
impl LoggerGuard {
fn empty() -> Self {
Self
}
}
pub(crate) struct ResilientFileWriter {
log_dir: PathBuf,
prefix: String,
appender: std::sync::Mutex<tracing_appender::rolling::RollingFileAppender>,
}
impl ResilientFileWriter {
pub(crate) fn new(log_dir: PathBuf, prefix: String) -> Self {
let appender = tracing_appender::rolling::daily(&log_dir, &prefix);
Self {
log_dir,
prefix,
appender: std::sync::Mutex::new(appender),
}
}
}
impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for ResilientFileWriter {
type Writer = ResilientFileGuard<'a>;
fn make_writer(&'a self) -> Self::Writer {
ResilientFileGuard {
parent: self,
appender: self.appender.lock().unwrap_or_else(|e| e.into_inner()),
}
}
}
pub(crate) struct ResilientFileGuard<'a> {
parent: &'a ResilientFileWriter,
appender: std::sync::MutexGuard<'a, tracing_appender::rolling::RollingFileAppender>,
}
impl std::io::Write for ResilientFileGuard<'_> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let result = self.appender.write(buf);
if result.is_err() {
*self.appender =
tracing_appender::rolling::daily(&self.parent.log_dir, &self.parent.prefix);
}
result
}
fn flush(&mut self) -> std::io::Result<()> {
self.appender.flush()
}
}
pub fn init_logging(config: LogConfig) -> Result<LoggerGuard, Box<dyn std::error::Error>> {
if config.debug_mode {
init_debug_logging(config)
} else {
init_minimal_logging(config)
}
}
fn init_debug_logging(config: LogConfig) -> Result<LoggerGuard, Box<dyn std::error::Error>> {
std::fs::create_dir_all(&config.log_dir)?;
let opencrabs_dir = config.log_dir.parent().unwrap_or(&config.log_dir);
let gitignore_path = opencrabs_dir.join(".gitignore");
if !gitignore_path.exists() {
std::fs::write(
&gitignore_path,
"# Ignore all OpenCrabs runtime files\n*\n!.gitignore\n",
)
.ok();
}
let file_appender = ResilientFileWriter::new(config.log_dir.clone(), config.log_prefix.clone());
let env_filter = EnvFilter::from_default_env()
.add_directive(config.log_level.into())
.add_directive("rusqlite=warn".parse()?)
.add_directive("hyper=warn".parse()?)
.add_directive("h2=warn".parse()?)
.add_directive("reqwest=warn".parse()?)
.add_directive("tower=warn".parse()?)
.add_directive("slack_morphism=warn".parse()?)
.add_directive("whatsapp_rust::client=error".parse()?)
.add_directive("whatsapp_rust=warn".parse()?);
tracing_subscriber::registry()
.with(env_filter)
.with(
tracing_subscriber::fmt::layer()
.with_writer(file_appender)
.with_timer(LocalTime)
.with_ansi(false) .with_target(true)
.with_thread_ids(true)
.with_line_number(true)
.with_file(true),
)
.init();
tracing::info!("🚀 OpenCrabs debug mode enabled");
tracing::info!("📁 Log directory: {}", config.log_dir.display());
tracing::info!("📊 Log level: {:?}", config.log_level);
tracing::debug!("Debug logging initialized successfully");
Ok(LoggerGuard::empty())
}
fn init_minimal_logging(config: LogConfig) -> Result<LoggerGuard, Box<dyn std::error::Error>> {
let env_filter = EnvFilter::from_default_env()
.add_directive(Level::WARN.into()) .add_directive("opencrabs=info".parse()?);
if config.console_output {
tracing_subscriber::registry()
.with(env_filter)
.with(
tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_timer(LocalTime)
.with_ansi(true)
.with_target(false)
.compact(),
)
.init();
} else {
tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer().with_writer(std::io::sink))
.init();
}
Ok(LoggerGuard::empty())
}
pub fn setup_from_cli(debug: bool) -> Result<LoggerGuard, Box<dyn std::error::Error>> {
let config = LogConfig::new().with_debug_mode(debug);
init_logging(config)
}
pub fn log_dir() -> PathBuf {
if let Ok(dir) = std::env::var("DEBUG_LOGS_LOCATION") {
PathBuf::from(dir)
} else {
crate::config::opencrabs_home().join("logs")
}
}
pub fn is_log_file(file_name: &str) -> bool {
file_name
.strip_prefix(DEFAULT_LOG_PREFIX)
.is_some_and(|rest| rest.starts_with('.'))
}
pub fn get_log_path() -> Option<PathBuf> {
let dir = log_dir();
if !dir.exists() {
return None;
}
std::fs::read_dir(&dir)
.ok()?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_name().to_str().is_some_and(is_log_file))
.max_by_key(|entry| entry.metadata().ok()?.modified().ok())
.map(|entry| entry.path())
}
pub fn cleanup_old_logs(max_age_days: u64) -> Result<usize, Box<dyn std::error::Error>> {
let dir = log_dir();
if !dir.exists() {
return Ok(0);
}
let max_age = std::time::Duration::from_secs(max_age_days * 24 * 60 * 60);
let now = std::time::SystemTime::now();
let mut removed = 0;
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if entry.file_name().to_str().is_some_and(is_log_file)
&& let Ok(metadata) = entry.metadata()
&& let Ok(modified) = metadata.modified()
&& let Ok(age) = now.duration_since(modified)
&& age > max_age
&& std::fs::remove_file(&path).is_ok()
{
removed += 1;
}
}
Ok(removed)
}
pub fn cleanup_old_temp_files(max_age_days: u64) -> Result<usize, Box<dyn std::error::Error>> {
let home = match dirs::home_dir() {
Some(h) => h,
None => return Ok(0),
};
let tmp_dir = home.join(".opencrabs").join("tmp").join("files");
if !tmp_dir.exists() {
return Ok(0);
}
let max_age = std::time::Duration::from_secs(max_age_days * 24 * 60 * 60);
let now = std::time::SystemTime::now();
let mut removed = 0;
for entry in std::fs::read_dir(&tmp_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
if let Ok(metadata) = entry.metadata()
&& let Ok(modified) = metadata.modified()
&& let Ok(age) = now.duration_since(modified)
&& age > max_age
&& std::fs::remove_file(&path).is_ok()
{
removed += 1;
}
}
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_log_config_default() {
let config = LogConfig::default();
assert!(!config.debug_mode);
assert_eq!(config.log_level, Level::INFO);
assert!(!config.console_output);
assert_eq!(config.log_prefix, "opencrabs");
}
#[test]
fn test_log_config_with_debug() {
let config = LogConfig::new().with_debug_mode(true);
assert!(config.debug_mode);
assert_eq!(config.log_level, Level::DEBUG);
}
#[test]
fn test_log_config_builder() {
let config = LogConfig::new()
.with_log_level(Level::TRACE)
.with_console_output(true)
.with_log_prefix("test".to_string());
assert_eq!(config.log_level, Level::TRACE);
assert!(config.console_output);
assert_eq!(config.log_prefix, "test");
}
#[test]
fn test_log_dir_in_home_opencrabs_folder() {
let config = LogConfig::default();
let log_dir_str = config.log_dir.to_string_lossy();
assert!(log_dir_str.contains(".opencrabs"));
assert!(log_dir_str.contains("logs"));
if let Some(home) = dirs::home_dir() {
assert!(config.log_dir.starts_with(&home));
}
}
}