use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Local, NaiveDateTime};
const PREFIX: &str = "prompts_";
const SUFFIX: &str = ".hjson";
const TS_FORMAT: &str = "%Y%m%d_%H%M%S";
pub const BACKUP_DIR: &str = ".prompts-backups";
#[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(BACKUP_DIR);
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/prompts_{}.hjson",
ts.format(TS_FORMAT)
)),
filename: format!("prompts_{}.hjson", ts.format(TS_FORMAT)),
timestamp: Some(ts),
size_bytes: 2048,
}
}
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")
}
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_prompts_backup_test_{nanos}"));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn relative_time_just_now() {
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_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 list_sorts_newest_first_skips_unrelated() {
let dir = tempdir_in_test();
let nested = dir.join(BACKUP_DIR);
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("prompts_20260527_103000.hjson"), "{}").unwrap();
fs::write(nested.join("prompts_20260527_103045.hjson"), "{}").unwrap();
fs::write(nested.join("prompts_20260527_103030.hjson"), "{}").unwrap();
fs::write(nested.join("inkhaven_20260527_103045.hjson"), "{}").unwrap();
fs::write(nested.join("prompts_20260527_103045.json"), "{}").unwrap();
fs::write(nested.join("readme.txt"), "ignore").unwrap();
let entries = list(&dir).unwrap();
assert_eq!(entries.len(), 3);
assert!(entries[0].filename.ends_with("103045.hjson"));
assert!(entries[2].filename.ends_with("103000.hjson"));
}
#[test]
fn list_missing_dir_returns_empty() {
let dir = tempdir_in_test();
let entries = list(&dir).unwrap();
assert!(entries.is_empty());
}
}