use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Local, NaiveDateTime};
const PREFIX: &str = "inkhaven_";
const SUFFIX: &str = ".hjson";
const TS_FORMAT: &str = "%Y%m%d_%H%M%S";
#[derive(Debug, Clone)]
pub struct BackupEntry {
pub path: PathBuf,
pub filename: String,
pub timestamp: Option<DateTime<Local>>,
pub size_bytes: u64,
}
pub fn list(project_root: &Path) -> Result<Vec<BackupEntry>> {
let dir = project_root.join(".config-backups");
if !dir.exists() {
return Ok(Vec::new());
}
let mut entries: Vec<BackupEntry> = Vec::new();
for raw in fs::read_dir(&dir)
.with_context(|| format!("read_dir {}", dir.display()))?
{
let entry = raw?;
let path = entry.path();
let metadata = entry.metadata().ok();
if !path.is_file() {
continue;
}
let filename = match path.file_name().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
if !filename.starts_with(PREFIX) || !filename.ends_with(SUFFIX) {
continue;
}
let ts_segment = &filename[PREFIX.len()..filename.len() - SUFFIX.len()];
let timestamp = NaiveDateTime::parse_from_str(ts_segment, TS_FORMAT)
.ok()
.and_then(|naive| naive.and_local_timezone(Local).single());
let size_bytes = metadata.map(|m| m.len()).unwrap_or(0);
entries.push(BackupEntry {
path,
filename,
timestamp,
size_bytes,
});
}
entries.sort_by(|a, b| match (a.timestamp, b.timestamp) {
(Some(ta), Some(tb)) => tb.cmp(&ta),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.filename.cmp(&b.filename),
});
Ok(entries)
}
pub fn read(entry: &BackupEntry) -> Result<String> {
fs::read_to_string(&entry.path)
.with_context(|| format!("read {}", entry.path.display()))
}
pub fn delete(entry: &BackupEntry) -> Result<()> {
fs::remove_file(&entry.path)
.with_context(|| format!("remove {}", entry.path.display()))
}
pub fn relative_time(entry: &BackupEntry, now: DateTime<Local>) -> String {
let Some(ts) = entry.timestamp else {
return "(unparsed timestamp)".to_string();
};
let delta = now.signed_duration_since(ts);
let total_secs = delta.num_seconds();
if total_secs < 0 {
return ts.format("%Y-%m-%d %H:%M:%S").to_string();
}
if total_secs < 60 {
return "just now".to_string();
}
let minutes = total_secs / 60;
if minutes < 60 {
return format!("{minutes} minute{} ago", if minutes == 1 { "" } else { "s" });
}
let hours = minutes / 60;
if hours < 24 {
return format!("{hours} hour{} ago", if hours == 1 { "" } else { "s" });
}
let days = hours / 24;
if days == 1 {
return "yesterday".to_string();
}
if days < 14 {
return format!("{days} days ago");
}
ts.format("%Y-%m-%d %H:%M").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn entry_at(ts: DateTime<Local>) -> BackupEntry {
BackupEntry {
path: PathBuf::from(format!("/tmp/inkhaven_{}.hjson", ts.format(TS_FORMAT))),
filename: format!("inkhaven_{}.hjson", ts.format(TS_FORMAT)),
timestamp: Some(ts),
size_bytes: 1024,
}
}
fn local_dt(y: i32, m: u32, d: u32, h: u32, mi: u32, s: u32) -> DateTime<Local> {
Local
.with_ymd_and_hms(y, m, d, h, mi, s)
.single()
.expect("valid local datetime")
}
#[test]
fn relative_time_just_now_under_60s() {
let ts = local_dt(2026, 5, 27, 10, 0, 0);
let now = local_dt(2026, 5, 27, 10, 0, 30);
assert_eq!(relative_time(&entry_at(ts), now), "just now");
}
#[test]
fn relative_time_minutes() {
let ts = local_dt(2026, 5, 27, 10, 0, 0);
let now = local_dt(2026, 5, 27, 10, 12, 0);
assert_eq!(relative_time(&entry_at(ts), now), "12 minutes ago");
}
#[test]
fn relative_time_singular_minute() {
let ts = local_dt(2026, 5, 27, 10, 0, 0);
let now = local_dt(2026, 5, 27, 10, 1, 30);
assert_eq!(relative_time(&entry_at(ts), now), "1 minute ago");
}
#[test]
fn relative_time_yesterday() {
let ts = local_dt(2026, 5, 26, 18, 0, 0);
let now = local_dt(2026, 5, 27, 18, 30, 0);
assert_eq!(relative_time(&entry_at(ts), now), "yesterday");
}
#[test]
fn relative_time_days() {
let ts = local_dt(2026, 5, 23, 9, 0, 0);
let now = local_dt(2026, 5, 27, 18, 0, 0);
assert!(relative_time(&entry_at(ts), now).starts_with("4 days ago"));
}
#[test]
fn relative_time_absolute_for_old_entries() {
let ts = local_dt(2025, 12, 1, 9, 0, 0);
let now = local_dt(2026, 5, 27, 18, 0, 0);
let out = relative_time(&entry_at(ts), now);
assert!(out.starts_with("2025-12-01"), "got: {out}");
}
#[test]
fn list_skips_unrelated_files_and_sorts_newest_first() {
let dir = tempdir_in_test();
let nested = dir.join(".config-backups");
fs::create_dir_all(&nested).unwrap();
fs::write(
nested.join("inkhaven_20260527_103000.hjson"),
"{}",
)
.unwrap();
fs::write(
nested.join("inkhaven_20260527_103045.hjson"),
"{}",
)
.unwrap();
fs::write(
nested.join("inkhaven_20260527_103030.hjson"),
"{}",
)
.unwrap();
fs::write(nested.join("readme.txt"), "ignore me").unwrap();
let entries = list(&dir).unwrap();
assert_eq!(entries.len(), 3);
assert!(entries[0].filename.ends_with("103045.hjson"));
assert!(entries[1].filename.ends_with("103030.hjson"));
assert!(entries[2].filename.ends_with("103000.hjson"));
}
#[test]
fn list_handles_missing_backup_dir() {
let dir = tempdir_in_test();
let entries = list(&dir).unwrap();
assert!(entries.is_empty());
}
fn tempdir_in_test() -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir()
.join(format!("inkhaven_config_tui_test_{nanos}"));
fs::create_dir_all(&dir).unwrap();
dir
}
}