use crate::config::AppConfig;
use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use time::format_description;
use time::UtcOffset;
use tracing_subscriber::fmt::time::OffsetTime;
use tracing_subscriber::prelude::*;
use tracing_subscriber::{filter::Directive, EnvFilter};
fn get_log_file_path(
working_dir: &PathBuf,
offset: UtcOffset,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let logs_dir = working_dir.join(".robit").join("logs");
std::fs::create_dir_all(&logs_dir)?;
let now = time::OffsetDateTime::now_utc().to_offset(offset);
let format = format_description::parse_borrowed::<3>("[year]-[month]-[day]").unwrap();
let date = now.format(&format).unwrap();
let log_file = logs_dir.join(format!("robit-{}.log", date));
Ok(log_file)
}
fn local_utc_offset() -> UtcOffset {
match UtcOffset::current_local_offset() {
Ok(offset) => offset,
Err(_) => {
eprintln!(
"Could not determine local time offset; log timestamps will be UTC."
);
UtcOffset::UTC
}
}
}
fn local_timer(
offset: UtcOffset,
) -> OffsetTime<format_description::FormatDescriptionV3<'static>> {
let format = format_description::parse_borrowed::<3>(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:6][offset_hour sign:mandatory]:[offset_minute]",
)
.expect("hardcoded log timestamp format is valid");
OffsetTime::new(offset, format)
}
fn cleanup_old_logs(logs_dir: &Path, retention_days: u32) {
if retention_days == 0 {
return;
}
let cutoff = SystemTime::now() - Duration::from_secs(retention_days as u64 * 86_400);
let entries = match std::fs::read_dir(logs_dir) {
Ok(e) => e,
Err(e) => {
tracing::warn!("Failed to scan log dir for cleanup: {}", e);
return;
}
};
let mut removed = 0u32;
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !(name.starts_with("robit-") && name.ends_with(".log")) {
continue;
}
let modified = match entry.metadata().and_then(|m| m.modified()) {
Ok(m) => m,
Err(_) => continue,
};
if modified < cutoff {
if let Err(e) = std::fs::remove_file(entry.path()) {
tracing::warn!("Failed to delete old log {}: {}", name, e);
} else {
removed += 1;
}
}
}
if removed > 0 {
tracing::info!(
"Cleaned up {} old log file(s) (retention {} days).",
removed,
retention_days
);
}
}
fn build_filter(
app_config: Option<&AppConfig>,
target_crate: &str,
additional_directives: &[&str],
) -> EnvFilter {
let mut filter = EnvFilter::from_default_env();
if std::env::var("RUST_LOG").is_err() {
let global_level = app_config
.and_then(|c| c.log_level.as_deref())
.unwrap_or("info");
if let Ok(dir) = format!("{}={}", target_crate, global_level).parse() {
filter = filter.add_directive(dir);
}
for robit_crate in &["robit_agent", "robit_chatbot", "robit_ai"] {
if robit_crate != &target_crate {
if let Ok(dir) = format!("{}={}", robit_crate, global_level).parse() {
filter = filter.add_directive(dir);
}
}
}
for dir_str in additional_directives {
if let Ok(dir) = dir_str.parse::<Directive>() {
filter = filter.add_directive(dir);
}
}
for dep_crate in &[
"reqwest",
"hyper",
"hyper_util",
"tungstenite",
"tokio_tungstenite",
"tokio",
"tauri",
] {
if let Ok(dir) = format!("{}=warn", dep_crate).parse() {
filter = filter.add_directive(dir);
}
}
}
filter
}
fn install_panic_hook() {
let previous_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let payload_msg: String = if let Some(s) = info.payload().downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"<non-string panic payload>".to_string()
};
let location = info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "<unknown location>".to_string());
let thread_name = std::thread::current().name().unwrap_or("<unnamed>").to_string();
tracing::error!(
"thread '{}' panicked at {}: {}",
thread_name,
location,
payload_msg
);
previous_hook(info);
}));
}
pub fn init_logging(
app_config: Option<&AppConfig>,
target_crate: &str,
working_dir: &PathBuf,
additional_directives: &[&str],
) {
let filter = build_filter(app_config, target_crate, additional_directives);
let offset = local_utc_offset();
let timer = local_timer(offset);
let log_file_enabled = app_config.and_then(|c| c.log_file).unwrap_or(false);
if log_file_enabled {
match get_log_file_path(working_dir, offset) {
Ok(log_path) => {
match OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
Ok(file) => {
let file_writer = tracing_subscriber::fmt::writer::MakeWriterExt::with_max_level(file, tracing::Level::TRACE);
let console_layer = tracing_subscriber::fmt::layer()
.with_writer(std::io::stdout)
.with_timer(timer.clone())
.with_filter(filter.clone());
let file_layer = tracing_subscriber::fmt::layer()
.with_writer(file_writer)
.with_ansi(false)
.with_timer(timer.clone())
.with_filter(filter);
let registry = tracing_subscriber::registry()
.with(console_layer)
.with(file_layer);
registry.init();
tracing::info!("Logging to file: {}", log_path.display());
let retention = app_config
.and_then(|c| c.log_retention_days)
.unwrap_or(14);
if let Some(dir) = log_path.parent() {
cleanup_old_logs(dir, retention);
}
}
Err(e) => {
eprintln!("Failed to open log file: {}. Falling back to console-only logging.", e);
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_timer(timer.clone())
.init();
}
}
}
Err(e) => {
eprintln!("Failed to prepare log path: {}. Falling back to console-only logging.", e);
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_timer(timer.clone())
.init();
}
}
} else {
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_timer(timer.clone())
.init();
}
install_panic_hook();
}
pub fn init_logging_silent(
app_config: Option<&AppConfig>,
target_crate: &str,
working_dir: &PathBuf,
additional_directives: &[&str],
) {
let filter = build_filter(app_config, target_crate, additional_directives);
let offset = local_utc_offset();
let timer = local_timer(offset);
let log_file_enabled = app_config.and_then(|c| c.log_file).unwrap_or(false);
if log_file_enabled {
match get_log_file_path(working_dir, offset) {
Ok(log_path) => match OpenOptions::new().create(true).append(true).open(&log_path) {
Ok(file) => {
let file_writer =
tracing_subscriber::fmt::writer::MakeWriterExt::with_max_level(
file,
tracing::Level::TRACE,
);
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(file_writer)
.with_ansi(false)
.with_timer(timer.clone())
.init();
tracing::info!("Logging to file: {}", log_path.display());
let retention = app_config
.and_then(|c| c.log_retention_days)
.unwrap_or(14);
if let Some(dir) = log_path.parent() {
cleanup_old_logs(dir, retention);
}
}
Err(e) => {
eprintln!(
"Failed to open log file: {}. Falling back to silent logging.",
e
);
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::sink)
.with_timer(timer.clone())
.init();
}
},
Err(e) => {
eprintln!(
"Failed to prepare log path: {}. Falling back to silent logging.",
e
);
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::sink)
.with_timer(timer.clone())
.init();
}
}
} else {
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::sink)
.with_timer(timer.clone())
.init();
}
install_panic_hook();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cleanup_old_logs_deletes_old_keeps_recent_and_ignores_others() {
let dir = std::env::temp_dir().join(format!("robit-log-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let ancient = SystemTime::now() - Duration::from_secs(60 * 86_400);
let write_with_mtime = |name: &str, mtime: SystemTime| {
let path = dir.join(name);
let f = std::fs::File::create(&path).unwrap();
f.set_modified(mtime).unwrap();
path
};
let old = write_with_mtime("robit-2000-01-01.log", ancient); let recent = write_with_mtime("robit-2099-01-01.log", SystemTime::now()); let other = write_with_mtime("err.log", ancient);
cleanup_old_logs(&dir, 14);
assert!(!old.exists(), "old robit-*.log should be deleted");
assert!(recent.exists(), "recent robit-*.log should be kept");
assert!(other.exists(), "non-robit file should be untouched");
let old2 = write_with_mtime("robit-2001-01-01.log", ancient);
cleanup_old_logs(&dir, 0);
assert!(old2.exists(), "retention_days=0 should disable cleanup");
let _ = std::fs::remove_dir_all(&dir);
}
}