use std::cell::RefCell;
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
const MAX_RECORDED_INPUT_CHARS: usize = 2_000;
fn home_dir() -> PathBuf {
let raw = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(raw)
}
pub(crate) fn trash_dir() -> PathBuf {
home_dir().join(".claudette").join("trash")
}
pub(crate) fn transcript_path() -> PathBuf {
home_dir()
.join(".claudette")
.join("transcript")
.join("actions.jsonl")
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis())
}
fn trash_target_for(original: &Path) -> std::io::Result<PathBuf> {
let dir = trash_dir();
fs::create_dir_all(&dir)?;
let filename = original.file_name().map_or_else(
|| "unnamed".to_string(),
|f| f.to_string_lossy().to_string(),
);
let base = format!("{}-{filename}", now_ms());
let mut candidate = dir.join(&base);
let mut n = 0u32;
while candidate.exists() {
n += 1;
candidate = dir.join(format!("{base}-{n}"));
}
Ok(candidate)
}
thread_local! {
static PENDING_UNDO: RefCell<Option<Value>> = const { RefCell::new(None) };
}
fn set_pending_undo(trash: &Path, original: &Path) {
let v = json!({
"trash": trash.display().to_string(),
"original": original.display().to_string(),
});
PENDING_UNDO.with(|p| *p.borrow_mut() = Some(v));
}
#[must_use]
pub fn take_pending_undo() -> Option<Value> {
PENDING_UNDO.with(|p| p.borrow_mut().take())
}
pub fn move_to_trash(original: &Path) -> std::io::Result<PathBuf> {
let target = trash_target_for(original)?;
if fs::rename(original, &target).is_err() {
fs::copy(original, &target)?;
fs::remove_file(original)?;
}
set_pending_undo(&target, original);
Ok(target)
}
pub fn snapshot_to_trash(original: &Path) -> std::io::Result<PathBuf> {
let target = trash_target_for(original)?;
fs::copy(original, &target)?;
set_pending_undo(&target, original);
Ok(target)
}
pub fn record(tool: &str, input: &str, undo: Option<Value>) {
let capped: String = if input.chars().count() > MAX_RECORDED_INPUT_CHARS {
let head: String = input.chars().take(MAX_RECORDED_INPUT_CHARS).collect();
format!("{head}… [capped, {} chars total]", input.chars().count())
} else {
input.to_string()
};
let line = json!({
"ts": now_ms() as u64,
"tool": tool,
"input": capped,
"undo": undo.unwrap_or(Value::Null),
});
let path = transcript_path();
let write = (|| -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
writeln!(f, "{line}")
})();
if let Err(e) = write {
eprintln!("transcript: record failed (tool call unaffected): {e}");
}
}
pub fn undo_last() -> Result<String, String> {
let path = transcript_path();
let raw = fs::read_to_string(&path)
.map_err(|_| "nothing to undo (no actions recorded yet)".to_string())?;
let entries: Vec<Value> = raw
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
let undone: Vec<&str> = entries
.iter()
.filter(|e| e.get("tool").and_then(Value::as_str) == Some("undo"))
.filter_map(|e| e.get("undone_trash").and_then(Value::as_str))
.collect();
let target = entries.iter().rev().find(|e| {
e.get("undo")
.and_then(|u| u.get("trash"))
.and_then(Value::as_str)
.is_some_and(|t| !undone.contains(&t))
});
let Some(entry) = target else {
return Err(
"nothing to undo (no recorded action carries a recoverable pre-image)".to_string(),
);
};
let ts = entry.get("ts").and_then(Value::as_u64).unwrap_or(0);
let tool = entry
.get("tool")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let undo_ref = entry.get("undo").cloned().unwrap_or(Value::Null);
let trash = undo_ref
.get("trash")
.and_then(Value::as_str)
.ok_or("transcript entry has a malformed undo ref (no trash path)")?;
let original = undo_ref
.get("original")
.and_then(Value::as_str)
.ok_or("transcript entry has a malformed undo ref (no original path)")?;
let trash_p = Path::new(trash);
if !trash_p.exists() {
return Err(format!(
"cannot undo {tool}: the trash copy is gone ({trash})"
));
}
let original_p = Path::new(original);
if let Some(parent) = original_p.parent() {
fs::create_dir_all(parent).map_err(|e| format!("cannot undo: {e}"))?;
}
let mut backed_up: Option<String> = None;
if original_p.exists() {
let backup = trash_target_for(original_p).map_err(|e| {
format!("cannot undo {tool}: failed to back up the current {original} first ({e})")
})?;
fs::copy(original_p, &backup).map_err(|e| {
format!("cannot undo {tool}: failed to back up the current {original} first ({e})")
})?;
backed_up = Some(backup.display().to_string());
}
fs::copy(trash_p, original_p)
.map_err(|e| format!("cannot undo {tool}: restore to {original} failed ({e})"))?;
let line = json!({
"ts": now_ms() as u64,
"tool": "undo",
"input": "",
"undo": Value::Null,
"undone_ts": ts,
"undone_trash": trash,
});
let marker = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut f| writeln!(f, "{line}"));
if let Err(e) = marker {
eprintln!(
"transcript: undo marker write failed ({e}) — a repeat /undo will \
re-restore the same entry"
);
}
Ok(match backed_up {
Some(b) => format!(
"restored {original} from trash (undid {tool}; the trash copy at {trash} is kept). \
The content that was there is backed up at {b}."
),
None => format!(
"restored {original} from trash (undid {tool}; the trash copy at {trash} is kept)"
),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::with_temp_home;
fn write_tmp(home: &Path, name: &str, content: &str) -> PathBuf {
let p = home.join(name);
fs::write(&p, content).unwrap();
p
}
#[test]
fn move_to_trash_relocates_and_survives_name_collision() {
with_temp_home(|home| {
let a = write_tmp(home, "victim.md", "first");
let t1 = move_to_trash(&a).unwrap();
assert!(!a.exists(), "original should be gone");
assert_eq!(fs::read_to_string(&t1).unwrap(), "first");
let b = write_tmp(home, "victim.md", "second");
let t2 = move_to_trash(&b).unwrap();
assert_ne!(t1, t2, "collision must produce distinct trash names");
assert_eq!(fs::read_to_string(&t2).unwrap(), "second");
assert_eq!(fs::read_to_string(&t1).unwrap(), "first");
});
}
#[test]
fn snapshot_keeps_the_original_in_place() {
with_temp_home(|home| {
let a = write_tmp(home, "live.txt", "pre-image");
let t = snapshot_to_trash(&a).unwrap();
assert!(a.exists(), "snapshot must not remove the original");
assert_eq!(fs::read_to_string(&t).unwrap(), "pre-image");
});
}
#[test]
fn record_and_undo_round_trip() {
with_temp_home(|home| {
let a = write_tmp(home, "note.md", "precious");
let _t = move_to_trash(&a).unwrap();
record("note_delete", r#"{"id":"note.md"}"#, take_pending_undo());
assert!(!a.exists());
let msg = undo_last().expect("undo should succeed");
assert!(a.exists(), "undo must restore the file: {msg}");
assert_eq!(fs::read_to_string(&a).unwrap(), "precious");
let err = undo_last().unwrap_err();
assert!(err.contains("nothing to undo"), "got: {err}");
});
}
#[test]
fn undo_twice_restores_both_entries_of_a_same_millisecond_batch() {
with_temp_home(|home| {
let a = write_tmp(home, "a.md", "AAA");
let b = write_tmp(home, "b.md", "BBB");
let _ = move_to_trash(&a).unwrap();
record("note_delete", "a", take_pending_undo());
let _ = move_to_trash(&b).unwrap();
record("note_delete", "b", take_pending_undo());
let msg1 = undo_last().expect("undo #1");
assert!(msg1.contains("b.md"), "most recent first: {msg1}");
assert!(b.exists());
let msg2 = undo_last().expect("undo #2 — same-ms sibling must remain reachable");
assert!(msg2.contains("a.md"), "got: {msg2}");
assert!(a.exists());
let err = undo_last().unwrap_err();
assert!(err.contains("nothing to undo"), "got: {err}");
});
}
#[test]
fn undo_never_destroys_newer_content_at_the_original_path() {
with_temp_home(|home| {
let f = write_tmp(home, "doc.txt", "ORIGINAL");
let _ = snapshot_to_trash(&f).unwrap();
record("write_file", "doc.txt v2", take_pending_undo());
fs::write(&f, "VERSION_TWO").unwrap();
fs::write(&f, "VERSION_THREE_NEWEST").unwrap();
let msg = undo_last().expect("undo should succeed");
assert_eq!(fs::read_to_string(&f).unwrap(), "ORIGINAL");
assert!(
msg.contains("backed up"),
"undo must report the pre-restore backup: {msg}"
);
let trash = home.join(".claudette").join("trash");
let recovered = std::fs::read_dir(&trash)
.unwrap()
.map(|e| std::fs::read_to_string(e.unwrap().path()).unwrap_or_default())
.any(|c| c == "VERSION_THREE_NEWEST");
assert!(
recovered,
"the clobbered newer content must survive in trash"
);
});
}
#[test]
fn undo_with_no_transcript_says_so() {
with_temp_home(|_| {
let err = undo_last().unwrap_err();
assert!(err.contains("nothing to undo"), "got: {err}");
});
}
#[test]
fn undo_skips_entries_without_refs_and_walks_backwards() {
with_temp_home(|home| {
let a = write_tmp(home, "a.md", "AAA");
let _ = move_to_trash(&a).unwrap();
record("note_delete", "a", take_pending_undo());
record("todo_add", "whatever", None);
let msg = undo_last().expect("should undo the delete underneath");
assert!(msg.contains("a.md"), "got: {msg}");
assert!(a.exists());
});
}
#[test]
fn record_caps_oversized_input() {
with_temp_home(|_| {
let huge = "x".repeat(10_000);
record("write_file", &huge, None);
let raw = fs::read_to_string(transcript_path()).unwrap();
let v: Value = serde_json::from_str(raw.lines().next().unwrap()).unwrap();
let stored = v.get("input").and_then(Value::as_str).unwrap();
assert!(stored.chars().count() < 2_100, "input must be capped");
assert!(stored.contains("capped"), "cap marker missing");
});
}
#[test]
fn pending_undo_is_take_once() {
with_temp_home(|home| {
let a = write_tmp(home, "x.txt", "1");
let _ = move_to_trash(&a).unwrap();
assert!(take_pending_undo().is_some());
assert!(
take_pending_undo().is_none(),
"second take must be empty — no stale undo may leak to the next tool call"
);
});
}
}