use std::io::Write;
use std::path::Path;
const MAX_LOG_SIZE: u64 = 1024 * 1024;
const MAX_ARCHIVES: u32 = 3;
pub(crate) fn log_hook_warning(message: &str) {
let log_path = match cache_dir() {
Some(dir) => dir.join("hook.log"),
None => return,
};
let _ = std::fs::create_dir_all(log_path.parent().unwrap_or(Path::new(".")));
rotate_if_needed(&log_path);
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
let timestamp = timestamp_string();
let _ = writeln!(file, "[{timestamp}] {message}");
}
}
fn rotate_if_needed(log_path: &Path) {
let size = std::fs::metadata(log_path).map(|m| m.len()).unwrap_or(0);
if size < MAX_LOG_SIZE {
return;
}
for i in (1..MAX_ARCHIVES).rev() {
let from = archive_path(log_path, i);
let to = archive_path(log_path, i + 1);
let _ = std::fs::rename(&from, &to);
}
let archive_1 = archive_path(log_path, 1);
let _ = std::fs::rename(log_path, &archive_1);
}
fn archive_path(log_path: &Path, index: u32) -> std::path::PathBuf {
let mut path = log_path.as_os_str().to_owned();
path.push(format!(".{index}"));
std::path::PathBuf::from(path)
}
pub(super) fn cache_dir() -> Option<std::path::PathBuf> {
if let Ok(dir) = std::env::var("SKIM_CACHE_DIR") {
return Some(std::path::PathBuf::from(dir));
}
dirs::cache_dir().map(|c| c.join("skim"))
}
fn timestamp_string() -> String {
let now = std::time::SystemTime::now();
let secs = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let days = secs / 86400;
let day_secs = secs % 86400;
let (year, month, day) = days_to_date(days);
let hour = day_secs / 3600;
let minute = (day_secs % 3600) / 60;
let second = day_secs % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
pub(super) fn days_to_date(days_since_epoch: u64) -> (u64, u64, u64) {
let z = days_since_epoch + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_log_rotation_at_1mb() {
let dir = tempfile::TempDir::new().unwrap();
let log_path = dir.path().join("hook.log");
let content = "x".repeat(MAX_LOG_SIZE as usize + 100);
std::fs::write(&log_path, &content).unwrap();
rotate_if_needed(&log_path);
assert!(
!log_path.exists(),
"Original log should be renamed during rotation"
);
let archive1 = archive_path(&log_path, 1);
assert!(archive1.exists(), "Archive .1 should exist after rotation");
let archived_content = std::fs::read_to_string(&archive1).unwrap();
assert_eq!(archived_content, content);
}
#[test]
fn test_rotation_shifts_existing_archives() {
let dir = tempfile::TempDir::new().unwrap();
let log_path = dir.path().join("hook.log");
std::fs::write(archive_path(&log_path, 1), "archive 1 content").unwrap();
std::fs::write(archive_path(&log_path, 2), "archive 2 content").unwrap();
let big_content = "y".repeat(MAX_LOG_SIZE as usize + 1);
std::fs::write(&log_path, &big_content).unwrap();
rotate_if_needed(&log_path);
let a1 = std::fs::read_to_string(archive_path(&log_path, 1)).unwrap();
assert_eq!(a1, big_content);
let a2 = std::fs::read_to_string(archive_path(&log_path, 2)).unwrap();
assert_eq!(a2, "archive 1 content");
let a3 = std::fs::read_to_string(archive_path(&log_path, 3)).unwrap();
assert_eq!(a3, "archive 2 content");
}
#[test]
fn test_rotation_not_triggered_under_limit() {
let dir = tempfile::TempDir::new().unwrap();
let log_path = dir.path().join("hook.log");
std::fs::write(&log_path, "small log entry\n").unwrap();
rotate_if_needed(&log_path);
assert!(log_path.exists(), "Small log should not be rotated");
assert!(
!archive_path(&log_path, 1).exists(),
"No archive should be created"
);
}
#[test]
fn test_rotation_missing_file_is_noop() {
let dir = tempfile::TempDir::new().unwrap();
let log_path = dir.path().join("nonexistent.log");
rotate_if_needed(&log_path);
assert!(!log_path.exists());
}
#[test]
fn test_timestamp_string_format() {
let ts = timestamp_string();
assert_eq!(ts.len(), 20, "Timestamp should be 20 chars: {ts}");
assert!(ts.ends_with('Z'), "Timestamp should end with Z: {ts}");
assert_eq!(&ts[4..5], "-", "Dash after year: {ts}");
assert_eq!(&ts[7..8], "-", "Dash after month: {ts}");
assert_eq!(&ts[10..11], "T", "T separator: {ts}");
assert_eq!(&ts[13..14], ":", "Colon after hour: {ts}");
assert_eq!(&ts[16..17], ":", "Colon after minute: {ts}");
}
#[test]
fn test_archive_path_format() {
let log = std::path::PathBuf::from("/tmp/hook.log");
assert_eq!(
archive_path(&log, 1),
std::path::PathBuf::from("/tmp/hook.log.1")
);
assert_eq!(
archive_path(&log, 3),
std::path::PathBuf::from("/tmp/hook.log.3")
);
}
#[test]
fn test_log_hook_warning_triggers_rotation() {
let dir = tempfile::TempDir::new().unwrap();
let cache = dir.path().join("skim-cache");
std::fs::create_dir_all(&cache).unwrap();
let log_path = cache.join("hook.log");
let big_content = "z".repeat(MAX_LOG_SIZE as usize + 100);
std::fs::write(&log_path, &big_content).unwrap();
std::env::set_var("SKIM_CACHE_DIR", &cache);
log_hook_warning("rotation integration test");
std::env::remove_var("SKIM_CACHE_DIR");
let archive1 = archive_path(&log_path, 1);
assert!(
archive1.exists(),
"Archive .1 should exist after rotation triggered by log_hook_warning"
);
let archived = std::fs::read_to_string(&archive1).unwrap();
assert_eq!(
archived, big_content,
"Archive .1 should contain the original oversized content"
);
assert!(
log_path.exists(),
"hook.log should be recreated after rotation"
);
let new_content = std::fs::read_to_string(&log_path).unwrap();
assert!(
new_content.contains("rotation integration test"),
"New hook.log should contain the warning message, got: {new_content}"
);
}
}