use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use tracing::Level;
use tracing_subscriber::fmt::time::FormatTime;
use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt};
static DEBUG_LOGS_ENABLED: AtomicBool = AtomicBool::new(false);
static DEBUG_FORCED_BY_FLAG: AtomicBool = AtomicBool::new(false);
pub fn effective_debug_logs(forced_by_flag: bool, config_enabled: bool) -> bool {
forced_by_flag || config_enabled
}
pub fn debug_logs_enabled() -> bool {
DEBUG_LOGS_ENABLED.load(Ordering::Relaxed)
}
fn set_debug_logs(enabled: bool) {
let previous = DEBUG_LOGS_ENABLED.swap(enabled, Ordering::Relaxed);
if previous != enabled {
tracing::info!(
"🔧 debug_logs {} via config",
if enabled { "ENABLED" } else { "DISABLED" }
);
}
}
pub fn apply_debug_logs(forced_by_flag: bool, config_enabled: bool) {
DEBUG_FORCED_BY_FLAG.store(forced_by_flag, Ordering::Relaxed);
set_debug_logs(effective_debug_logs(forced_by_flag, config_enabled));
}
pub fn apply_debug_logs_from_config(config_enabled: bool) {
let forced = DEBUG_FORCED_BY_FLAG.load(Ordering::Relaxed);
set_debug_logs(effective_debug_logs(forced, config_enabled));
}
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<Option<tracing_appender::rolling::RollingFileAppender>>,
}
impl ResilientFileWriter {
pub(crate) fn new(log_dir: PathBuf, prefix: String) -> Self {
Self {
log_dir,
prefix,
appender: std::sync::Mutex::new(None),
}
}
fn build(log_dir: &PathBuf, prefix: &str) -> tracing_appender::rolling::RollingFileAppender {
if std::fs::create_dir_all(log_dir).is_ok() {
let gitignore_path = log_dir
.parent()
.unwrap_or(log_dir.as_path())
.join(".gitignore");
if !gitignore_path.exists() {
std::fs::write(
&gitignore_path,
"# Ignore all OpenCrabs runtime files\n*\n!.gitignore\n",
)
.ok();
}
}
tracing_appender::rolling::daily(log_dir, prefix)
}
}
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, Option<tracing_appender::rolling::RollingFileAppender>>,
}
impl std::io::Write for ResilientFileGuard<'_> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.appender.is_none() {
*self.appender = Some(ResilientFileWriter::build(
&self.parent.log_dir,
&self.parent.prefix,
));
}
let appender = self
.appender
.as_mut()
.expect("appender was just materialized");
let result = appender.write(buf);
if result.is_err() {
*self.appender = Some(ResilientFileWriter::build(
&self.parent.log_dir,
&self.parent.prefix,
));
}
result
}
fn flush(&mut self) -> std::io::Result<()> {
match self.appender.as_mut() {
Some(appender) => appender.flush(),
None => Ok(()),
}
}
}
pub fn init_logging(config: LogConfig) -> Result<LoggerGuard, Box<dyn std::error::Error>> {
use tracing_subscriber::filter::filter_fn;
use tracing_subscriber::fmt::writer::BoxMakeWriter;
DEBUG_LOGS_ENABLED.store(config.debug_mode, Ordering::Relaxed);
DEBUG_FORCED_BY_FLAG.store(config.debug_mode, Ordering::Relaxed);
let file_env = EnvFilter::from_default_env()
.add_directive(Level::DEBUG.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()?)
.add_directive("Client=warn".parse()?)
.add_directive("UnifiedSession=warn".parse()?);
let file_appender = ResilientFileWriter::new(config.log_dir.clone(), config.log_prefix.clone());
let file_layer = 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)
.with_filter(file_env)
.with_filter(filter_fn(|_meta| debug_logs_enabled()));
let console_env = EnvFilter::from_default_env()
.add_directive(Level::WARN.into())
.add_directive("opencrabs=info".parse()?);
let console_writer: BoxMakeWriter = if config.console_output {
BoxMakeWriter::new(std::io::stderr)
} else {
BoxMakeWriter::new(std::io::sink)
};
let console_layer = tracing_subscriber::fmt::layer()
.with_writer(console_writer)
.with_timer(LocalTime)
.with_ansi(config.console_output)
.with_target(false)
.compact()
.with_filter(console_env);
tracing_subscriber::registry()
.with(file_layer)
.with(console_layer)
.init();
if debug_logs_enabled() {
tracing::info!("🚀 OpenCrabs debug logging enabled");
tracing::info!("📁 Log directory: {}", config.log_dir.display());
}
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 tmp_dir = crate::config::opencrabs_home().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)
}