use std::path::PathBuf;
use thiserror::Error;
pub use crate::observability::Sanitizer;
pub use crate::observability::logging::{LogConfig, LogFormat, LogLevel};
#[derive(Debug, Error)]
pub enum LogError {
#[error("Failed to create log directory: {0}")]
DirectoryCreationFailed(#[from] std::io::Error),
#[error("Failed to open log file: {0}")]
FileOpenFailed(String),
#[error("Failed to read log file: {0}")]
ReadFailed(String),
}
pub fn default_log_directory() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".jarvy")
.join("logs")
}
pub fn current_log_file() -> PathBuf {
default_log_directory().join("jarvy.log")
}
pub fn read_recent_logs(lines: usize) -> Result<Vec<String>, LogError> {
let log_file = current_log_file();
if !log_file.exists() {
return Ok(Vec::new());
}
let content =
std::fs::read_to_string(&log_file).map_err(|e| LogError::ReadFailed(e.to_string()))?;
let all_lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let start = all_lines.len().saturating_sub(lines);
Ok(all_lines[start..].to_vec())
}
#[derive(Debug, serde::Serialize)]
pub struct LogStats {
pub total_files: usize,
pub total_size_bytes: u64,
pub current_file_size_bytes: u64,
pub oldest_entry: Option<String>,
pub newest_entry: Option<String>,
pub entries_by_level: std::collections::HashMap<String, usize>,
}
pub fn get_log_stats() -> Result<LogStats, LogError> {
let log_dir = default_log_directory();
let mut total_files = 0;
let mut total_size: u64 = 0;
let mut current_file_size: u64 = 0;
if log_dir.exists() {
for entry in (std::fs::read_dir(&log_dir)?).flatten() {
let path = entry.path();
if path.is_file() {
if let Ok(metadata) = path.metadata() {
total_files += 1;
total_size += metadata.len();
if path.file_name().map(|n| n == "jarvy.log").unwrap_or(false) {
current_file_size = metadata.len();
}
}
}
}
}
let mut entries_by_level = std::collections::HashMap::new();
let mut oldest_entry = None;
let mut newest_entry = None;
let log_file = current_log_file();
if log_file.exists() {
if let Ok(content) = std::fs::read_to_string(&log_file) {
let lines: Vec<&str> = content.lines().collect();
if !lines.is_empty() {
oldest_entry = lines.first().map(|s| s.to_string());
newest_entry = lines.last().map(|s| s.to_string());
}
for line in lines {
if line.contains("\"level\":\"ERROR\"") || line.contains(" ERROR ") {
*entries_by_level.entry("ERROR".to_string()).or_insert(0) += 1;
} else if line.contains("\"level\":\"WARN\"") || line.contains(" WARN ") {
*entries_by_level.entry("WARN".to_string()).or_insert(0) += 1;
} else if line.contains("\"level\":\"INFO\"") || line.contains(" INFO ") {
*entries_by_level.entry("INFO".to_string()).or_insert(0) += 1;
} else if line.contains("\"level\":\"DEBUG\"") || line.contains(" DEBUG ") {
*entries_by_level.entry("DEBUG".to_string()).or_insert(0) += 1;
} else if line.contains("\"level\":\"TRACE\"") || line.contains(" TRACE ") {
*entries_by_level.entry("TRACE".to_string()).or_insert(0) += 1;
}
}
}
}
Ok(LogStats {
total_files,
total_size_bytes: total_size,
current_file_size_bytes: current_file_size,
oldest_entry,
newest_entry,
entries_by_level,
})
}
pub fn clean_logs(max_age_days: u32, all: bool) -> Result<(usize, u64), LogError> {
let log_dir = default_log_directory();
if !log_dir.exists() {
return Ok((0, 0));
}
let mut removed_files = 0;
let mut removed_bytes: u64 = 0;
let max_age_secs = max_age_days as u64 * 24 * 60 * 60;
for entry in std::fs::read_dir(&log_dir)?.flatten() {
let path = entry.path();
if path.is_file() {
let should_remove = if all {
true
} else {
if let Ok(metadata) = path.metadata() {
if let Ok(modified) = metadata.modified() {
let age = std::time::SystemTime::now()
.duration_since(modified)
.unwrap_or_default();
age.as_secs() > max_age_secs
} else {
false
}
} else {
false
}
};
if should_remove {
if let Ok(metadata) = path.metadata() {
removed_bytes += metadata.len();
}
if std::fs::remove_file(&path).is_ok() {
removed_files += 1;
}
}
}
}
Ok((removed_files, removed_bytes))
}
pub fn format_size(bytes: u64) -> String {
if bytes >= 1024 * 1024 * 1024 {
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
} else if bytes >= 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else if bytes >= 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{} B", bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_log_directory() {
let dir = default_log_directory();
assert!(dir.ends_with(".jarvy/logs"));
}
#[test]
fn test_current_log_file() {
let file = current_log_file();
assert!(file.ends_with("jarvy.log"));
}
#[test]
fn test_format_size() {
assert_eq!(format_size(500), "500 B");
assert_eq!(format_size(1024), "1.0 KB");
assert_eq!(format_size(1024 * 1024), "1.0 MB");
assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB");
}
}