use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UndoSnapshot {
pub path: String,
pub prior: Option<String>,
pub tool: String,
pub ts: i64,
}
pub fn journal_path() -> PathBuf {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
cwd.join(".aonyx").join("undo.jsonl")
}
pub fn append_snapshot(snap: UndoSnapshot) -> std::io::Result<()> {
append_snapshot_to(&journal_path(), snap)
}
pub fn append_snapshot_to(journal: &Path, snap: UndoSnapshot) -> std::io::Result<()> {
use std::io::Write;
if let Some(parent) = journal.parent() {
std::fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(&snap).map_err(std::io::Error::other)?;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(journal)?;
writeln!(f, "{line}")?;
Ok(())
}
pub fn pop_last_snapshot() -> std::io::Result<Option<UndoSnapshot>> {
pop_last_snapshot_from(&journal_path())
}
pub fn pop_last_snapshot_from(journal: &Path) -> std::io::Result<Option<UndoSnapshot>> {
if !journal.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(journal)?;
let mut lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect();
let Some(last_line) = lines.pop() else {
return Ok(None);
};
let snap: UndoSnapshot = serde_json::from_str(last_line).map_err(std::io::Error::other)?;
if lines.is_empty() {
let _ = std::fs::remove_file(journal);
} else {
let mut new_content = lines.join("\n");
new_content.push('\n');
std::fs::write(journal, new_content)?;
}
Ok(Some(snap))
}
pub fn list_snapshots(limit: usize) -> std::io::Result<Vec<UndoSnapshot>> {
list_snapshots_from(&journal_path(), limit)
}
pub fn list_snapshots_from(journal: &Path, limit: usize) -> std::io::Result<Vec<UndoSnapshot>> {
if !journal.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(journal)?;
let mut out = Vec::new();
for line in content.lines().rev() {
if line.trim().is_empty() {
continue;
}
if out.len() >= limit {
break;
}
if let Ok(snap) = serde_json::from_str::<UndoSnapshot>(line) {
out.push(snap);
}
}
Ok(out)
}
pub fn restore(snap: &UndoSnapshot) -> std::io::Result<()> {
match &snap.prior {
Some(content) => {
if let Some(parent) = Path::new(&snap.path).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(&snap.path, content)
}
None => {
if Path::new(&snap.path).exists() {
std::fs::remove_file(&snap.path)
} else {
Ok(())
}
}
}
}
pub fn snapshot(path: impl Into<String>, prior: Option<String>, tool: &str) -> UndoSnapshot {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
UndoSnapshot {
path: path.into(),
prior,
tool: tool.to_string(),
ts,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn pop_returns_none_when_no_journal() {
let dir = TempDir::new().unwrap();
let j = dir.path().join("undo.jsonl");
assert!(pop_last_snapshot_from(&j).unwrap().is_none());
}
#[test]
fn append_then_pop_round_trips_snapshot() {
let dir = TempDir::new().unwrap();
let j = dir.path().join("undo.jsonl");
append_snapshot_to(&j, snapshot("foo.rs", Some("before".into()), "fs_edit")).unwrap();
let popped = pop_last_snapshot_from(&j).unwrap().expect("some");
assert_eq!(popped.path, "foo.rs");
assert_eq!(popped.prior.as_deref(), Some("before"));
assert_eq!(popped.tool, "fs_edit");
assert!(pop_last_snapshot_from(&j).unwrap().is_none());
}
#[test]
fn pop_returns_lifo_order() {
let dir = TempDir::new().unwrap();
let j = dir.path().join("undo.jsonl");
append_snapshot_to(&j, snapshot("a", Some("a0".into()), "fs_edit")).unwrap();
append_snapshot_to(&j, snapshot("b", Some("b0".into()), "fs_edit")).unwrap();
append_snapshot_to(&j, snapshot("c", Some("c0".into()), "fs_edit")).unwrap();
assert_eq!(pop_last_snapshot_from(&j).unwrap().unwrap().path, "c");
assert_eq!(pop_last_snapshot_from(&j).unwrap().unwrap().path, "b");
assert_eq!(pop_last_snapshot_from(&j).unwrap().unwrap().path, "a");
assert!(pop_last_snapshot_from(&j).unwrap().is_none());
}
#[test]
fn restore_writes_prior_back_to_disk() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("hello.txt");
std::fs::write(&target, "after").unwrap();
let snap = snapshot(target.to_string_lossy(), Some("before".into()), "fs_edit");
restore(&snap).unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "before");
}
#[test]
fn list_snapshots_returns_empty_when_no_journal() {
let dir = TempDir::new().unwrap();
let j = dir.path().join("undo.jsonl");
assert!(list_snapshots_from(&j, 10).unwrap().is_empty());
}
#[test]
fn list_snapshots_returns_newest_first_capped_to_limit() {
let dir = TempDir::new().unwrap();
let j = dir.path().join("undo.jsonl");
append_snapshot_to(&j, snapshot("a", Some("a0".into()), "fs_edit")).unwrap();
append_snapshot_to(&j, snapshot("b", Some("b0".into()), "fs_edit")).unwrap();
append_snapshot_to(&j, snapshot("c", Some("c0".into()), "fs_edit")).unwrap();
let all = list_snapshots_from(&j, 10).unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].path, "c");
assert_eq!(all[1].path, "b");
assert_eq!(all[2].path, "a");
let capped = list_snapshots_from(&j, 2).unwrap();
assert_eq!(capped.len(), 2);
assert_eq!(capped[0].path, "c");
assert_eq!(capped[1].path, "b");
}
#[test]
fn list_snapshots_skips_malformed_lines() {
let dir = TempDir::new().unwrap();
let j = dir.path().join("undo.jsonl");
std::fs::write(
&j,
"{ not valid json }\n{\"path\":\"good\",\"prior\":null,\"tool\":\"fs_write\",\"ts\":1}\n",
)
.unwrap();
let snaps = list_snapshots_from(&j, 10).unwrap();
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].path, "good");
}
#[test]
fn restore_deletes_file_when_prior_is_none() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("new.txt");
std::fs::write(&target, "newly created").unwrap();
let snap = snapshot(target.to_string_lossy(), None, "fs_write");
restore(&snap).unwrap();
assert!(!target.exists());
}
}