pmacs/tests/save_clobber_guard_acceptan...

203 lines
6.3 KiB
Rust

//! `EditorCore::save()` must not silently overwrite a file that changed on
//! disk since the buffer read it.
//!
//! Before this guard, pmacs wrote unconditionally and only *then* recorded
//! the new `FileMeta` — so another editor's (or a `git checkout`'s) writes
//! were destroyed without a word. The comparison seam
//! (`FileMeta: PartialEq`, `file_io::current_meta`) already existed and no
//! caller used it.
use pmacs::editor::EditorState;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
fn tempdir() -> PathBuf {
static SEQ: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!(
"pmacs-clobber-{}-{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&d).unwrap();
d
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
fn write(p: &std::path::Path, body: &str) {
std::fs::write(p, body).unwrap();
}
fn read(p: &std::path::Path) -> String {
std::fs::read_to_string(p).unwrap()
}
fn status(s: &EditorState) -> String {
s.core.borrow().status.clone()
}
/// Open `path` and dirty the buffer.
fn open_and_dirty(s: &EditorState, path: &str) {
exec(
s,
&format!("pmacs.buffer.find_or_open({path:?}); pmacs.window.buffer():insert(0, 'mine ')"),
);
}
#[test]
fn save_refuses_to_clobber_a_file_changed_on_disk() {
let dir = tempdir();
let f = dir.join("a.txt");
write(&f, "original\n");
let fs = f.display().to_string();
let s = EditorState::new();
open_and_dirty(&s, &fs);
// Another writer lands between our read and our save.
write(&f, "THEIRS -- do not destroy\n");
let saved: bool = eval(&s, "return pmacs.editor.save()");
assert!(!saved, "save must refuse");
assert_eq!(
read(&f),
"THEIRS -- do not destroy\n",
"their content is intact"
);
assert!(
status(&s).contains("changed on disk") && status(&s).contains("save-anyway"),
"the refusal says what happened and how to override: {:?}",
status(&s)
);
// The buffer keeps its unsaved edits — nothing was lost on our side.
let modified: bool = eval(&s, "return pmacs.window.buffer():is_modified()");
assert!(modified);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_save_command_does_not_fire_after_save_when_it_refuses() {
let dir = tempdir();
let f = dir.join("a.txt");
write(&f, "original\n");
let fs = f.display().to_string();
let s = EditorState::new();
open_and_dirty(&s, &fs);
exec(
&s,
"_G.after = 0; pmacs.hook.add('buffer.after-save', function() _G.after = _G.after + 1 end)",
);
write(&f, "theirs\n");
exec(&s, "pmacs.command.invoke('buffer.save')");
let after: i64 = eval(&s, "return _G.after");
assert_eq!(after, 0, "after-save must not fire on a refused save");
assert_eq!(read(&f), "theirs\n");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn save_anyway_overwrites_deliberately_and_resyncs_meta() {
let dir = tempdir();
let f = dir.join("a.txt");
write(&f, "original\n");
let fs = f.display().to_string();
let s = EditorState::new();
open_and_dirty(&s, &fs);
write(&f, "theirs\n");
assert!(!eval::<bool>(&s, "return pmacs.editor.save()"));
// The user looked and decided their buffer wins.
exec(&s, "pmacs.command.invoke('buffer.save-anyway')");
assert_eq!(read(&f), "mine original\n", "overwritten on purpose");
// The buffer re-syncs to the file it just wrote, so an immediate
// ordinary save is allowed again.
exec(&s, "pmacs.window.buffer():insert(0, 'more ')");
assert!(
eval::<bool>(&s, "return pmacs.editor.save()"),
"meta was refreshed by the forced save"
);
assert_eq!(read(&f), "more mine original\n");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_unchanged_file_saves_normally_and_repeatedly() {
let dir = tempdir();
let f = dir.join("a.txt");
write(&f, "original\n");
let fs = f.display().to_string();
let s = EditorState::new();
open_and_dirty(&s, &fs);
assert!(eval::<bool>(&s, "return pmacs.editor.save()"));
assert_eq!(read(&f), "mine original\n");
// A save updates our recorded meta, so the next one is not a false
// positive — the guard must not trip on our own writes.
exec(&s, "pmacs.window.buffer():insert(0, 'again ')");
assert!(eval::<bool>(&s, "return pmacs.editor.save()"));
assert_eq!(read(&f), "again mine original\n");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_deleted_file_is_recreated_not_refused() {
let dir = tempdir();
let f = dir.join("a.txt");
write(&f, "original\n");
let fs = f.display().to_string();
let s = EditorState::new();
open_and_dirty(&s, &fs);
// Nothing on disk to clobber, so recreating it is not data loss.
std::fs::remove_file(&f).unwrap();
assert!(
eval::<bool>(&s, "return pmacs.editor.save()"),
"a vanished file is recreated, not refused"
);
assert_eq!(read(&f), "mine original\n");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_new_file_buffer_refuses_once_someone_else_creates_the_file() {
let dir = tempdir();
let missing = dir.join("draft.txt");
let s = EditorState::new();
// The argv `[new file]` shape: a path with nothing on disk, no meta.
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, 'my draft')");
// While we drafted, someone created the file. We have never seen its
// contents, so writing over them is exactly the clobber we refuse.
write(&missing, "theirs\n");
assert!(!eval::<bool>(&s, "return pmacs.editor.save()"));
assert_eq!(read(&missing), "theirs\n");
// With the file still absent it would have saved cleanly.
std::fs::remove_file(&missing).unwrap();
assert!(eval::<bool>(&s, "return pmacs.editor.save()"));
assert_eq!(read(&missing), "my draft");
std::fs::remove_dir_all(&dir).ok();
}