use anyhow::{anyhow, Result};
use lazy_static::lazy_static;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
static COUNTER: AtomicU64 = AtomicU64::new(0);
struct Undo {
original: PathBuf,
backup: Option<PathBuf>,
existed: bool,
is_dir: bool,
}
fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ft = entry.file_type()?;
let from = entry.path();
let to = dst.join(entry.file_name());
if ft.is_dir() {
copy_tree(&from, &to)?;
} else if ft.is_file() {
std::fs::copy(&from, &to)?;
}
}
Ok(())
}
struct Frame {
id: String,
start: usize,
seen: HashSet<PathBuf>,
savepoints: Vec<(String, usize)>,
}
struct TxState {
dir: PathBuf,
undos: Vec<Undo>,
n: u64,
frames: Vec<Frame>,
}
fn restore_one(u: &Undo) -> bool {
if u.existed {
if let Some(b) = &u.backup {
if u.is_dir {
let _ = std::fs::remove_dir_all(&u.original);
copy_tree(b, &u.original).is_ok()
} else {
if let Some(parent) = u.original.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::copy(b, &u.original).is_ok()
}
} else {
false
}
} else if u.original.is_file() {
let _ = std::fs::remove_file(&u.original);
true
} else if u.original.is_dir() {
let _ = std::fs::remove_dir_all(&u.original);
true
} else {
false
}
}
lazy_static! {
static ref TX: Mutex<Option<TxState>> = Mutex::new(None);
}
fn absolutize(path: &str) -> PathBuf {
let p = Path::new(path);
if p.is_absolute() {
p.to_path_buf()
} else {
crate::safety::workspace_root().join(p)
}
}
fn new_id() -> String {
format!(
"tx_{}_{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst)
)
}
pub fn is_active() -> bool {
TX.lock().map(|g| g.is_some()).unwrap_or(false)
}
pub fn depth() -> usize {
TX.lock()
.ok()
.and_then(|g| g.as_ref().map(|t| t.frames.len()))
.unwrap_or(0)
}
pub fn status() -> Option<(String, usize)> {
TX.lock().ok().and_then(|g| {
g.as_ref().map(|t| {
let id = t.frames.last().map(|f| f.id.clone()).unwrap_or_default();
(id, t.undos.len())
})
})
}
pub fn begin() -> Result<String> {
let mut g = TX.lock().map_err(|_| anyhow!("tx lock poisoned"))?;
let id = new_id();
match g.as_mut() {
Some(tx) => {
let start = tx.undos.len();
tx.frames.push(Frame {
id: id.clone(),
start,
seen: HashSet::new(),
savepoints: Vec::new(),
});
}
None => {
let dir = crate::safety::workspace_root()
.join(".ae")
.join("tx")
.join(&id);
std::fs::create_dir_all(&dir)
.map_err(|e| anyhow!("tx_begin: cannot create journal dir: {}", e))?;
*g = Some(TxState {
dir,
undos: Vec::new(),
n: 0,
frames: vec![Frame {
id: id.clone(),
start: 0,
seen: HashSet::new(),
savepoints: Vec::new(),
}],
});
}
}
Ok(id)
}
pub fn snapshot(path: &str) {
let mut g = match TX.lock() {
Ok(g) => g,
Err(_) => return,
};
let tx = match g.as_mut() {
Some(t) => t,
None => return,
};
let abs = absolutize(path);
let already_captured = match tx.frames.last_mut() {
Some(f) => !f.seen.insert(abs.clone()),
None => return,
};
if already_captured {
return;
}
let existed = abs.exists();
let is_dir = existed && abs.is_dir();
let backup = if existed && abs.is_file() {
let bpath = tx.dir.join(format!("b{}", tx.n));
tx.n += 1;
std::fs::copy(&abs, &bpath).ok().map(|_| bpath)
} else if is_dir {
let bpath = tx.dir.join(format!("d{}", tx.n));
tx.n += 1;
copy_tree(&abs, &bpath).ok().map(|_| bpath)
} else {
None
};
tx.undos.push(Undo {
original: abs,
backup,
existed,
is_dir,
});
}
pub fn commit() -> Result<usize> {
let mut g = TX.lock().map_err(|_| anyhow!("tx lock poisoned"))?;
let tx = g
.as_mut()
.ok_or_else(|| anyhow!("tx_commit: no active transaction"))?;
let frame = tx
.frames
.pop()
.ok_or_else(|| anyhow!("tx_commit: no active transaction"))?;
let ops = tx.undos.len().saturating_sub(frame.start);
if tx.frames.is_empty() {
let dir = tx.dir.clone();
*g = None;
let _ = std::fs::remove_dir_all(&dir);
}
Ok(ops)
}
pub fn rollback() -> Result<usize> {
let mut g = TX.lock().map_err(|_| anyhow!("tx lock poisoned"))?;
let tx = g
.as_mut()
.ok_or_else(|| anyhow!("tx_rollback: no active transaction"))?;
let frame = tx
.frames
.pop()
.ok_or_else(|| anyhow!("tx_rollback: no active transaction"))?;
let tail: Vec<Undo> = tx.undos.drain(frame.start..).collect();
let mut restored = 0usize;
for u in tail.iter().rev() {
if restore_one(u) {
restored += 1;
}
}
if tx.frames.is_empty() {
let dir = tx.dir.clone();
*g = None;
let _ = std::fs::remove_dir_all(&dir);
}
Ok(restored)
}
pub fn savepoint(name: &str) -> Result<()> {
let mut g = TX.lock().map_err(|_| anyhow!("tx lock poisoned"))?;
let tx = g
.as_mut()
.ok_or_else(|| anyhow!("tx_savepoint: no active transaction"))?;
let idx = tx.undos.len();
let frame = tx
.frames
.last_mut()
.ok_or_else(|| anyhow!("tx_savepoint: no active transaction"))?;
frame.savepoints.push((name.to_string(), idx));
Ok(())
}
pub fn rollback_to(name: &str) -> Result<usize> {
let mut g = TX.lock().map_err(|_| anyhow!("tx lock poisoned"))?;
let tx = g
.as_mut()
.ok_or_else(|| anyhow!("tx_rollback_to: no active transaction"))?;
let (idx, pos) = {
let frame = tx
.frames
.last()
.ok_or_else(|| anyhow!("tx_rollback_to: no active transaction"))?;
let pos = frame
.savepoints
.iter()
.rposition(|(n, _)| n == name)
.ok_or_else(|| anyhow!("tx_rollback_to: no savepoint named '{}'", name))?;
(frame.savepoints[pos].1, pos)
};
let tail: Vec<Undo> = tx.undos.drain(idx..).collect();
let mut restored = 0usize;
{
let frame = tx.frames.last_mut().unwrap();
for u in tail.iter().rev() {
frame.seen.remove(&u.original);
if restore_one(u) {
restored += 1;
}
}
frame.savepoints.truncate(pos + 1);
}
Ok(restored)
}