724 lines
28 KiB
Rust
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();
|
|
}
|
|
}
|