//! Autosave + crash-recovery acceptance (Arc 3 phase 3). //! //! Each test injects a private tempdir `StateDir` (integration tests link //! the lib without `cfg(test)`), so nothing touches a developer's real //! state dir. Sweeps are driven directly rather than through the timer, //! so the 1-second interval floor never slows the suite. //! //! Framing: `docs/autosave-recovery-framing.md`. use pmacs::editor::EditorState; use pmacs::lua_bindings::StateDir; use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; fn fresh_state_dir() -> PathBuf { static SEQ: AtomicUsize = AtomicUsize::new(0); let dir = std::env::temp_dir().join(format!( "pmacs-autosave-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed) )); std::fs::create_dir_all(&dir).unwrap(); dir } fn editor(state_dir: &std::path::Path) -> EditorState { let s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host .lua() .set_app_data(StateDir(state_dir.to_path_buf())); s } fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String { let p = dir.join(name); std::fs::write(&p, body).unwrap(); p.display().to_string() } fn exec(s: &EditorState, src: &str) { s.lua_host.lua().load(src.to_string()).exec().unwrap(); } fn eval(s: &EditorState, src: &str) -> T { s.lua_host.lua().load(src.to_string()).eval().unwrap() } /// Force a sweep; returns how many buffers were written. fn sweep(s: &EditorState) -> i64 { let (written, _blocked): (i64, i64) = eval(s, "return pmacs.autosave.sweep()"); written } /// Force a sweep; returns `(written, blocked)`. fn sweep2(s: &EditorState) -> (i64, i64) { eval(s, "return pmacs.autosave.sweep()") } /// Force a sweep; returns `(written, blocked, conflicted)`. fn sweep3(s: &EditorState) -> (i64, i64, i64) { eval(s, "return pmacs.autosave.sweep()") } /// Open `path`, dirty it, then open a SECOND buffer on the same path via /// `from_file` (which does not dedup) and dirty that differently. /// Returns with the duplicate active. fn two_buffers_one_path(s: &EditorState, path: &str) { exec(s, &format!("_G.a = pmacs.buffer.find_or_open({path:?})")); exec(s, "pmacs.window.buffer():insert(0, 'AAA ')"); exec(s, &format!("_G.b = pmacs.buffer.from_file({path:?})")); exec(s, "pmacs.window.buffer():insert(0, 'BBB ')"); } fn recovered(s: &EditorState, path: &str) -> Vec { let b: mlua::String = eval( s, &format!("return pmacs.autosave._recover_bytes({path:?})"), ); b.as_bytes().to_vec() } fn status(s: &EditorState, path: &str) -> String { eval(s, &format!("return pmacs.autosave._status({path:?})")) } /// Open a file and dirty it by `n` inserted bytes at the front. fn open_and_dirty(s: &EditorState, path: &str, text: &str) { exec( s, &format!("pmacs.buffer.find_or_open({path:?}); pmacs.window.buffer():insert(0, {text:?})"), ); } #[test] fn sweep_writes_recovery_for_a_modified_file_buffer() { let dir = fresh_state_dir(); let s = editor(&dir); let f = write_file(&dir, "a.txt", "on disk\n"); open_and_dirty(&s, &f, "unsaved "); assert_eq!(sweep(&s), 1, "one modified file buffer written"); assert_eq!(status(&s, &f), "fresh"); // The recovery contents are the buffer's, not the file's. let bytes: mlua::String = eval(&s, &format!("return pmacs.autosave._recover_bytes({f:?})")); assert_eq!(&*bytes.as_bytes(), b"unsaved on disk\n"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn envelope_round_trips_non_utf8_contents() { let dir = fresh_state_dir(); let s = editor(&dir); let f = write_file(&dir, "bin.dat", ""); // 0xff is invalid UTF-8; the envelope reads bytes, not a String. exec( &s, &format!( "pmacs.buffer.find_or_open({f:?}); pmacs.window.buffer():insert(0, '\\255\\n\\0a')" ), ); assert_eq!(sweep(&s), 1); let bytes: mlua::String = eval(&s, &format!("return pmacs.autosave._recover_bytes({f:?})")); assert_eq!(&*bytes.as_bytes(), &[0xffu8, b'\n', 0x00, b'a'][..]); std::fs::remove_dir_all(&dir).ok(); } #[test] fn sweep_skips_clean_scratch_and_unchanged_buffers() { let dir = fresh_state_dir(); let s = editor(&dir); let f = write_file(&dir, "a.txt", "hello\n"); // A clean file buffer + the scratch buffer: nothing to write. exec(&s, &format!("pmacs.buffer.find_or_open({f:?})")); assert_eq!(sweep(&s), 0, "clean buffer and scratch are skipped"); // Dirty it → one write. Sweeping again with no further edit → zero // (the (path_hash, revision) skip cache). exec(&s, "pmacs.window.buffer():insert(0, 'x')"); assert_eq!(sweep(&s), 1); assert_eq!(sweep(&s), 0, "unchanged since last copy → no rewrite"); // Another edit → written again. exec(&s, "pmacs.window.buffer():insert(0, 'y')"); assert_eq!(sweep(&s), 1); std::fs::remove_dir_all(&dir).ok(); } #[test] fn path_change_writes_new_key_and_discards_the_old() { let dir = fresh_state_dir(); let s = editor(&dir); let old = write_file(&dir, "old.txt", "body\n"); let new = dir.join("new.txt").display().to_string(); open_and_dirty(&s, &old, "dirty "); assert_eq!(sweep(&s), 1); assert_eq!(status(&s, &old), "fresh"); // Rename WITHOUT editing the buffer — what an LSP WorkspaceEdit // rename does: the file moves on disk (preserving mtime/size) and the // buffer keeps its BufferId *and* its revision, only its path changes. std::fs::rename(&old, &new).unwrap(); { let id = s.core.borrow().active_buffer_id(); s.core .borrow_mut() .set_buffer_path(id, Some(PathBuf::from(&new))); } // A revision-only cache would skip this write, never create the // recovery under the new key, and orphan the old one. assert_eq!(sweep(&s), 1, "path change forces a rewrite"); assert_eq!(status(&s, &new), "fresh", "new key written"); assert_eq!(status(&s, &old), "none", "old key discarded"); let bytes: mlua::String = eval( &s, &format!("return pmacs.autosave._recover_bytes({new:?})"), ); assert_eq!(&*bytes.as_bytes(), b"dirty body\n"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn new_file_buffer_is_swept_with_null_origin_and_recovers() { let dir = fresh_state_dir(); let s = editor(&dir); // A `[new file]`: a path with no file on disk, so no `file_meta`. // Lua's find_or_open *errors* on a missing path, so this is built the // way argv `pmacs draft.txt` does — an empty buffer with a path. let missing = dir.join("draft.txt"); exec( &s, "_G.nb = pmacs.buffer.create('draft.txt'); pmacs.window.switch_buffer(_G.nb)", ); { let id = s.core.borrow().active_buffer_id(); s.core .borrow_mut() .set_buffer_path(id, Some(missing.clone())); } // Typing into it is what makes it modified (and worth recovering). exec(&s, "pmacs.window.buffer():insert(0, 'unsaved draft')"); let p = missing.display().to_string(); assert_eq!(sweep(&s), 1, "a new-file buffer is swept"); // origin is null → fresh while the file is still absent. assert_eq!(status(&s, &p), "fresh"); let bytes: mlua::String = eval(&s, &format!("return pmacs.autosave._recover_bytes({p:?})")); assert_eq!(&*bytes.as_bytes(), b"unsaved draft"); // Someone creates the file meanwhile → stale, never auto-offered. std::fs::write(&missing, b"theirs").unwrap(); assert_eq!(status(&s, &p), "stale"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn external_change_makes_recovery_stale() { let dir = fresh_state_dir(); let s = editor(&dir); let f = write_file(&dir, "a.txt", "original\n"); open_and_dirty(&s, &f, "mine "); assert_eq!(sweep(&s), 1); assert_eq!(status(&s, &f), "fresh"); // Someone else edits the file on disk. std::fs::write(&f, b"theirs, quite different\n").unwrap(); assert_eq!(status(&s, &f), "stale", "never auto-offered"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn corrupt_recovery_is_typed_quiet_and_discardable() { let dir = fresh_state_dir(); let s = editor(&dir); let f = write_file(&dir, "a.txt", "body\n"); exec(&s, &format!("pmacs.buffer.find_or_open({f:?})")); // Plant a malformed envelope under the right key. let key = pmacs::autosave::key_for(std::path::Path::new(&f)); pmacs::state::write_private(&dir, &key, b"garbage without a newline").unwrap(); assert_eq!(status(&s, &f), "corrupt"); // The aggregate report must not error or announce it. let (fresh, corrupt): (Vec, i64) = eval(&s, "return pmacs.autosave._pending()"); assert!(fresh.is_empty(), "corrupt is never offered"); assert_eq!(corrupt, 1, "counted separately"); // And it is discardable. exec(&s, &format!("pmacs.autosave._discard({f:?})")); assert_eq!(status(&s, &f), "none"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn pending_aggregates_and_names_a_single_file() { let dir = fresh_state_dir(); let s = editor(&dir); let mut paths = Vec::new(); for i in 0..3 { let f = write_file(&dir, &format!("f{i}.txt"), "body\n"); open_and_dirty(&s, &f, "x"); paths.push(f); } assert_eq!(sweep(&s), 3); let (fresh, corrupt): (Vec, i64) = eval(&s, "return pmacs.autosave._pending()"); assert_eq!(fresh.len(), 3, "all three reported in ONE call"); assert_eq!(corrupt, 0); std::fs::remove_dir_all(&dir).ok(); } #[test] fn sweep_never_overwrites_unclaimed_crash_recovery() { let dir = fresh_state_dir(); // Session 1 crashes with unsaved work: a recovery copy is on disk. let s1 = editor(&dir); let f = write_file(&dir, "a.txt", "on disk\n"); open_and_dirty(&s1, &f, "CRASH WORK "); assert_eq!(sweep(&s1), 1); let crash_copy = recovered(&s1, &f); assert_eq!(&crash_copy, b"CRASH WORK on disk\n"); // Session 2 reopens the file (on-disk contents) and edits it BEFORE // running recover-file. Sweeping must NOT clobber the crash copy. let s2 = editor(&dir); exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})")); exec(&s2, "pmacs.window.buffer():insert(0, 'new edits ')"); let (written, blocked) = sweep2(&s2); assert_eq!(written, 0, "must not write over unclaimed crash data"); assert_eq!(blocked, 1, "the sweep is blocked and reported"); assert_eq!( recovered(&s2, &f), crash_copy, "the crash recovery survives intact" ); assert_eq!(status(&s2, &f), "fresh", "still offered to the user"); // Once recover-file adopts it, autosave resumes for that path. exec(&s2, "pmacs.autosave._adopt(pmacs.window.buffer())"); // Adopt records the copy at the buffer's *current* revision, so the // very next sweep sees no change; an edit makes it write again. exec(&s2, "pmacs.window.buffer():insert(0, 'more ')"); let (written, blocked) = sweep2(&s2); assert_eq!((written, blocked), (1, 0), "adopted → sweeps again"); assert_eq!(recovered(&s2, &f), b"more new edits on disk\n"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn duplicate_buffers_on_one_path_conflict_instead_of_corrupting() { let dir = fresh_state_dir(); let s = editor(&dir); let f = write_file(&dir, "a.txt", "on disk\n"); two_buffers_one_path(&s, &f); // A recovery file is keyed by path, so only ONE of the two dirty // buffers can be protected. The first claims the slot; the other is // reported, never silently mis-protected. let (written, blocked, conflicted) = sweep3(&s); assert_eq!((written, blocked, conflicted), (1, 0, 1)); assert_eq!( recovered(&s, &f), b"AAA on disk\n", "the slot's owner is what is on disk" ); // The loser must NOT be marked protected: it keeps conflicting, and // its contents never silently overwrite the owner's copy. let (written, _, conflicted) = sweep3(&s); assert_eq!( (written, conflicted), (0, 1), "owner unchanged, dup still conflicts" ); exec(&s, "pmacs.window.switch_buffer(_G.b)"); exec(&s, "pmacs.window.buffer():insert(0, 'more ')"); let (written, _, conflicted) = sweep3(&s); assert_eq!( (written, conflicted), (0, 1), "editing the dup does not win the slot" ); assert_eq!(recovered(&s, &f), b"AAA on disk\n", "owner's copy intact"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn a_duplicate_buffers_save_does_not_retire_the_owners_recovery() { let dir = fresh_state_dir(); let s = editor(&dir); let f = write_file(&dir, "a.txt", "on disk\n"); two_buffers_one_path(&s, &f); assert_eq!(sweep3(&s), (1, 0, 1)); let owner_copy = recovered(&s, &f); // Save the DUPLICATE. Its cleanup must not touch the other buffer's // recovery — that copy is the only record of the owner's unsaved work. exec(&s, "pmacs.window.switch_buffer(_G.b)"); exec(&s, "pmacs.command.invoke('buffer.save')"); assert_ne!(status(&s, &f), "none", "the owner's recovery survives"); assert_eq!(recovered(&s, &f), owner_copy); std::fs::remove_dir_all(&dir).ok(); } #[test] fn killing_the_owner_frees_the_slot_for_the_duplicate() { let dir = fresh_state_dir(); let s = editor(&dir); let f = write_file(&dir, "a.txt", "on disk\n"); two_buffers_one_path(&s, &f); assert_eq!(sweep3(&s), (1, 0, 1)); // Killing the owner retires its copy and releases the slot; the // duplicate can then claim it and finally be protected. exec(&s, "pmacs.buffer.kill(_G.a)"); assert_eq!(status(&s, &f), "none", "owner's copy retired with it"); let (written, blocked, conflicted) = sweep3(&s); assert_eq!( (written, blocked, conflicted), (1, 0, 0), "dup claims the slot" ); assert_eq!(recovered(&s, &f), b"BBB on disk\n"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn adopting_clears_the_previous_owners_stale_skip_cache() { let dir = fresh_state_dir(); let s = editor(&dir); let f = write_file(&dir, "a.txt", "on disk\n"); two_buffers_one_path(&s, &f); // A owns the slot; B is the conflicted duplicate. assert_eq!(sweep3(&s), (1, 0, 1)); assert_eq!(recovered(&s, &f), b"AAA on disk\n"); // B recovers (adopts), stealing the slot. A keeps its dirty contents. exec(&s, "pmacs.window.switch_buffer(_G.b)"); exec(&s, "pmacs.autosave._adopt(pmacs.window.buffer())"); // Now kill B without saving: the slot is freed and its file deleted. exec(&s, "pmacs.buffer.kill(_G.b)"); assert_eq!(status(&s, &f), "none"); // A is still dirty and now unprotected. The next sweep must write it. // A stale `written[A]` (same hash, same revision) would make the skip // cache call it "unchanged since its last copy" and leave it exposed. let (written, blocked, conflicted) = sweep3(&s); assert_eq!( (written, blocked, conflicted), (1, 0, 0), "the old owner is re-protected once the slot frees, without an edit" ); assert_eq!(recovered(&s, &f), b"AAA on disk\n"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn a_failing_sweep_is_reported_not_swallowed() { let dir = fresh_state_dir(); // Plant a regular file where the `autosave/` directory must go, so // every recovery write fails (stands in for ENOSPC / a read-only // state dir). std::fs::write(dir.join("autosave"), b"not a directory").unwrap(); let s = editor(&dir); let f = write_file(&dir, "a.txt", "body\n"); open_and_dirty(&s, &f, "precious "); // The raw sweep surfaces the error rather than returning 0 silently. let ok: bool = eval(&s, "return (pcall(pmacs.autosave.sweep))"); assert!(!ok, "a write failure must not look like a successful sweep"); // And the quit path reports it instead of swallowing it — a failure // there means the quit is about to discard unprotected work. s.core.borrow_mut().status.clear(); let not_vetoed: bool = eval(&s, "return pmacs.hook.run('editor.before-quit')"); assert!(not_vetoed, "reporting must still never veto quit"); let status = s.core.borrow().status.clone(); assert!( status.contains("autosave FAILED") && status.contains("NOT being protected"), "the failure is surfaced: {status:?}" ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn saving_without_recovering_preserves_unclaimed_crash_data() { let dir = fresh_state_dir(); // Session 1 crashes with unsaved work. let s1 = editor(&dir); let f = write_file(&dir, "a.txt", "on disk\n"); open_and_dirty(&s1, &f, "CRASH WORK "); assert_eq!(sweep(&s1), 1); let crash_copy = recovered(&s1, &f); // Session 2 reopens, edits, and SAVES — without ever recovering or // discarding. The save must not destroy the crash copy: only // recover-file (adopt) or discard-recovery may release it. let s2 = editor(&dir); exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})")); exec(&s2, "pmacs.window.buffer():insert(0, 'new ')"); exec(&s2, "pmacs.command.invoke('buffer.save')"); assert_ne!( status(&s2, &f), "none", "saving must not delete unclaimed crash data" ); assert_eq!( recovered(&s2, &f), crash_copy, "the crash recovery survives a save" ); // It is now stale (the file changed on disk), so it is never // auto-offered — but it is still there to recover or discard. assert_eq!(status(&s2, &f), "stale"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn killing_without_recovering_preserves_unclaimed_crash_data() { let dir = fresh_state_dir(); let s1 = editor(&dir); let f = write_file(&dir, "a.txt", "on disk\n"); open_and_dirty(&s1, &f, "CRASH "); assert_eq!(sweep(&s1), 1); let crash_copy = recovered(&s1, &f); let s2 = editor(&dir); exec(&s2, &format!("_G.b = pmacs.buffer.find_or_open({f:?})")); exec(&s2, "pmacs.buffer.kill(_G.b)"); assert_eq!(recovered(&s2, &f), crash_copy, "kill preserves it too"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn recover_then_kill_retires_the_adopted_recovery() { let dir = fresh_state_dir(); let s1 = editor(&dir); let f = write_file(&dir, "a.txt", "on disk\n"); open_and_dirty(&s1, &f, "crash "); assert_eq!(sweep(&s1), 1); // Reopen, recover, then kill immediately — before any save or sweep. // The removal callback fires after the buffer is gone, so the only // way to find the copy is the entry `_adopt` recorded for its id. let s2 = editor(&dir); exec(&s2, &format!("_G.b = pmacs.buffer.find_or_open({f:?})")); exec( &s2, &format!( " local bytes = pmacs.autosave._recover_bytes({f:?}) local b = pmacs.window.buffer() b:replace(0, b:len(), bytes) pmacs.autosave._adopt(b) " ), ); exec(&s2, "pmacs.buffer.kill(_G.b)"); assert_eq!( status(&s2, &f), "none", "an adopted recovery is retired on kill, not left to be re-offered" ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn discard_recovery_lets_the_next_sweep_reprotect_immediately() { let dir = fresh_state_dir(); let s = editor(&dir); let f = write_file(&dir, "a.txt", "body\n"); open_and_dirty(&s, &f, "mine "); assert_eq!(sweep(&s), 1); assert_eq!(sweep(&s), 0, "unchanged → skipped"); // Explicitly discard while the buffer is still dirty. The next sweep // must re-create protection at once: a stale skip-cache entry would // leave the buffer unprotected until its next edit. exec(&s, &format!("pmacs.autosave._discard({f:?})")); assert_eq!(status(&s, &f), "none"); assert_eq!(sweep(&s), 1, "protection restored without needing an edit"); assert_eq!(recovered(&s, &f), b"mine body\n"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn discarding_an_unclaimed_recovery_unblocks_the_sweep() { let dir = fresh_state_dir(); let s1 = editor(&dir); let f = write_file(&dir, "a.txt", "on disk\n"); open_and_dirty(&s1, &f, "crash "); assert_eq!(sweep(&s1), 1); let s2 = editor(&dir); exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})")); exec(&s2, "pmacs.window.buffer():insert(0, 'mine ')"); assert_eq!(sweep2(&s2), (0, 1), "blocked"); exec(&s2, &format!("pmacs.autosave._discard({f:?})")); assert_eq!(sweep2(&s2), (1, 0), "discarded → sweeps again"); assert_eq!(recovered(&s2, &f), b"mine on disk\n"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn killing_a_new_file_buffer_gcs_its_recovery() { let dir = fresh_state_dir(); let s = editor(&dir); // A `[new file]` fires no after-load, so no per-buffer removal // callback is registered — the sweep-time GC is the backstop. let missing = dir.join("draft.txt"); exec( &s, "_G.nb = pmacs.buffer.create('draft.txt'); pmacs.window.switch_buffer(_G.nb)", ); { let id = s.core.borrow().active_buffer_id(); s.core .borrow_mut() .set_buffer_path(id, Some(missing.clone())); } exec(&s, "pmacs.window.buffer():insert(0, 'draft')"); let p = missing.display().to_string(); assert_eq!(sweep(&s), 1); assert_eq!(status(&s, &p), "fresh"); exec(&s, "pmacs.buffer.kill(_G.nb)"); // The next sweep GCs the dead buffer's recovery copy. sweep(&s); assert_eq!( status(&s, &p), "none", "killed new-file buffer is cleaned up" ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn saving_after_a_rename_removes_the_recovery_written_under_the_old_path() { let dir = fresh_state_dir(); let s = editor(&dir); let old = write_file(&dir, "old.txt", "body\n"); let new = dir.join("new.txt").display().to_string(); open_and_dirty(&s, &old, "dirty "); assert_eq!(sweep(&s), 1, "recovery written under the OLD key"); // Rename, then save — without an intervening sweep. A path-captured // cleanup would delete the new key and leave the old one behind. std::fs::rename(&old, &new).unwrap(); { let id = s.core.borrow().active_buffer_id(); s.core .borrow_mut() .set_buffer_path(id, Some(PathBuf::from(&new))); } exec(&s, "pmacs.command.invoke('buffer.save')"); assert_eq!(status(&s, &old), "none", "old key removed"); assert_eq!(status(&s, &new), "none", "new key removed"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn a_pre_existing_lax_autosave_dir_is_tightened() { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; let dir = fresh_state_dir(); // Someone (an older pmacs, or the user) left autosave/ at 0755. let autosave_dir = dir.join("autosave"); std::fs::create_dir_all(&autosave_dir).unwrap(); std::fs::set_permissions(&autosave_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); let s = editor(&dir); let f = write_file(&dir, "a.txt", "body\n"); open_and_dirty(&s, &f, "secret "); assert_eq!(sweep(&s), 1); let dmode = std::fs::metadata(&autosave_dir) .unwrap() .permissions() .mode() & 0o777; assert_eq!(dmode, 0o700, "a lax autosave dir is tightened, not left"); std::fs::remove_dir_all(&dir).ok(); } } #[test] fn tick_reports_recoveries_once_aggregated() { let dir = fresh_state_dir(); // Seed three recovery copies, then "crash" and reopen the files. let s1 = editor(&dir); let mut paths = Vec::new(); for i in 0..3 { let f = write_file(&dir, &format!("f{i}.txt"), "body\n"); open_and_dirty(&s1, &f, "x"); paths.push(f); } assert_eq!(sweep(&s1), 3); let s2 = editor(&dir); for f in &paths { exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})")); } // Each `after-load` only raises a flag; the tick does the reporting, // so three loads collapse into ONE aggregate message. exec(&s2, "pmacs.hook.run('process.after-tick')"); let status = s2.core.borrow().status.clone(); assert!( status.contains("3 files have autosave recovery"), "one aggregated message, not three: {status:?}" ); // A second tick does not re-report (the flag was cleared). s2.core.borrow_mut().status.clear(); exec(&s2, "pmacs.hook.run('process.after-tick')"); assert!(s2.core.borrow().status.is_empty(), "no repeat report"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn tick_names_the_file_when_exactly_one_is_recoverable() { let dir = fresh_state_dir(); let s1 = editor(&dir); let f = write_file(&dir, "solo.txt", "body\n"); open_and_dirty(&s1, &f, "x"); assert_eq!(sweep(&s1), 1); let s2 = editor(&dir); exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})")); exec(&s2, "pmacs.hook.run('process.after-tick')"); let status = s2.core.borrow().status.clone(); assert!( status.contains("solo.txt") && status.contains("recover-file"), "single recovery names the file: {status:?}" ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn save_and_kill_delete_the_recovery_copy() { let dir = fresh_state_dir(); let s = editor(&dir); // Clean save deletes it (buffer.after-save). let f = write_file(&dir, "a.txt", "body\n"); open_and_dirty(&s, &f, "x"); assert_eq!(sweep(&s), 1); exec(&s, "pmacs.command.invoke('buffer.save')"); assert_eq!(status(&s, &f), "none", "clean save retires the recovery"); // Kill deletes it (per-buffer on_removed registered at after-load). let g = write_file(&dir, "b.txt", "body\n"); exec(&s, &format!("_G.gb = pmacs.buffer.find_or_open({g:?})")); exec(&s, "pmacs.window.buffer():insert(0, 'x')"); assert_eq!(sweep(&s), 1); assert_eq!(status(&s, &g), "fresh"); exec(&s, "pmacs.buffer.kill(_G.gb)"); assert_eq!(status(&s, &g), "none", "kill retires the recovery"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn recover_file_installs_contents_fires_after_edit_and_leaves_modified() { let dir = fresh_state_dir(); let s = editor(&dir); let f = write_file(&dir, "a.txt", "on disk\n"); open_and_dirty(&s, &f, "recovered "); assert_eq!(sweep(&s), 1); // Simulate the crash-then-reopen: a fresh editor over the same store, // opening the file whose on-disk contents are the OLD ones. let s2 = editor(&dir); exec( &s2, r#" _G.after_edit = 0 pmacs.hook.add("buffer.after-edit", function() _G.after_edit = _G.after_edit + 1 end) "#, ); exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})")); assert_eq!(status(&s2, &f), "fresh"); // The buffer still holds the on-disk contents (no silent substitution). let before: mlua::String = eval( &s2, "local b = pmacs.window.buffer(); return b:slice(0, b:len())", ); assert_eq!(&*before.as_bytes(), b"on disk\n"); // Drive recover-file's accept path directly (the command opens a // minibuffer; we exercise what its on_accept does). exec( &s2, &format!( r#" local bytes = pmacs.autosave._recover_bytes({f:?}) local b = pmacs.window.buffer() b:replace(0, b:len(), bytes) pmacs.hook.run("buffer.after-edit") "# ), ); let after: mlua::String = eval( &s2, "local b = pmacs.window.buffer(); return b:slice(0, b:len())", ); assert_eq!(&*after.as_bytes(), b"recovered on disk\n"); let fired: i64 = eval(&s2, "return _G.after_edit"); assert!( fired >= 1, "after-edit fired so LSP/syntax see the recovery" ); let modified: bool = eval(&s2, "return pmacs.window.buffer():is_modified()"); assert!( modified, "recovered buffer is dirty; user must save to keep" ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn recovery_files_are_private_0600_under_a_0700_dir() { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; let dir = fresh_state_dir(); let s = editor(&dir); let f = write_file(&dir, "secret.txt", ""); open_and_dirty(&s, &f, "unsaved secret"); assert_eq!(sweep(&s), 1); let key = pmacs::autosave::key_for(std::path::Path::new(&f)); let file = dir.join(&key); let fmode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777; assert_eq!(fmode, 0o600, "recovery file holds unsaved contents"); let dmode = std::fs::metadata(dir.join("autosave")) .unwrap() .permissions() .mode() & 0o777; assert_eq!(dmode, 0o700); std::fs::remove_dir_all(&dir).ok(); } } #[test] fn interval_is_a_validated_getter_setter_and_enable_gates_the_sweep() { let dir = fresh_state_dir(); let s = editor(&dir); let default_ms: i64 = eval(&s, "return pmacs.autosave.interval_ms()"); assert_eq!(default_ms, 30000, "Emacs's auto-save-timeout"); let set: i64 = eval(&s, "return pmacs.autosave.interval_ms(60000)"); assert_eq!(set, 60000); let read_back: i64 = eval(&s, "return pmacs.autosave.interval_ms()"); assert_eq!(read_back, 60000, "change takes effect immediately"); // Floats floor; bad values error. let floored: i64 = eval(&s, "return pmacs.autosave.interval_ms(1500.9)"); assert_eq!(floored, 1500); for bad in ["'soon'", "0", "999", "-1", "{}"] { let ok: bool = eval( &s, &format!("return (pcall(pmacs.autosave.interval_ms, {bad}))"), ); assert!(!ok, "interval_ms({bad}) must be rejected"); } // A rejected set leaves the previous value intact. let still: i64 = eval(&s, "return pmacs.autosave.interval_ms()"); assert_eq!(still, 1500); // enable(false) makes sweep a no-op even with a dirty buffer. let f = write_file(&dir, "a.txt", "body\n"); open_and_dirty(&s, &f, "x"); exec(&s, "pmacs.autosave.enable(false)"); assert_eq!(sweep(&s), 0, "disabled → no sweep"); assert_eq!(status(&s, &f), "none"); exec(&s, "pmacs.autosave.enable(true)"); assert_eq!(sweep(&s), 1, "re-enabled → sweeps"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn before_quit_sweeps_synchronously_without_vetoing() { let dir = fresh_state_dir(); let s = editor(&dir); let f = write_file(&dir, "a.txt", "body\n"); open_and_dirty(&s, &f, "unsaved "); // Not swept yet. assert_eq!(status(&s, &f), "none"); // before-quit is short-circuit: a `true` result means "not vetoed". let not_vetoed: bool = eval(&s, "return pmacs.hook.run('editor.before-quit')"); assert!(not_vetoed, "autosave must never veto quit"); assert_eq!( status(&s, &f), "fresh", "quitting with unsaved changes leaves a recovery copy" ); std::fs::remove_dir_all(&dir).ok(); } // Isolated bootstrap storage roots (see the module docs): an // integration test is compiled without `cfg(test)`, so a raw // `EditorState::new()` would read the developer's real `init.lua` and // write into their real data root. #[path = "common/iso.rs"] mod iso;