use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn log_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home)
.join(".config")
.join("vibestats")
.join("vibestats.log")
}
fn epoch_to_datetime(secs: u64) -> (u64, u64, u64, u64, u64, u64) {
let s = secs % 60;
let total_min = secs / 60;
let min = total_min % 60;
let total_hours = total_min / 60;
let h = total_hours % 24;
let mut days = total_hours / 24;
let mut year: u64 = 1970;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if days < days_in_year {
break;
}
days -= days_in_year;
year += 1;
}
let leap = is_leap_year(year);
let month_days: [u64; 12] = [
31,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut month: u64 = 1;
for &md in &month_days {
if days < md {
break;
}
days -= md;
month += 1;
}
let day = days + 1; (year, month, day, h, min, s)
}
fn is_leap_year(year: u64) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
fn utc_timestamp() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (y, mo, d, h, min, s) = epoch_to_datetime(secs);
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, min, s)
}
const MAX_LOG_BYTES: u64 = 1_048_576;
fn rotate_if_needed(log_path: &Path) {
if let Ok(meta) = std::fs::metadata(log_path) {
if meta.len() >= MAX_LOG_BYTES {
let rotated = match (log_path.parent(), log_path.file_name()) {
(Some(parent), Some(name)) => {
let mut rotated_name = name.to_os_string();
rotated_name.push(".1");
parent.join(rotated_name)
}
_ => return,
};
let _ = std::fs::rename(log_path, rotated); }
}
}
fn write_log_entry(log_path: &Path, level: &str, message: &str) -> std::io::Result<()> {
use std::io::Write;
if let Some(parent) = log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
rotate_if_needed(log_path);
let line = format!("{} {} {}\n", utc_timestamp(), level, message);
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)?;
file.write_all(line.as_bytes())?;
Ok(())
}
pub fn log(level: &str, message: &str) {
let _ = write_log_entry(&log_path(), level, message);
}
#[allow(dead_code)] pub fn info(message: &str) {
log("INFO", message);
}
pub fn error(message: &str) {
log("ERROR", message);
}
#[allow(dead_code)] pub fn warn(message: &str) {
log("WARN", message);
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
fn write_entry_to(path: &Path, level: &str, msg: &str) {
let _ = write_log_entry(path, level, msg);
}
fn unique_test_dir(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("vibestats_test_{}_{}", name, std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
fn assert_log_line_format(line: &str, expected_level: &str, expected_msg: &str) {
assert!(line.len() >= 21, "line too short: {line:?}");
let ts = &line[..20];
let rest = &line[20..];
assert!(ts.ends_with('Z'), "timestamp must end with Z: {ts}");
let chars: Vec<char> = ts.chars().collect();
assert_eq!(chars[4], '-', "YYYY-MM sep missing");
assert_eq!(chars[7], '-', "MM-DD sep missing");
assert_eq!(chars[10], 'T', "date-time T sep missing");
assert_eq!(chars[13], ':', "HH:MM sep missing");
assert_eq!(chars[16], ':', "MM:SS sep missing");
for (i, &c) in chars.iter().enumerate() {
if ![4, 7, 10, 13, 16, 19].contains(&i) {
assert!(
c.is_ascii_digit(),
"expected digit at pos {i} in ts, got {c:?}"
);
}
}
let expected_rest = format!(" {} {}", expected_level, expected_msg);
assert_eq!(rest, expected_rest, "level/message portion mismatch");
}
#[test]
fn test_log_format_matches_spec() {
let dir = unique_test_dir("format");
let _ = fs::create_dir_all(&dir);
let log_file = dir.join("test.log");
write_entry_to(&log_file, "INFO", "hello world");
let content = fs::read_to_string(&log_file).expect("log file should exist");
let line = content.lines().next().expect("should have one line");
assert_log_line_format(line, "INFO", "hello world");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_log_format_ends_with_newline() {
let dir = unique_test_dir("newline");
let _ = fs::create_dir_all(&dir);
let log_file = dir.join("test.log");
write_entry_to(&log_file, "ERROR", "something went wrong");
let content = fs::read_to_string(&log_file).expect("log file should exist");
assert!(content.ends_with('\n'), "log entry must end with newline");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_multiple_entries_are_appended() {
let dir = unique_test_dir("append");
let _ = fs::create_dir_all(&dir);
let log_file = dir.join("test.log");
write_entry_to(&log_file, "INFO", "first");
write_entry_to(&log_file, "INFO", "second");
write_entry_to(&log_file, "INFO", "third");
let content = fs::read_to_string(&log_file).expect("log file should exist");
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 3, "should have 3 log entries");
assert!(lines[0].ends_with("first"), "first entry missing");
assert!(lines[1].ends_with("second"), "second entry missing");
assert!(lines[2].ends_with("third"), "third entry missing");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_rotation_at_1mb_threshold() {
let dir = unique_test_dir("rotate");
let _ = fs::create_dir_all(&dir);
let log_file = dir.join("vibestats.log");
let rotated_file = dir.join("vibestats.log.1");
{
let mut f = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&log_file)
.expect("should create log file");
let filler = vec![b'x'; MAX_LOG_BYTES as usize];
f.write_all(&filler).expect("should write filler");
}
write_entry_to(&log_file, "INFO", "after rotation");
assert!(
rotated_file.exists(),
"vibestats.log.1 should exist after rotation"
);
assert!(
log_file.exists(),
"vibestats.log should be recreated after rotation"
);
let new_content = fs::read_to_string(&log_file).expect("new log should exist");
assert!(
new_content.contains("after rotation"),
"new log should contain the post-rotation entry"
);
let rotated_content = fs::read_to_string(&rotated_file).expect("rotated log should exist");
assert!(
!rotated_content.contains("after rotation"),
"rotated log should not contain the new entry"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_rotation_preserves_file_stem() {
let dir = unique_test_dir("rotate_custom");
let _ = fs::create_dir_all(&dir);
let log_file = dir.join("custom.log");
let rotated_file = dir.join("custom.log.1");
{
let mut f = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&log_file)
.expect("should create log file");
let filler = vec![b'x'; MAX_LOG_BYTES as usize];
f.write_all(&filler).expect("should write filler");
}
write_entry_to(&log_file, "INFO", "rotated custom");
assert!(
rotated_file.exists(),
"custom.log.1 should exist after rotation (rotated name must be derived from log path)"
);
assert!(
!dir.join("vibestats.log.1").exists(),
"must not create vibestats.log.1 when source log is custom.log"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_no_rotation_below_1mb() {
let dir = unique_test_dir("no_rotate");
let _ = fs::create_dir_all(&dir);
let log_file = dir.join("vibestats.log");
let rotated_file = dir.join("vibestats.log.1");
{
let mut f = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&log_file)
.expect("should create log file");
let filler = vec![b'x'; (MAX_LOG_BYTES - 1) as usize];
f.write_all(&filler).expect("should write filler");
}
write_entry_to(&log_file, "INFO", "no rotation");
assert!(
!rotated_file.exists(),
"vibestats.log.1 should NOT exist when below threshold"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_epoch_to_datetime_known_values() {
let (y, mo, d, h, min, s) = epoch_to_datetime(0);
assert_eq!((y, mo, d, h, min, s), (1970, 1, 1, 0, 0, 0));
let (y, mo, d, h, min, s) = epoch_to_datetime(946_684_800);
assert_eq!((y, mo, d, h, min, s), (2000, 1, 1, 0, 0, 0));
let (y, mo, d, h, min, s) = epoch_to_datetime(1_744_243_200);
assert_eq!((y, mo, d, h, min, s), (2025, 4, 10, 0, 0, 0));
let (y, mo, d, h, min, s) = epoch_to_datetime(1_767_225_600);
assert_eq!((y, mo, d, h, min, s), (2026, 1, 1, 0, 0, 0));
let (y, mo, d, h, min, s) = epoch_to_datetime(1_775_876_820);
assert_eq!((y, mo, d, h, min, s), (2026, 4, 11, 3, 7, 0));
}
#[test]
fn test_utc_timestamp_format() {
let ts = utc_timestamp();
assert_eq!(ts.len(), 20, "timestamp should be 20 characters: {ts}");
assert!(ts.ends_with('Z'), "timestamp must end with Z");
assert_eq!(&ts[4..5], "-", "separator at pos 4 should be -");
assert_eq!(&ts[7..8], "-", "separator at pos 7 should be -");
assert_eq!(&ts[10..11], "T", "T separator at pos 10");
assert_eq!(&ts[13..14], ":", "colon at pos 13");
assert_eq!(&ts[16..17], ":", "colon at pos 16");
}
#[test]
fn test_log_to_non_existent_parent_creates_dir() {
let dir = unique_test_dir("mkdir");
let log_file = dir.join("sub").join("vibestats.log");
let _ = write_log_entry(&log_file, "INFO", "dir created");
assert!(
log_file.exists(),
"log file should be created even if parent was absent"
);
let _ = fs::remove_dir_all(&dir);
}
}