use std::fs;
use std::path::{Path, PathBuf};
use crate::error::LorumError;
const MAX_BACKUPS: usize = 10;
#[derive(Debug, Clone)]
pub struct BackupInfo {
pub path: PathBuf,
pub name: String,
pub timestamp: String,
pub time_display: String,
pub size: u64,
}
pub fn backup_dir() -> Result<PathBuf, LorumError> {
Ok(crate::config::resolve_config_dir()?
.join("lorum")
.join("backups"))
}
pub fn create_backup(tool_name: &str, source_path: &Path) -> Result<PathBuf, LorumError> {
let dir = backup_dir()?;
fs::create_dir_all(&dir)?;
let timestamp = timestamp();
let ext = source_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bak");
let backup_name = format!("{tool_name}-{timestamp}.{ext}");
let backup_path = dir.join(&backup_name);
fs::copy(source_path, &backup_path)?;
cleanup_old_backups(tool_name, &dir)?;
Ok(backup_path)
}
pub fn list_backups(tool_name: &str) -> Result<Vec<BackupInfo>, LorumError> {
let dir = backup_dir()?;
if !dir.exists() {
return Ok(Vec::new());
}
list_backups_in_dir(tool_name, &dir)
}
pub fn restore_backup(tool_name: &str, target_path: &Path) -> Result<(), LorumError> {
let backups = list_backups(tool_name)?;
let latest = backups.first().ok_or_else(|| LorumError::Other {
message: format!("no backups found for {tool_name}"),
})?;
fs::copy(&latest.path, target_path)?;
Ok(())
}
pub fn restore_backup_from_path(backup_path: &Path, target_path: &Path) -> Result<(), LorumError> {
fs::copy(backup_path, target_path)?;
Ok(())
}
fn cleanup_old_backups(tool_name: &str, dir: &Path) -> Result<(), LorumError> {
let mut backups = list_backups_in_dir(tool_name, dir)?;
if backups.len() > MAX_BACKUPS {
for old in backups.drain(MAX_BACKUPS..) {
if let Err(e) = fs::remove_file(&old.path) {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(LorumError::Io { source: e });
}
}
}
}
Ok(())
}
fn list_backups_in_dir(tool_name: &str, dir: &Path) -> Result<Vec<BackupInfo>, LorumError> {
let prefix = format!("{tool_name}-");
let mut backups: Vec<BackupInfo> = fs::read_dir(dir)?
.filter_map(|e| e.ok())
.filter_map(|e| {
let path = e.path();
let name = path.file_name()?.to_str()?.to_string();
if !name.starts_with(&prefix) {
return None;
}
let metadata = fs::metadata(&path).ok()?;
let size = metadata.len();
let rest = name.strip_prefix(&prefix)?;
let timestamp = rest.rsplit_once('.')?.0;
let (timestamp, time_display) = parse_timestamp(timestamp);
Some(BackupInfo {
path,
name,
timestamp,
time_display,
size,
})
})
.collect();
backups.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(backups)
}
fn timestamp() -> String {
let now = std::time::SystemTime::now();
let secs = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let (year, month, day, hour, minute, second) = epoch_to_utc(secs);
format!(
"{:04}{:02}{:02}-{:02}{:02}{:02}",
year, month, day, hour, minute, second
)
}
fn epoch_to_utc(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
const SECS_PER_MINUTE: u64 = 60;
const SECS_PER_HOUR: u64 = 3600;
const SECS_PER_DAY: u64 = 86400;
let mut days = secs / SECS_PER_DAY;
let rem = secs % SECS_PER_DAY;
let hour = (rem / SECS_PER_HOUR) as u32;
let rem = rem % SECS_PER_HOUR;
let minute = (rem / SECS_PER_MINUTE) as u32;
let second = (rem % SECS_PER_MINUTE) as u32;
let mut year = 1970u32;
loop {
let dim = if is_leap_year(year) { 366 } else { 365 };
if days < dim {
break;
}
days -= dim;
year += 1;
}
let month_days = if is_leap_year(year) {
[31u64, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31u64, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1u32;
for (i, &dim) in month_days.iter().enumerate() {
if days < dim {
month = (i + 1) as u32;
break;
}
days -= dim;
}
let day = (days + 1) as u32;
(year, month, day, hour, minute, second)
}
fn is_leap_year(year: u32) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
fn parse_timestamp(ts: &str) -> (String, String) {
if ts.len() == 15 && ts.as_bytes()[8] == b'-' {
let formatted = format!(
"{}-{}-{} {}:{}:{}",
&ts[0..4],
&ts[4..6],
&ts[6..8],
&ts[9..11],
&ts[11..13],
&ts[13..15]
);
(ts.to_string(), formatted)
} else if ts.parse::<u64>().is_ok() {
(ts.to_string(), format!("epoch: {ts}"))
} else {
(ts.to_string(), ts.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::fs;
use std::panic;
#[test]
#[serial]
fn backup_dir_uses_xdg_config_home() {
let tmp = tempfile::tempdir().unwrap();
let xdg = tmp.path().join("xdg_config");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", &xdg);
}
let result = panic::catch_unwind(|| {
let dir = backup_dir().unwrap();
assert_eq!(dir, xdg.join("lorum").join("backups"));
});
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
result.unwrap();
}
#[test]
#[serial]
fn backup_dir_falls_back_to_home_dot_config() {
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let result = panic::catch_unwind(|| {
let dir = backup_dir().unwrap();
let home = dirs::home_dir().expect("home dir");
assert_eq!(dir, home.join(".config").join("lorum").join("backups"));
});
result.unwrap();
}
#[test]
#[serial]
fn create_backup_creates_file_and_prunes() {
let tmp = tempfile::tempdir().unwrap();
let xdg = tmp.path().join("xdg_config");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", &xdg);
}
let result = panic::catch_unwind(|| {
let source = tmp.path().join("settings.json");
fs::write(&source, r#"{"test": true}"#).unwrap();
let backup_path = create_backup("test-tool", &source).unwrap();
assert!(backup_path.exists());
assert_eq!(
fs::read_to_string(&backup_path).unwrap(),
r#"{"test": true}"#
);
assert!(backup_path.to_string_lossy().contains("test-tool-"));
assert_eq!(backup_path.extension().unwrap(), "json");
});
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
result.unwrap();
}
#[test]
#[serial]
fn restore_backup_restores_latest() {
let tmp = tempfile::tempdir().unwrap();
let xdg = tmp.path().join("xdg_config");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", &xdg);
}
let result = panic::catch_unwind(|| {
let source = tmp.path().join("settings.json");
fs::write(&source, "original").unwrap();
let _ = create_backup("restore-tool", &source).unwrap();
fs::write(&source, "modified").unwrap();
let _ = create_backup("restore-tool", &source).unwrap();
let target = tmp.path().join("restored.json");
restore_backup("restore-tool", &target).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "modified");
});
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
result.unwrap();
}
#[test]
#[serial]
fn restore_backup_from_path_works() {
let tmp = tempfile::tempdir().unwrap();
let xdg = tmp.path().join("xdg_config");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", &xdg);
}
let result = panic::catch_unwind(|| {
let source = tmp.path().join("settings.json");
fs::write(&source, "specific backup").unwrap();
let backup_path = create_backup("specific-tool", &source).unwrap();
let target = tmp.path().join("restored.json");
restore_backup_from_path(&backup_path, &target).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "specific backup");
});
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
result.unwrap();
}
#[test]
fn timestamp_format_is_yyyymmdd_hhmmss() {
let ts = timestamp();
assert_eq!(ts.len(), 15);
assert_eq!(ts.as_bytes()[8], b'-');
assert!(ts.chars().all(|c| c.is_ascii_digit() || c == '-'));
}
#[test]
fn epoch_to_utc_known_values() {
assert_eq!(epoch_to_utc(0), (1970, 1, 1, 0, 0, 0));
assert_eq!(epoch_to_utc(946_684_800), (2000, 1, 1, 0, 0, 0));
}
#[test]
fn epoch_to_utc_leap_year_2024() {
assert_eq!(epoch_to_utc(1_709_164_800), (2024, 2, 29, 0, 0, 0));
}
#[test]
fn epoch_to_utc_non_leap_century_2100() {
assert_eq!(epoch_to_utc(4_107_542_400), (2100, 3, 1, 0, 0, 0));
}
#[test]
fn epoch_to_utc_leap_century_2000() {
assert_eq!(epoch_to_utc(951_782_400), (2000, 2, 29, 0, 0, 0));
}
#[test]
fn create_and_list_backups() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("settings.json");
fs::write(&source, r#"{"test": true}"#).unwrap();
let backup_dir = dir.path().join("backups");
fs::create_dir_all(&backup_dir).unwrap();
let ts = timestamp();
let backup_name = format!("claude-code-{ts}.json");
let backup_path = backup_dir.join(&backup_name);
fs::copy(&source, &backup_path).unwrap();
assert!(backup_path.exists());
let contents = fs::read_to_string(&backup_path).unwrap();
assert_eq!(contents, r#"{"test": true}"#);
}
#[test]
fn list_backups_empty_when_no_dir() {
let result = list_backups("nonexistent-tool-xyz");
assert!(result.is_ok());
let backups = result.unwrap();
assert!(backups.is_empty());
}
#[test]
fn restore_from_latest_backup() {
let dir = tempfile::tempdir().unwrap();
let backup_dir = dir.path().join("backups");
fs::create_dir_all(&backup_dir).unwrap();
let old_backup = backup_dir.join("test-tool-20240101-000000.json");
let new_backup = backup_dir.join("test-tool-20241231-235959.json");
fs::write(&old_backup, "old content").unwrap();
fs::write(&new_backup, "new content").unwrap();
let backups = list_backups_in_dir("test-tool", &backup_dir).unwrap();
assert_eq!(backups.len(), 2);
assert_eq!(backups[0].path, new_backup);
assert_eq!(backups[1].path, old_backup);
let target = dir.path().join("restored.json");
fs::copy(&backups[0].path, &target).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "new content");
}
#[test]
fn restore_from_legacy_epoch_backup() {
let dir = tempfile::tempdir().unwrap();
let backup_dir = dir.path().join("backups");
fs::create_dir_all(&backup_dir).unwrap();
let epoch_backup = backup_dir.join("test-tool-2000.json");
fs::write(&epoch_backup, "epoch content").unwrap();
let backups = list_backups_in_dir("test-tool", &backup_dir).unwrap();
assert_eq!(backups.len(), 1);
assert_eq!(backups[0].time_display, "epoch: 2000");
let target = dir.path().join("restored.json");
fs::copy(&backups[0].path, &target).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "epoch content");
}
#[test]
fn mixed_new_and_legacy_backups_sort_correctly() {
let dir = tempfile::tempdir().unwrap();
let backup_dir = dir.path().join("backups");
fs::create_dir_all(&backup_dir).unwrap();
fs::write(backup_dir.join("test-tool-1000.json"), "old").unwrap();
fs::write(backup_dir.join("test-tool-20241231-235959.json"), "new").unwrap();
let backups = list_backups_in_dir("test-tool", &backup_dir).unwrap();
assert_eq!(backups.len(), 2);
assert!(backups[0].name.contains("20241231"));
assert!(backups[1].name.contains("1000"));
}
#[test]
fn cleanup_removes_old_backups() {
let dir = tempfile::tempdir().unwrap();
let backup_dir = dir.path().join("backups");
fs::create_dir_all(&backup_dir).unwrap();
for i in 0..=MAX_BACKUPS {
let ts = format!("2024{:02}{:02}-{:02}{:02}{:02}", 1, i + 1, 0, 0, 0);
let path = backup_dir.join(format!("test-tool-{ts}.json"));
fs::write(&path, format!("content-{i}")).unwrap();
}
assert_eq!(
list_backups_in_dir("test-tool", &backup_dir).unwrap().len(),
MAX_BACKUPS + 1
);
cleanup_old_backups("test-tool", &backup_dir).unwrap();
let remaining = list_backups_in_dir("test-tool", &backup_dir).unwrap();
assert_eq!(remaining.len(), MAX_BACKUPS);
}
#[test]
fn list_backups_filters_by_tool_name() {
let dir = tempfile::tempdir().unwrap();
let backup_dir = dir.path().join("backups");
fs::create_dir_all(&backup_dir).unwrap();
fs::write(backup_dir.join("claude-code-20240101-000000.json"), "").unwrap();
fs::write(backup_dir.join("codex-20240101-000000.toml"), "").unwrap();
fs::write(backup_dir.join("claude-code-20240102-000000.json"), "").unwrap();
let claude = list_backups_in_dir("claude-code", &backup_dir).unwrap();
let codex = list_backups_in_dir("codex", &backup_dir).unwrap();
assert_eq!(claude.len(), 2);
assert_eq!(codex.len(), 1);
}
#[test]
fn backup_info_contains_size() {
let dir = tempfile::tempdir().unwrap();
let backup_dir = dir.path().join("backups");
fs::create_dir_all(&backup_dir).unwrap();
let path = backup_dir.join("test-tool-20240101-000000.json");
fs::write(&path, "hello world").unwrap();
let backups = list_backups_in_dir("test-tool", &backup_dir).unwrap();
assert_eq!(backups.len(), 1);
assert_eq!(backups[0].size, 11);
}
}