use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
use crate::logger::{LoggerConfig, LoggerError};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
pub fn default_dev_log_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(4)
.map(|root| root.join("logs"))
.unwrap_or_else(|| PathBuf::from("logs"))
}
fn today_date() -> String {
let dur = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
let secs = dur.as_secs();
let days = secs / 86400;
let mut y = 1970u64;
let mut d = days;
loop {
let year_days = if is_leap(y) { 366 } else { 365 };
if d < year_days {
break;
}
d -= year_days;
y += 1;
}
let month_days = if is_leap(y) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut m = 0u64;
for (i, &md) in month_days.iter().enumerate() {
if d < md {
m = i as u64;
break;
}
d -= md;
}
format!("{:04}-{:02}-{:02}", y, m + 1, d + 1)
}
fn is_leap(year: u64) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
fn platform_validate_path(path: &Path) -> Result<(), String> {
let path_str = path.to_str().ok_or("Log path contains invalid characters")?;
if path_str.is_empty() {
return Err("Log path must not be empty".into());
}
#[cfg(target_os = "windows")]
{
let invalid_chars = ['<', '>', '"', '|', '?', '*'];
for c in invalid_chars {
if path_str.contains(c) {
return Err(format!("Invalid character '{}' in log path", c));
}
}
}
#[cfg(not(target_os = "windows"))]
{
if path_str.contains('\0') {
return Err("Log path contains null character".into());
}
}
Ok(())
}
pub fn daily_log_path(log_dir: &Path) -> PathBuf {
log_dir.join(format!("bettertui-{}.log", today_date()))
}
fn open_or_truncate(path: &Path) -> Result<fs::File, String> {
if path.exists() {
let metadata = fs::metadata(path).map_err(|e| format!("Failed to get file metadata: {}", e))?;
let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
let modified_secs = modified.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_secs();
let now_secs = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_secs();
if modified_secs / 86400 == now_secs / 86400 {
return fs::OpenOptions::new()
.append(true)
.create(true)
.open(path)
.map_err(|e| format!("Failed to open log file: {}", e));
}
let _ = fs::remove_file(path);
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create log directory: {}", e))?;
}
fs::File::create(path).map_err(|e| format!("Failed to create log file: {}", e))
}
fn try_open_log_file(config: &LoggerConfig) -> Option<fs::File> {
let target = config.resolve_file_target()?;
if let Err(e) = platform_validate_path(&target.file) {
eprintln!("[logger] Invalid log path {:?}, file logging disabled: {}", target.file, e);
return None;
}
if let Err(e) = fs::create_dir_all(&target.dir) {
eprintln!("[logger] Failed to create log dir {:?}, file logging disabled: {}", target.dir, e);
return None;
}
match open_or_truncate(&target.file) {
Ok(file) => {
eprintln!("[logger] Writing logs to: {}", target.file.display());
Some(file)
}
Err(e) => {
eprintln!("[logger] Failed to open log file, file logging disabled: {}", e);
None
}
}
}
pub fn init_subscriber(config: &LoggerConfig) -> Result<(), LoggerError> {
let env_filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(config.level.as_str()))
.unwrap_or_else(|_| EnvFilter::new("info"));
let terminal_layer = tracing_subscriber::fmt::layer()
.with_ansi(config.color)
.with_target(config.module)
.with_thread_ids(config.thread)
.with_thread_names(config.thread)
.with_file(true)
.with_line_number(true);
let file_layer = try_open_log_file(config).map(|file| {
tracing_subscriber::fmt::layer()
.with_writer(Mutex::new(file))
.with_ansi(false)
.with_target(true)
.with_thread_ids(config.thread)
.with_thread_names(config.thread)
.with_file(true)
.with_line_number(true)
});
tracing_subscriber::Registry::default().with(env_filter).with(terminal_layer).with(file_layer).init();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leap_years() {
assert!(is_leap(2020));
assert!(is_leap(2000));
assert!(is_leap(2400));
assert!(!is_leap(2021));
assert!(!is_leap(1900));
assert!(!is_leap(2100));
}
#[test]
fn today_date_format() {
let date = today_date();
assert_eq!(date.len(), 10, "date should be YYYY-MM-DD");
let parts: Vec<&str> = date.split('-').collect();
assert_eq!(parts.len(), 3);
let year: u64 = parts[0].parse().unwrap();
let month: u64 = parts[1].parse().unwrap();
let day: u64 = parts[2].parse().unwrap();
assert!(year >= 2020);
assert!((1..=12).contains(&month));
assert!((1..=31).contains(&day));
}
#[test]
fn validate_path_rejects_empty() {
assert!(platform_validate_path(Path::new("")).is_err());
}
#[test]
fn validate_path_accepts_absolute() {
assert!(platform_validate_path(Path::new("/tmp/bettertui/app.log")).is_ok());
}
#[test]
fn daily_log_path_shape() {
let dir = std::env::temp_dir().join("bettertui_sub_test");
let path = daily_log_path(&dir);
assert!(path.starts_with(&dir));
let name = path.file_name().unwrap().to_str().unwrap();
assert!(name.starts_with("bettertui-"));
assert!(name.ends_with(".log"));
}
#[test]
fn default_dev_log_dir_ends_with_logs() {
let dir = default_dev_log_dir();
assert!(dir.ends_with("logs"), "got {:?}", dir);
}
#[test]
fn open_or_truncate_creates_and_appends() {
let dir = std::env::temp_dir().join(format!("btui_sub_{}", std::process::id()));
let path = dir.join("t.log");
let _ = fs::remove_dir_all(&dir);
{
use std::io::Write;
let mut f = open_or_truncate(&path).expect("create");
writeln!(f, "line one").unwrap();
}
{
use std::io::Write;
let mut f = open_or_truncate(&path).expect("append same day");
writeln!(f, "line two").unwrap();
}
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("line one"));
assert!(contents.contains("line two"));
let _ = fs::remove_dir_all(&dir);
}
}