use anyhow::{anyhow, Result};
use chrono::{DateTime, Local, NaiveDateTime, TimeZone};
use std::fs;
use std::path::{Path, PathBuf};
pub fn parse_id(id: &str) -> Option<DateTime<Local>> {
let naive = NaiveDateTime::parse_from_str(id, "%Y-%m-%d_%H-%M").ok()?;
Local.from_local_datetime(&naive).single()
}
pub fn snapshot_dir() -> PathBuf {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join("disky")
}
pub fn new_snapshot_path() -> Result<String> {
let dir = snapshot_dir();
fs::create_dir_all(&dir)?;
let ts = Local::now().format("%Y-%m-%d_%H-%M").to_string();
Ok(dir
.join(format!("{}.db", ts))
.to_string_lossy()
.into_owned())
}
pub fn latest_snapshot() -> Option<String> {
let dir = snapshot_dir();
let mut entries: Vec<_> = fs::read_dir(&dir)
.ok()?
.flatten()
.filter(|e| e.path().extension().map(|x| x == "db").unwrap_or(false))
.collect();
entries.sort_by_key(|e| e.file_name());
entries
.last()
.map(|e| e.path().to_string_lossy().into_owned())
}
pub fn list_snapshots() -> Vec<(String, u64)> {
let dir = snapshot_dir();
let mut entries: Vec<_> = fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().extension().map(|x| x == "db").unwrap_or(false))
.map(|e| {
let size = e.metadata().map(|m| m.len()).unwrap_or(0);
(e.path().to_string_lossy().into_owned(), size)
})
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
entries
}
pub fn resolve(spec: &str) -> Result<String> {
if spec == "@latest" {
return latest_snapshot()
.ok_or_else(|| anyhow!("no snapshot found; run `disky scan` first (not found)"));
}
if let Some(rest) = spec.strip_prefix("@latest~") {
let n: usize = rest
.parse()
.map_err(|_| anyhow!("invalid snapshot ref '{}' (expected @latest~<N>)", spec))?;
let snaps = list_snapshots();
if snaps.is_empty() {
return Err(anyhow!(
"no snapshot found; run `disky scan` first (not found)"
));
}
let idx = snaps.len().checked_sub(n + 1).ok_or_else(|| {
anyhow!(
"@latest~{} out of range — only {} snapshots",
n,
snaps.len()
)
})?;
return Ok(snaps[idx].0.clone());
}
if spec.contains('/') || Path::new(spec).extension().is_some() {
return Ok(spec.to_string());
}
let candidate = snapshot_dir().join(format!("{}.db", spec));
if candidate.exists() {
return Ok(candidate.to_string_lossy().into_owned());
}
Err(anyhow!(
"snapshot '{}' not found in {} (not found)",
spec,
snapshot_dir().display()
))
}
pub fn id_for(path: &str) -> Option<String> {
Path::new(path)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_for_extracts_file_stem() {
assert_eq!(
id_for("/var/db/2026-05-15_11-56.db"),
Some("2026-05-15_11-56".to_string())
);
assert_eq!(id_for("snap.db"), Some("snap".to_string()));
}
#[test]
fn resolve_returns_explicit_path_unchanged() {
let path = "/tmp/explicit.db";
assert_eq!(resolve(path).unwrap(), path);
}
#[test]
fn resolve_returns_path_with_extension_unchanged() {
assert_eq!(resolve("local.db").unwrap(), "local.db");
}
#[test]
fn parse_id_handles_canonical_format() {
assert!(parse_id("2026-05-15_11-56").is_some());
assert!(parse_id("not-a-date").is_none());
assert!(parse_id("2026-13-99_99-99").is_none());
}
#[test]
fn resolve_missing_id_returns_not_found_message() {
let err = resolve("nonexistent-id-xyz").unwrap_err();
let s = format!("{:#}", err);
assert!(s.contains("not found"));
}
}