pmacs/src/autosave.rs

724 lines
28 KiB
Rust

// autosave.rs --- periodic recovery copies + crash recovery (Arc 3 phase 3).
//! Every modified file buffer is periodically written to a private
//! recovery file under `$XDG_STATE_HOME/pmacs/autosave/`. If pmacs dies,
//! the next session notices the copy and offers `M-x recover-file`.
//! Emacs's `auto-save-mode` + `recover-file`.
//!
//! This module owns the parts Lua cannot do: enumerating **all** buffers'
//! paths (Lua has no per-buffer path getter), and the `FileMeta`
//! external-change guard (`FileMeta` is neither Lua-visible nor serde).
//! `builtin/runtime/autosave.lua` owns the cadence, the configurable
//! interval, and the recovery UX.
//!
//! Recovery files are written `0600` under a `0700` directory
//! (Q#AS11) — they hold *unsaved file contents*, a different class of
//! secret from saveplace's cursor offsets.
//!
//! Framing: docs/autosave-recovery-framing.md.
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::Path;
use mlua::Lua;
use serde::{Deserialize, Serialize};
use crate::buffer::BufferId;
use crate::file_io::FileMeta;
use crate::hash::sha256_hex;
use crate::lua_bindings::{SharedCore, StateDir};
/// Bump when the envelope shape changes incompatibly. A recovery file
/// with an unrecognized version reads as [`RecoveryStatus::Corrupt`] —
/// never silently applied.
pub const AUTOSAVE_VERSION: u32 = 1;
/// The header of a recovery file: one line of JSON, then `\n`, then the
/// raw buffer bytes (Q#AS4). One atomic write, so a crash can never leave
/// a torn header/contents pair.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Header {
version: u32,
/// The buffer's path, for provenance and orphan inspection.
path: String,
/// The origin file's identity when this copy was taken.
///
/// **Nullable**: a `[new file]` buffer (a path that does not exist on
/// disk yet) has no `file_meta`, and its unsaved contents are exactly
/// the work most worth recovering. `None` means "there was no file on
/// disk when this was autosaved".
origin: Option<Origin>,
}
/// `FileMeta` hand-serialized — it is not serde, and `SystemTime` has no
/// stable wire form. Stored as an offset from the Unix epoch so the
/// comparison is exact; we never reconstruct a `SystemTime`, only compare
/// these parts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
struct Origin {
mtime_secs: i64,
mtime_nanos: u32,
size: u64,
}
impl Origin {
fn from_meta(m: &FileMeta) -> Self {
let (mtime_secs, mtime_nanos) = match m.mtime.duration_since(std::time::UNIX_EPOCH) {
Ok(d) => (
i64::try_from(d.as_secs()).unwrap_or(i64::MAX),
d.subsec_nanos(),
),
// Pre-epoch mtimes are exotic but representable.
Err(e) => {
let d = e.duration();
(
i64::try_from(d.as_secs()).map_or(i64::MIN, |s| -s),
d.subsec_nanos(),
)
}
};
Self {
mtime_secs,
mtime_nanos,
size: m.size,
}
}
}
/// What a recovery file means for a given path (Q#AS5).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecoveryStatus {
/// No recovery file.
None,
/// The on-disk file is unchanged since the copy was taken (or is
/// still absent, for a `[new file]`), so the recovery is strictly
/// newer. The only status that is announced.
Fresh,
/// The file changed underneath us — externally edited, deleted, or
/// (for a `[new file]`) created by someone else. Never auto-offered:
/// silently clobbering it is the one unrecoverable mistake here.
Stale,
/// Unparseable or unrecognized version. Never offered, never errors;
/// `discard-recovery` removes it.
Corrupt,
}
impl RecoveryStatus {
/// The lowercase name Lua sees.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
RecoveryStatus::None => "none",
RecoveryStatus::Fresh => "fresh",
RecoveryStatus::Stale => "stale",
RecoveryStatus::Corrupt => "corrupt",
}
}
}
/// The `pmacs.state` key a path's recovery file lives under.
#[must_use]
pub fn key_for(path: &Path) -> String {
format!("autosave/{}", sha256_hex(&path.display().to_string()))
}
/// Per-session autosave bookkeeping.
#[derive(Default)]
pub struct AutosaveCache(RefCell<CacheInner>);
#[derive(Default)]
struct CacheInner {
/// Skip cache: `BufferId → (path_hash, revision)` (Q#AS8).
///
/// Keyed on the **path hash as well as the revision**, not the
/// revision alone: a buffer keeps its `BufferId` across a path change
/// (an LSP `WorkspaceEdit` rename calls `set_buffer_path`), so a
/// revision-only cache would skip the write, never create the
/// recovery file under the new key, and orphan the old one. It also
/// remembers *where a buffer's recovery currently lives*, which is
/// what makes cleanup work after a rename.
written: HashMap<BufferId, (String, u64)>,
/// Which buffer owns each recovery slot: `path_hash → BufferId`
/// (Q#AS12, Q#AS13).
///
/// Two roles in one map:
///
/// * **Absent** = the recovery file at that hash (if any) is
/// *unclaimed crash data* — this session did not write it. Sweeping
/// would overwrite the crash copy with the current buffer,
/// destroying exactly what autosave protects. So it blocks the
/// sweep until `recover-file` adopts it or `discard-recovery`
/// removes it, and neither save nor kill may delete it.
/// * **Present** = the slot belongs to exactly *one* buffer. A
/// recovery file is keyed by path (a later session knows only
/// paths, never old `BufferId`s), but `pmacs.buffer.from_file` can
/// open a *second* buffer on the same path. Both cannot be
/// protected under one key: the later write would win on disk while
/// both buffers believed themselves saved. So the first modified
/// buffer claims the slot and any other buffer on that path is
/// reported as conflicted, not silently mis-protected.
owner: HashMap<String, BufferId>,
}
/// Encode a header + contents into the one-file envelope.
fn encode(header: &Header, contents: &[u8]) -> Result<Vec<u8>, String> {
// serde_json's compact form never contains a raw newline, so the
// first `\n` unambiguously ends the header.
let mut out = serde_json::to_vec(header).map_err(|e| e.to_string())?;
debug_assert!(!out.contains(&b'\n'));
out.push(b'\n');
out.extend_from_slice(contents);
Ok(out)
}
/// Split an envelope at its **first** newline. Contents may contain
/// newlines and arbitrary non-UTF-8 bytes, so only the first one counts.
/// Returns `None` for anything malformed — the caller maps that to
/// [`RecoveryStatus::Corrupt`].
fn decode(bytes: &[u8]) -> Option<(Header, &[u8])> {
let nl = bytes.iter().position(|&b| b == b'\n')?;
let header: Header = serde_json::from_slice(&bytes[..nl]).ok()?;
if header.version != AUTOSAVE_VERSION {
return None;
}
Some((header, &bytes[nl + 1..]))
}
/// The configured state dir, if any (absent under tests / no HOME).
fn base_dir(lua: &Lua) -> Option<std::path::PathBuf> {
lua.app_data_ref::<StateDir>().map(|d| d.0.clone())
}
/// Classify the recovery file for `path` (Q#AS5's table).
#[must_use]
pub fn status(base: &Path, path: &Path) -> RecoveryStatus {
let key = key_for(path);
let Ok(Some(bytes)) = crate::state::read_bytes(base, &key) else {
return RecoveryStatus::None;
};
let Some((header, _)) = decode(&bytes) else {
return RecoveryStatus::Corrupt;
};
let on_disk = crate::file_io::current_meta(path).ok();
match (header.origin, on_disk) {
// Had an origin, file still there: fresh iff identity matches.
(Some(o), Some(cur)) if o == Origin::from_meta(&cur) => RecoveryStatus::Fresh,
// `[new file]`: fresh while it is still absent.
(None, None) => RecoveryStatus::Fresh,
// Everything else changed underneath us: the file was edited
// externally, deleted, or (for a `[new file]`) created by someone
// else. Never auto-offered.
_ => RecoveryStatus::Stale,
}
}
/// The recovered contents for `path`, if a parseable recovery exists.
/// Returns bytes for `Stale` too — the command warns and confirms.
#[must_use]
pub fn recover_bytes(base: &Path, path: &Path) -> Option<Vec<u8>> {
let bytes = crate::state::read_bytes(base, &key_for(path))
.ok()
.flatten()?;
let (_, contents) = decode(&bytes)?;
Some(contents.to_vec())
}
/// Delete the recovery file for `path` (idempotent).
pub fn discard(base: &Path, path: &Path) -> bool {
crate::state::remove(base, &key_for(path)).is_ok()
}
/// A buffer that needs a recovery copy written, gathered under the core
/// borrow so all IO happens after it is released.
struct Pending {
id: BufferId,
path: String,
path_hash: String,
revision: u64,
origin: Option<Origin>,
contents: Vec<u8>,
}
/// One autosave pass: write a recovery copy of every modified file
/// buffer whose contents changed since its last copy. Returns how many
/// were written.
///
/// Runs on the main thread; the two skips in Q#AS8 keep that bounded.
/// Unlike desktop-save this is **not** daemon-gated — autosave is
/// per-buffer, not per-frontend, and a daemon holds the unsaved work.
///
/// Returns `(written, blocked, conflicted)`:
///
/// * `blocked` — an **unclaimed** recovery file already sits at the
/// buffer's key (Q#AS12): crash data this session did not write.
/// * `conflicted` — another buffer already owns that path's recovery
/// slot (Q#AS13): two buffers visit the same file and only one can be
/// protected under a path-keyed recovery file.
///
/// # Errors
/// A state-write failure. Individual buffers never abort the pass.
pub fn sweep(lua: &Lua) -> Result<(usize, usize, usize), String> {
let Some(base) = base_dir(lua) else {
return Ok((0, 0, 0));
};
let core = lua
.app_data_ref::<SharedCore>()
.ok_or("no editor core")?
.clone();
let gathered = gather(lua, &core, &base)?;
let Gathered {
writes,
orphans,
live,
blocked,
conflicted,
} = gathered;
let mut written = 0usize;
{
let cache = lua
.app_data_ref::<AutosaveCache>()
.ok_or("no autosave cache")?;
let mut cache = cache.0.borrow_mut();
// A buffer whose path moved leaves its old recovery behind.
for old in orphans {
let _ = crate::state::remove(&base, &format!("autosave/{old}"));
cache.owner.remove(&old);
}
// GC: a buffer that left the registry (killed) takes its recovery
// copy with it. This is the backstop that covers `[new file]`
// buffers, which fire no `after-load` and so never get a
// per-buffer removal callback registered. Only the slot's owner
// may retire it.
let dead: Vec<(BufferId, String)> = cache
.written
.iter()
.filter(|(id, _)| !live.contains(id))
.map(|(id, (hash, _))| (*id, hash.clone()))
.collect();
for (id, hash) in dead {
if cache.owner.get(&hash) == Some(&id) {
let _ = crate::state::remove(&base, &format!("autosave/{hash}"));
cache.owner.remove(&hash);
}
cache.written.remove(&id);
}
for p in writes {
let header = Header {
version: AUTOSAVE_VERSION,
path: p.path,
origin: p.origin,
};
let bytes = encode(&header, &p.contents)?;
crate::state::write_private(&base, &format!("autosave/{}", p.path_hash), &bytes)
.map_err(|e| e.to_string())?;
cache.owner.insert(p.path_hash.clone(), p.id);
cache.written.insert(p.id, (p.path_hash, p.revision));
written += 1;
}
}
Ok((written, blocked, conflicted))
}
/// What one pass of the registry decided, before any IO.
struct Gathered {
writes: Vec<Pending>,
/// Recovery keys left behind by buffers whose path moved.
orphans: Vec<String>,
/// Every buffer still in the registry (drives the dead-buffer GC).
live: Vec<BufferId>,
blocked: usize,
conflicted: usize,
}
/// Walk the registry under a single borrow and decide what to write.
/// All IO happens in [`sweep`] after this returns, because a recovery
/// write must not run while the core is borrowed.
fn gather(lua: &Lua, core: &SharedCore, base: &Path) -> Result<Gathered, String> {
let mut writes: Vec<Pending> = Vec::new();
let mut orphans: Vec<String> = Vec::new();
let mut live: Vec<BufferId> = Vec::new();
let mut blocked = 0usize;
let mut conflicted = 0usize;
// Slots claimed earlier in *this* pass. `owner` is only updated in the
// write loop, so without this two dirty duplicates of one path would
// both queue a write to the same key.
let mut queued: HashMap<String, BufferId> = HashMap::new();
{
let cache = lua
.app_data_ref::<AutosaveCache>()
.ok_or("no autosave cache")?;
let cache = cache.0.borrow();
let c = core.borrow();
let reg = c.registry.borrow();
for &id in reg.ids() {
let Ok(buf) = reg.get(id) else { continue };
live.push(id);
// Skips scratch / *special* (no path). Includes `[new file]`
// buffers: path set, `file_meta` absent.
let Some(path) = buf.file_path() else {
continue;
};
if !buf.is_modified() {
continue;
}
let path_s = path.display().to_string();
let path_hash = sha256_hex(&path_s);
let revision = buf.revision();
// Exactly one buffer may own a path's recovery slot (Q#AS13):
// the file is keyed by path, so a second buffer on the same
// path cannot also be protected — the later write would win on
// disk while both believed themselves saved.
let slot_owner = cache
.owner
.get(&path_hash)
.or_else(|| queued.get(&path_hash));
match slot_owner {
Some(&owner_id) if owner_id != id => {
conflicted += 1;
continue;
}
Some(_) => {} // we already own the slot
None => {
// Unowned. Never clobber unclaimed crash data (Q#AS12):
// a recovery file this session did not write is the
// crash copy the user has not recovered yet.
if crate::state::exists(base, &format!("autosave/{path_hash}")).unwrap_or(false)
{
blocked += 1;
continue;
}
}
}
if let Some((prev_hash, prev_rev)) = cache.written.get(&id) {
if prev_hash == &path_hash && *prev_rev == revision {
continue; // unchanged since its last copy
}
if prev_hash != &path_hash {
// The path moved: the old key is now an orphan.
orphans.push(prev_hash.clone());
}
}
queued.insert(path_hash.clone(), id);
let len = buf.len();
let mut contents = vec![0u8; usize::try_from(len).unwrap_or(0)];
if len > 0 {
buf.snapshot_rope().slice(0, len, &mut contents);
}
writes.push(Pending {
id,
path: path_s,
path_hash,
revision,
origin: buf.file_meta().map(Origin::from_meta),
contents,
});
}
}
Ok(Gathered {
writes,
orphans,
live,
blocked,
conflicted,
})
}
/// Claim `buffer`'s recovery file for this session (Q#AS12). Called by
/// `recover-file` once the contents are installed in the buffer — the
/// crash data now lives in the buffer, so the copy is no longer
/// irreplaceable.
///
/// It records a `written` entry as well as the ownership, because after a
/// recover the file's contents *are* the buffer's contents. That makes
/// two things right at once: the skip cache correctly declines to rewrite
/// it, and `discard_buffer` can find and retire it — including from a
/// removal callback that fires *after* the buffer is gone, when there is
/// no path left to read (finding).
pub fn adopt(lua: &Lua, id: BufferId) {
let Some(core) = lua.app_data_ref::<SharedCore>() else {
return;
};
let entry = {
let c = core.borrow();
let reg = c.registry.borrow();
let Ok(buf) = reg.get(id) else { return };
let Some(p) = buf.file_path() else { return };
(sha256_hex(&p.display().to_string()), buf.revision())
};
drop(core);
if let Some(cache) = lua.app_data_ref::<AutosaveCache>() {
let mut cache = cache.0.borrow_mut();
let (hash, revision) = entry;
// Recovering into this buffer makes it the slot's owner — its
// contents are now what the file holds. Any previous owner of the
// slot (a duplicate buffer on the same path) loses the claim and
// will report as conflicted on the next sweep, which is truthful:
// the file no longer corresponds to it.
//
// Dropping the old owner's skip-cache entry maintains the
// invariant `written[id] ⟹ owner[hash] == id` (finding). Without
// it: adopt into B, then kill B without saving. `discard_buffer`
// frees the slot and deletes the file, but A's stale
// `written[A] = (hash, revA)` survives — so the next sweep sees A
// dirty at an unchanged revision, calls it "unchanged since its
// last copy", and leaves it unprotected until its next edit.
cache
.written
.retain(|&other, (h, _)| other == id || h != &hash);
cache.owner.insert(hash.clone(), id);
cache.written.insert(id, (hash, revision));
}
}
/// Delete the recovery file for `path` and drop every claim and skip-cache
/// entry pointing at it.
///
/// This is the **explicit** release path (`discard-recovery`), so it
/// ignores ownership — the user asked. Clearing the matching `written`
/// entries matters (finding): otherwise a still-dirty buffer would hit the
/// unchanged-`(path_hash, revision)` fast path on the next sweep and go
/// unprotected until its next edit.
pub fn discard_path(lua: &Lua, path: &Path) -> bool {
let Some(base) = base_dir(lua) else {
return false;
};
let hash = sha256_hex(&path.display().to_string());
if let Some(cache) = lua.app_data_ref::<AutosaveCache>() {
let mut cache = cache.0.borrow_mut();
cache.owner.remove(&hash);
cache.written.retain(|_, (h, _)| h != &hash);
}
discard(&base, path)
}
/// Retire the recovery copy of a specific **buffer** (Q#AS12).
///
/// Keyed by `BufferId`, not by the path captured when the buffer loaded:
/// it considers both the buffer's *current* path key (if it is still
/// live) and the key its last recovery was actually **written** under.
/// Those differ after a rename — an LSP `WorkspaceEdit` changes the path
/// while the `BufferId` stays — and a path-captured callback would leave
/// the real recovery file behind.
///
/// **Only keys this session owns are removed** (Q#AS12, finding). Saving
/// or killing a buffer you reopened after a crash must *not* destroy the
/// unclaimed recovery copy sitting at its path — you never recovered it.
/// Only `recover-file` (which adopts) or an explicit `discard-recovery`
/// releases unclaimed crash data.
pub fn discard_buffer(lua: &Lua, id: BufferId) {
let Some(base) = base_dir(lua) else {
return;
};
let mut keys: Vec<String> = Vec::new();
// The key the last sweep (or an adopt) recorded for this buffer. This
// is the only source that still works once the buffer is gone — a
// removal callback fires after it has left the registry.
if let Some(cache) = lua.app_data_ref::<AutosaveCache>()
&& let Some((hash, _)) = cache.0.borrow().written.get(&id)
{
keys.push(hash.clone());
}
// The buffer's current path, which may have moved since that write.
if let Some(core) = lua.app_data_ref::<SharedCore>() {
let c = core.borrow();
let reg = c.registry.borrow();
if let Ok(buf) = reg.get(id)
&& let Some(p) = buf.file_path()
{
keys.push(sha256_hex(&p.display().to_string()));
}
}
let Some(cache) = lua.app_data_ref::<AutosaveCache>() else {
return;
};
let mut cache = cache.0.borrow_mut();
// Retire only slots **this buffer** owns. Two guards in one check:
// * an unowned slot is unclaimed crash data — saving or killing the
// buffer you reopened after a crash must not destroy it (Q#AS12);
// * a slot owned by a *different* buffer belongs to that buffer's
// recovery — a duplicate buffer on the same path must not retire
// it (Q#AS13).
keys.retain(|h| cache.owner.get(h) == Some(&id));
for hash in &keys {
let _ = crate::state::remove(&base, &format!("autosave/{hash}"));
cache.owner.remove(hash);
}
// This buffer's own bookkeeping goes regardless: it is being saved or
// killed, so any skip-cache entry for it is spent.
cache.written.remove(&id);
}
/// Every open file buffer that has a recovery file, with its status
/// (Q#AS6). Enumerating in Rust is what makes this cover argv
/// `[new file]` buffers, which fire no hook at all — the Lua reporter
/// never has to know they exist.
///
/// Returns `(fresh_paths, corrupt_count)`.
#[must_use]
pub fn pending(lua: &Lua) -> (Vec<String>, usize) {
let mut fresh = Vec::new();
let mut corrupt = 0usize;
let (Some(base), Some(core)) = (base_dir(lua), lua.app_data_ref::<SharedCore>()) else {
return (fresh, corrupt);
};
// Collect paths first so the guard drops before any IO re-entrancy.
let paths: Vec<std::path::PathBuf> = {
let c = core.borrow();
let reg = c.registry.borrow();
reg.ids()
.iter()
.filter_map(|&id| reg.get(id).ok()?.file_path().map(Path::to_path_buf))
.collect()
};
for p in paths {
match status(&base, &p) {
RecoveryStatus::Fresh => fresh.push(p.display().to_string()),
RecoveryStatus::Corrupt => corrupt += 1,
RecoveryStatus::None | RecoveryStatus::Stale => {}
}
}
(fresh, corrupt)
}
#[cfg(test)]
mod tests {
use super::*;
fn header(origin: Option<Origin>) -> Header {
Header {
version: AUTOSAVE_VERSION,
path: "/tmp/a.rs".into(),
origin,
}
}
#[test]
fn envelope_round_trips_arbitrary_bytes() {
// Contents with newlines AND invalid UTF-8 — the reason we split
// at the first newline and read bytes, not a String.
let contents = [0xffu8, b'\n', b'a', 0x00, b'\n'];
let h = header(Some(Origin {
mtime_secs: 5,
mtime_nanos: 7,
size: 5,
}));
let bytes = encode(&h, &contents).unwrap();
let (got_h, got_c) = decode(&bytes).unwrap();
assert_eq!(got_h, h);
assert_eq!(got_c, &contents[..]);
}
#[test]
fn envelope_round_trips_null_origin() {
// A `[new file]` buffer: no origin meta.
let h = header(None);
let bytes = encode(&h, b"draft").unwrap();
let (got_h, got_c) = decode(&bytes).unwrap();
assert!(got_h.origin.is_none());
assert_eq!(got_c, b"draft");
}
#[test]
fn decode_rejects_malformed_and_wrong_version() {
assert!(decode(b"no newline at all").is_none());
assert!(decode(b"{not json}\nbody").is_none());
assert!(decode(b"\nbody").is_none(), "empty header");
let bad_version = br#"{"version":999,"path":"/x","origin":null}"#;
let mut bytes = bad_version.to_vec();
bytes.push(b'\n');
assert!(decode(&bytes).is_none(), "unrecognized version → corrupt");
}
#[test]
fn key_is_a_valid_state_key() {
let k = key_for(Path::new("/home/u/a b.rs"));
assert!(k.starts_with("autosave/"));
assert!(crate::state::validate_name(&k).is_ok());
}
#[test]
fn status_none_when_no_recovery_file() {
let dir = std::env::temp_dir().join(format!("pmacs-as-none-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(
status(&dir, Path::new("/tmp/nonexistent.rs")),
RecoveryStatus::None
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn status_corrupt_for_garbage_envelope() {
let dir = std::env::temp_dir().join(format!("pmacs-as-corrupt-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let target = Path::new("/tmp/whatever.rs");
crate::state::write_private(&dir, &key_for(target), b"garbage, no newline").unwrap();
assert_eq!(status(&dir, target), RecoveryStatus::Corrupt);
// And it is discardable.
assert!(discard(&dir, target));
assert_eq!(status(&dir, target), RecoveryStatus::None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn new_file_status_is_fresh_until_the_file_appears() {
let dir = std::env::temp_dir().join(format!("pmacs-as-newfile-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("draft.rs");
// origin: null — a `[new file]` buffer.
let bytes = encode(
&Header {
version: AUTOSAVE_VERSION,
path: target.display().to_string(),
origin: None,
},
b"unsaved draft",
)
.unwrap();
crate::state::write_private(&dir, &key_for(&target), &bytes).unwrap();
assert_eq!(status(&dir, &target), RecoveryStatus::Fresh, "file absent");
assert_eq!(
recover_bytes(&dir, &target).as_deref(),
Some(&b"unsaved draft"[..])
);
// Someone created the file meanwhile → stale, never auto-offered.
std::fs::write(&target, b"someone else's content").unwrap();
assert_eq!(status(&dir, &target), RecoveryStatus::Stale);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn existing_file_fresh_until_it_changes_on_disk() {
let dir = std::env::temp_dir().join(format!("pmacs-as-exist-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("a.rs");
std::fs::write(&target, b"on disk").unwrap();
let meta = crate::file_io::current_meta(&target).unwrap();
let bytes = encode(
&Header {
version: AUTOSAVE_VERSION,
path: target.display().to_string(),
origin: Some(Origin::from_meta(&meta)),
},
b"unsaved edits",
)
.unwrap();
crate::state::write_private(&dir, &key_for(&target), &bytes).unwrap();
assert_eq!(status(&dir, &target), RecoveryStatus::Fresh);
// Touch the file (different size ⇒ different identity).
std::fs::write(&target, b"changed underneath us").unwrap();
assert_eq!(status(&dir, &target), RecoveryStatus::Stale);
// Delete it entirely → still stale (the base is gone).
std::fs::remove_file(&target).unwrap();
assert_eq!(status(&dir, &target), RecoveryStatus::Stale);
std::fs::remove_dir_all(&dir).ok();
}
}