use std::io::{self, IsTerminal};
use tracing::Level;
use tracing_subscriber::{
fmt::{self, format::FmtSpan},
layer::SubscriberExt,
util::SubscriberInitExt,
EnvFilter, Layer, Registry,
};
#[derive(Debug, Clone)]
pub struct LoggingConfig {
pub level: Level,
pub color: bool,
pub show_timestamps: bool,
pub show_target: bool,
pub json_format: bool,
pub enable_spans: bool,
pub file_output: Option<std::path::PathBuf>,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: Level::INFO,
color: true,
show_timestamps: false,
show_target: false,
json_format: false,
enable_spans: false,
file_output: None,
}
}
}
impl LoggingConfig {
pub fn for_mode(mode: ApplicationMode) -> Self {
match mode {
ApplicationMode::McpServer => Self {
level: Level::DEBUG,
color: false, show_timestamps: true,
show_target: true,
json_format: true, enable_spans: false, file_output: None,
},
ApplicationMode::Dashboard => Self {
level: Level::INFO,
color: false, show_timestamps: true,
show_target: true,
json_format: false,
enable_spans: true, file_output: None,
},
ApplicationMode::Cli => Self {
level: Level::INFO,
color: true,
show_timestamps: false,
show_target: false,
json_format: false,
enable_spans: false,
file_output: None,
},
ApplicationMode::Test => Self {
level: Level::DEBUG,
color: false,
show_timestamps: true,
show_target: true,
json_format: false,
enable_spans: true,
file_output: None,
},
}
}
pub fn from_args(quiet: bool, verbose: bool, json: bool) -> Self {
let level = if verbose {
Level::DEBUG
} else if quiet {
Level::ERROR
} else {
Level::INFO
};
Self {
level,
color: !quiet && !json && std::io::stdout().is_terminal(),
show_timestamps: verbose || json,
show_target: verbose,
json_format: json,
enable_spans: verbose,
file_output: None,
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum ApplicationMode {
McpServer,
Dashboard,
Cli,
Test,
}
pub fn init_logging(config: LoggingConfig) -> io::Result<()> {
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(format!("intent_engine={}", config.level)));
let registry = Registry::default().with(env_filter);
if let Some(log_file) = config.file_output {
let log_dir = log_file
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid log file path"))?;
let file_name = log_file
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid log file name"))?;
std::fs::create_dir_all(log_dir)?;
let file_appender = tracing_appender::rolling::daily(log_dir, file_name);
if config.json_format {
let json_layer = tracing_subscriber::fmt::layer()
.json()
.with_current_span(config.enable_spans)
.with_span_events(FmtSpan::CLOSE)
.with_writer(file_appender);
json_layer.with_subscriber(registry).init();
} else {
let fmt_layer = fmt::layer()
.with_target(config.show_target)
.with_level(true)
.with_ansi(false)
.with_writer(file_appender);
if config.show_timestamps {
fmt_layer
.with_timer(fmt::time::ChronoUtc::rfc_3339())
.with_subscriber(registry)
.init();
} else {
fmt_layer.with_subscriber(registry).init();
}
}
} else if config.json_format {
let json_layer = tracing_subscriber::fmt::layer()
.json()
.with_current_span(config.enable_spans)
.with_span_events(FmtSpan::CLOSE)
.with_writer(io::stdout);
json_layer.with_subscriber(registry).init();
} else {
let fmt_layer = fmt::layer()
.with_target(config.show_target)
.with_level(true)
.with_ansi(config.color)
.with_writer(io::stdout);
if config.show_timestamps {
fmt_layer
.with_timer(fmt::time::ChronoUtc::rfc_3339())
.with_subscriber(registry)
.init();
} else {
fmt_layer.with_subscriber(registry).init();
}
}
Ok(())
}
pub fn cleanup_old_logs(log_dir: &std::path::Path, retention_days: u32) -> io::Result<()> {
use std::fs;
use std::time::SystemTime;
if !log_dir.exists() {
return Ok(()); }
let now = SystemTime::now();
let retention_duration = std::time::Duration::from_secs(retention_days as u64 * 24 * 60 * 60);
let mut cleaned_count = 0;
let mut cleaned_size: u64 = 0;
for entry in fs::read_dir(log_dir)? {
let entry = entry?;
let path = entry.path();
let path_str = path.to_string_lossy();
if !path_str.contains(".log.") || !path.is_file() {
continue;
}
let metadata = entry.metadata()?;
let modified = metadata.modified()?;
if let Ok(age) = now.duration_since(modified) {
if age > retention_duration {
let size = metadata.len();
match fs::remove_file(&path) {
Ok(_) => {
cleaned_count += 1;
cleaned_size += size;
tracing::info!(
"Cleaned up old log file: {} (age: {} days, size: {} bytes)",
path.display(),
age.as_secs() / 86400,
size
);
},
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "Failed to remove old log file");
},
}
}
}
}
if cleaned_count > 0 {
tracing::info!(
"Log cleanup completed: removed {} files, freed {} bytes",
cleaned_count,
cleaned_size
);
}
Ok(())
}
pub fn log_file_path(mode: ApplicationMode) -> std::path::PathBuf {
let home = dirs::home_dir().expect("Failed to get home directory");
let log_dir = home.join(".intent-engine").join("logs");
std::fs::create_dir_all(&log_dir).ok();
match mode {
ApplicationMode::Dashboard => log_dir.join("dashboard.log"),
ApplicationMode::McpServer => log_dir.join("mcp-server.log"),
ApplicationMode::Cli => log_dir.join("cli.log"),
ApplicationMode::Test => log_dir.join("test.log"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::SystemTime;
use tempfile::TempDir;
#[test]
fn test_logging_config_default() {
let config = LoggingConfig::default();
assert_eq!(config.level, Level::INFO);
assert!(config.color);
assert!(!config.show_timestamps);
assert!(!config.show_target);
assert!(!config.json_format);
assert!(!config.enable_spans);
assert!(config.file_output.is_none());
}
#[test]
fn test_logging_config_for_mode_mcp_server() {
let config = LoggingConfig::for_mode(ApplicationMode::McpServer);
assert_eq!(config.level, Level::DEBUG);
assert!(!config.color); assert!(config.show_timestamps);
assert!(config.show_target);
assert!(config.json_format); assert!(!config.enable_spans); assert!(config.file_output.is_none());
}
#[test]
fn test_logging_config_for_mode_dashboard() {
let config = LoggingConfig::for_mode(ApplicationMode::Dashboard);
assert_eq!(config.level, Level::INFO);
assert!(!config.color); assert!(config.show_timestamps);
assert!(config.show_target);
assert!(!config.json_format);
assert!(config.enable_spans); assert!(config.file_output.is_none());
}
#[test]
fn test_logging_config_for_mode_cli() {
let config = LoggingConfig::for_mode(ApplicationMode::Cli);
assert_eq!(config.level, Level::INFO);
assert!(config.color); assert!(!config.show_timestamps);
assert!(!config.show_target);
assert!(!config.json_format);
assert!(!config.enable_spans);
assert!(config.file_output.is_none());
}
#[test]
fn test_logging_config_for_mode_test() {
let config = LoggingConfig::for_mode(ApplicationMode::Test);
assert_eq!(config.level, Level::DEBUG);
assert!(!config.color);
assert!(config.show_timestamps);
assert!(config.show_target);
assert!(!config.json_format);
assert!(config.enable_spans); assert!(config.file_output.is_none());
}
#[test]
fn test_logging_config_from_args_verbose() {
let config = LoggingConfig::from_args(false, true, false);
assert_eq!(config.level, Level::DEBUG);
assert!(config.show_timestamps);
assert!(config.show_target);
assert!(!config.json_format);
assert!(config.enable_spans);
}
#[test]
fn test_logging_config_from_args_quiet() {
let config = LoggingConfig::from_args(true, false, false);
assert_eq!(config.level, Level::ERROR);
assert!(!config.color); assert!(!config.show_timestamps); assert!(!config.show_target);
}
#[test]
fn test_logging_config_from_args_json() {
let config = LoggingConfig::from_args(false, false, true);
assert_eq!(config.level, Level::INFO);
assert!(!config.color); assert!(config.show_timestamps); assert!(config.json_format);
}
#[test]
fn test_logging_config_from_args_normal() {
let config = LoggingConfig::from_args(false, false, false);
assert_eq!(config.level, Level::INFO);
assert!(!config.show_timestamps);
assert!(!config.show_target);
assert!(!config.json_format);
assert!(!config.enable_spans);
}
#[test]
fn test_log_file_path_dashboard() {
let path = log_file_path(ApplicationMode::Dashboard);
assert!(path.to_string_lossy().ends_with("dashboard.log"));
assert!(path.to_string_lossy().contains(".intent-engine"));
assert!(path.to_string_lossy().contains("logs"));
}
#[test]
fn test_log_file_path_mcp_server() {
let path = log_file_path(ApplicationMode::McpServer);
assert!(path.to_string_lossy().ends_with("mcp-server.log"));
}
#[test]
fn test_log_file_path_cli() {
let path = log_file_path(ApplicationMode::Cli);
assert!(path.to_string_lossy().ends_with("cli.log"));
}
#[test]
fn test_log_file_path_test() {
let path = log_file_path(ApplicationMode::Test);
assert!(path.to_string_lossy().ends_with("test.log"));
}
#[test]
fn test_cleanup_old_logs_nonexistent_dir() {
let temp = TempDir::new().unwrap();
let nonexistent = temp.path().join("nonexistent");
let result = cleanup_old_logs(&nonexistent, 7);
assert!(result.is_ok());
}
#[test]
fn test_cleanup_old_logs_empty_dir() {
let temp = TempDir::new().unwrap();
let result = cleanup_old_logs(temp.path(), 7);
assert!(result.is_ok());
}
#[test]
fn test_cleanup_old_logs_keeps_current_logs() {
let temp = TempDir::new().unwrap();
let current_log = temp.path().join("dashboard.log");
fs::write(¤t_log, "current log data").unwrap();
cleanup_old_logs(temp.path(), 0).unwrap();
assert!(current_log.exists());
}
#[test]
fn test_cleanup_old_logs_removes_old_rotated_files() {
let temp = TempDir::new().unwrap();
let old_log = temp.path().join("dashboard.log.2020-01-01");
fs::write(&old_log, "old log data").unwrap();
let ten_days_ago = SystemTime::now()
.checked_sub(std::time::Duration::from_secs(10 * 24 * 60 * 60))
.unwrap();
filetime::set_file_mtime(&old_log, filetime::FileTime::from_system_time(ten_days_ago))
.unwrap();
cleanup_old_logs(temp.path(), 7).unwrap();
assert!(!old_log.exists());
}
#[test]
fn test_cleanup_old_logs_keeps_recent_rotated_files() {
let temp = TempDir::new().unwrap();
let recent_log = temp.path().join("mcp-server.log.2025-11-25");
fs::write(&recent_log, "recent log data").unwrap();
let three_days_ago = SystemTime::now()
.checked_sub(std::time::Duration::from_secs(3 * 24 * 60 * 60))
.unwrap();
filetime::set_file_mtime(
&recent_log,
filetime::FileTime::from_system_time(three_days_ago),
)
.unwrap();
cleanup_old_logs(temp.path(), 7).unwrap();
assert!(recent_log.exists());
}
#[test]
fn test_cleanup_old_logs_ignores_non_log_files() {
let temp = TempDir::new().unwrap();
let old_file = temp.path().join("config.json");
fs::write(&old_file, "{}").unwrap();
let ten_days_ago = SystemTime::now()
.checked_sub(std::time::Duration::from_secs(10 * 24 * 60 * 60))
.unwrap();
filetime::set_file_mtime(
&old_file,
filetime::FileTime::from_system_time(ten_days_ago),
)
.unwrap();
cleanup_old_logs(temp.path(), 7).unwrap();
assert!(old_file.exists());
}
#[test]
fn test_cleanup_old_logs_ignores_subdirectories() {
let temp = TempDir::new().unwrap();
let subdir = temp.path().join("archive.log.2020-01-01");
fs::create_dir(&subdir).unwrap();
let result = cleanup_old_logs(temp.path(), 7);
assert!(result.is_ok());
assert!(subdir.exists());
}
}