2104 lines
74 KiB
Rust
2104 lines
74 KiB
Rust
// tests/m8_3_acceptance.rs --- T M8.3 wdired (editable rename / chmod) acceptance.
|
|
|
|
//! Acceptance for the M8.3 deliverable: layering an editable
|
|
//! rename / chmod surface on top of the M8.2 dired view. Pins the
|
|
//! four spec bullets:
|
|
//!
|
|
//! 1. Renaming a file by editing its name and committing produces
|
|
//! the matching `rename` syscall.
|
|
//! 2. Editing permissions to an invalid pattern produces a
|
|
//! rejection at the `intercept_edit` layer, not at the syscall.
|
|
//! 3. Editing the size column (which is read-only) is rejected
|
|
//! cleanly.
|
|
//! 4. Concurrent external file changes during edit produce a
|
|
//! refresh prompt rather than corrupting the user's edits.
|
|
//!
|
|
//! Plus several read-only-column regressions: kind char, mtime
|
|
//! column, separators, and newline insertion.
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::PathBuf;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use pmacs::editor::EditorState;
|
|
use pmacs::lua_bindings::PackageInstallOverride;
|
|
use tempfile::TempDir;
|
|
|
|
fn dired_package_path() -> PathBuf {
|
|
let here = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
here.join("tests").join("fixtures").join("pmacs-dired")
|
|
}
|
|
|
|
fn pump_until<F: Fn(&EditorState) -> bool>(state: &mut EditorState, predicate: F) {
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while !predicate(state) {
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"async pump deadline exceeded after 5s"
|
|
);
|
|
state.tick_async();
|
|
std::thread::sleep(Duration::from_millis(2));
|
|
}
|
|
}
|
|
|
|
fn editor_with_dired() -> (EditorState, TempDir, TempDir) {
|
|
let cache = tempfile::tempdir().expect("cache tempdir");
|
|
let user_root = tempfile::tempdir().expect("user-root tempdir");
|
|
let mut state = EditorState::new();
|
|
state.lua_host.reopen_init_phase_for_testing();
|
|
state.lua_host.set_package_install_override(
|
|
PackageInstallOverride::new()
|
|
.with_cache_dir(cache.path().to_path_buf())
|
|
.with_user_install_root(user_root.path().to_path_buf()),
|
|
);
|
|
let pkg = dired_package_path();
|
|
let pkg_str = pkg.display().to_string();
|
|
let install = format!(
|
|
r#"
|
|
pmacs.packages.install_local("{pkg_str}")
|
|
require("pmacs-dired")
|
|
"#
|
|
);
|
|
state
|
|
.lua_host
|
|
.eval(Some("dired-install"), &install)
|
|
.unwrap_or_else(|e| panic!("install_local + require failed: {e}"));
|
|
(state, cache, user_root)
|
|
}
|
|
|
|
/// Open `path` in dired and pump until M.open returns. Active buffer
|
|
/// is the dired buffer afterward; a wdired session can begin via
|
|
/// the wdired-edit command.
|
|
fn open_dired_on(state: &mut EditorState, path: &str) {
|
|
let chunk = format!(
|
|
r#"
|
|
_G.M8_3_DONE = nil
|
|
pmacs.async(function()
|
|
require("pmacs-dired").open("{path}")
|
|
_G.M8_3_DONE = true
|
|
end)
|
|
"#
|
|
);
|
|
state
|
|
.lua_host
|
|
.eval(Some("open-for-wdired"), &chunk)
|
|
.unwrap_or_else(|e| panic!("dired open failed: {e}"));
|
|
pump_until(state, |s| {
|
|
s.lua_host
|
|
.lua()
|
|
.globals()
|
|
.get::<bool>("M8_3_DONE")
|
|
.unwrap_or(false)
|
|
});
|
|
}
|
|
|
|
/// Pump until the active handle's `last_commit_outcome` is set
|
|
/// (success or failure). The wdired-commit command schedules a
|
|
/// pmacs.async whose outcome lands there; we don't observe it via
|
|
/// pcall on invoke because async errors are surfaced through
|
|
/// pmacs.error, not raised back.
|
|
fn pump_until_commit_done(state: &mut EditorState) {
|
|
pump_until(state, |s| {
|
|
s.lua_host
|
|
.lua()
|
|
.load(
|
|
r"
|
|
local h = require('pmacs-dired')._test.active_handle()
|
|
return h ~= nil and h.last_commit_outcome ~= nil
|
|
",
|
|
)
|
|
.eval::<bool>()
|
|
.unwrap_or(false)
|
|
});
|
|
}
|
|
|
|
/// Read the active handle's `last_commit_outcome` ("ok" / "failed: ...").
|
|
fn read_commit_outcome(state: &mut EditorState) -> String {
|
|
state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r"
|
|
local h = require('pmacs-dired')._test.active_handle()
|
|
return tostring(h.last_commit_outcome)
|
|
",
|
|
)
|
|
.eval::<String>()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn active_buffer_text(state: &mut EditorState) -> String {
|
|
state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r"
|
|
local buf = pmacs.window.buffer()
|
|
return buf:slice(0, buf:len())
|
|
",
|
|
)
|
|
.eval::<String>()
|
|
.expect("read active buffer")
|
|
}
|
|
|
|
fn edit_wdired_names(state: &mut EditorState, pairs: &[(&str, &str)]) {
|
|
let pairs_lua = pairs
|
|
.iter()
|
|
.map(|(from, to)| format!("[{from:?}] = {to:?}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let chunk = format!(
|
|
r#"
|
|
local replacements = {{ {pairs_lua} }}
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local NAME_START = require("pmacs-dired")._test.NAME_START
|
|
local function line_end(i)
|
|
if i < #h.edit.line_start_marks then
|
|
return h.edit.line_start_marks[i + 1]:get() - 1
|
|
end
|
|
return h.buf:len()
|
|
end
|
|
for i, e in ipairs(h.edit.snapshot) do
|
|
local to = replacements[e.name]
|
|
if to then
|
|
local ls = h.edit.line_start_marks[i]:get()
|
|
h.buf:replace(ls + NAME_START, line_end(i), to)
|
|
end
|
|
end
|
|
"#
|
|
);
|
|
state
|
|
.lua_host
|
|
.eval(Some("edit-wdired-names"), &chunk)
|
|
.expect("edit wdired names");
|
|
}
|
|
|
|
fn install_second_rename_failure_hook(state: &mut EditorState) {
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("hook-second-rename"),
|
|
r#"
|
|
local real = pmacs.fs.rename
|
|
local calls = 0
|
|
pmacs.fs.rename = function(from, to)
|
|
calls = calls + 1
|
|
if calls == 2 then
|
|
return setmetatable({}, {
|
|
__index = {
|
|
await = function()
|
|
error("synthetic mid-batch rename failure")
|
|
end,
|
|
},
|
|
})
|
|
end
|
|
return real(from, to)
|
|
end
|
|
"#,
|
|
)
|
|
.expect("install rename hook");
|
|
}
|
|
|
|
fn wdired_edit_active(state: &mut EditorState) -> bool {
|
|
state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
return h.edit ~= nil
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("read edit state")
|
|
}
|
|
|
|
fn file_mtime_parts(path: &std::path::Path) -> (u64, u32) {
|
|
let modified = std::fs::metadata(path)
|
|
.expect("metadata")
|
|
.modified()
|
|
.expect("modified");
|
|
let since_epoch = modified
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("test mtimes are post-epoch");
|
|
(since_epoch.as_secs(), since_epoch.subsec_nanos())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bullet 1 --- rename via name edit + commit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn wdired_rename_via_name_edit_then_commit_produces_rename_syscall() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("old.txt"), b"contents").expect("write old");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
let path_str = dir.path().display().to_string();
|
|
open_dired_on(&mut state, &path_str);
|
|
|
|
// Enter wdired edit mode.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// The line for "old.txt" has the name beginning at byte 39 of
|
|
// the line. Replace the entire name segment ([line_start+39,
|
|
// eol)) with "new.txt".
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("rename-edit"),
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local mark = h.edit.line_start_marks[1]
|
|
local ls = mark:get()
|
|
local NAME_START = require("pmacs-dired")._test.NAME_START
|
|
local eol = h.buf:len()
|
|
local name_start = ls + NAME_START
|
|
h.buf:replace(name_start, eol, "new.txt")
|
|
"#,
|
|
)
|
|
.expect("rename edit");
|
|
|
|
// Commit. The command schedules a pmacs.async; poll the
|
|
// handle's last_commit_outcome to detect completion.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-commit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert_eq!(outcome, "ok", "commit must succeed; got: {outcome}");
|
|
// The file is now at the new path; the old path no longer exists.
|
|
assert!(
|
|
dir.path().join("new.txt").exists(),
|
|
"after wdired-commit, new.txt must exist on disk"
|
|
);
|
|
assert!(
|
|
!dir.path().join("old.txt").exists(),
|
|
"after wdired-commit, old.txt must be gone from disk"
|
|
);
|
|
|
|
// The buffer reflects the post-commit listing.
|
|
let text = active_buffer_text(&mut state);
|
|
assert!(
|
|
text.contains("new.txt") && !text.contains(" old.txt"),
|
|
"post-commit dired buffer must list new.txt only; got: {text}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bullet 2 --- invalid perms char rejected at intercept_edit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn wdired_invalid_perms_char_rejected_at_intercept_layer() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"x").expect("write file");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// Replace one perm char with 'z' (not in the rwx alphabet). We
|
|
// use replace (not insert) because the perms column is fixed-
|
|
// width: an insert would shift the column and is rejected for
|
|
// a different reason --- the alphabet check is for length-
|
|
// preserving same-column replaces.
|
|
let msg: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local ls = h.edit.line_start_marks[1]:get()
|
|
-- Position 1 is the first perms char; replace it with 'z'.
|
|
local ok, err = pcall(function()
|
|
h.buf:replace(ls + 1, ls + 2, "z")
|
|
end)
|
|
assert(not ok, "intercept must reject 'z' in perms")
|
|
return tostring(err)
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("intercept error eval");
|
|
assert!(
|
|
msg.contains("perms column") && msg.contains("'r', 'w', 'x', and '-'"),
|
|
"rejection must name the rwx alphabet; got: {msg}"
|
|
);
|
|
|
|
// Buffer is unchanged: file mode unchanged on disk because no
|
|
// chmod ran (we never reached commit).
|
|
let mode_before = std::fs::metadata(dir.path().join("file.txt"))
|
|
.unwrap()
|
|
.permissions()
|
|
.mode()
|
|
& 0o777;
|
|
// Run wdired-abandon to clean up, then verify nothing happened.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("abandon"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-abandon")"#,
|
|
)
|
|
.expect("abandon");
|
|
let mode_after = std::fs::metadata(dir.path().join("file.txt"))
|
|
.unwrap()
|
|
.permissions()
|
|
.mode()
|
|
& 0o777;
|
|
assert_eq!(mode_before, mode_after, "no chmod should have fired");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bullet 3 --- size column is read-only and rejected
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn wdired_size_column_edits_rejected() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"hello").expect("write file");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// Try to insert into the size column. Per the layout, size starts
|
|
// at relative offset 11 (PERMS_END + 1).
|
|
let msg: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local ls = h.edit.line_start_marks[1]:get()
|
|
local ok, err = pcall(function() h.buf:insert(ls + 13, "9") end)
|
|
assert(not ok, "intercept must reject size-column edits")
|
|
return tostring(err)
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("size-column eval");
|
|
assert!(
|
|
msg.contains("read-only"),
|
|
"rejection must name the read-only column; got: {msg}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bullet 4 --- external change between edit and commit produces refresh
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn wdired_external_change_at_commit_aborts_with_refresh_guidance() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("a.txt"), b"a").expect("write a");
|
|
std::fs::write(dir.path().join("b.txt"), b"b").expect("write b");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
let path_str = dir.path().display().to_string();
|
|
open_dired_on(&mut state, &path_str);
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// Edit a.txt's name to a_new.txt.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("name-edit"),
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local NAME_START = require("pmacs-dired")._test.NAME_START
|
|
local ls1 = h.edit.line_start_marks[1]:get()
|
|
local ls2 = h.edit.line_start_marks[2]:get()
|
|
-- a.txt is on line 1; name region is [ls1 + NAME_START, ls2 - 1).
|
|
local name_start = ls1 + NAME_START
|
|
local eol = ls2 - 1 -- position of the trailing '\n'
|
|
h.buf:replace(name_start, eol, "a_new.txt")
|
|
"#,
|
|
)
|
|
.expect("name-edit");
|
|
|
|
// Externally rename b.txt -> b_external.txt while the user is
|
|
// mid-edit. This is the "concurrent external file change"
|
|
// scenario.
|
|
std::fs::rename(dir.path().join("b.txt"), dir.path().join("b_external.txt"))
|
|
.expect("external rename");
|
|
|
|
// Try to commit: should refuse with refresh-prompt guidance.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("commit-attempt"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert!(
|
|
outcome.starts_with("failed: "),
|
|
"commit must fail when external changes happened; got: {outcome}"
|
|
);
|
|
assert!(
|
|
outcome.contains("external"),
|
|
"error must say 'external'; got: {outcome}"
|
|
);
|
|
assert!(
|
|
outcome.contains("wdired-abandon"),
|
|
"error must point at wdired-abandon; got: {outcome}"
|
|
);
|
|
|
|
// a.txt was NOT renamed (commit aborted before any syscalls).
|
|
assert!(
|
|
dir.path().join("a.txt").exists(),
|
|
"a.txt must remain since commit aborted"
|
|
);
|
|
assert!(
|
|
!dir.path().join("a_new.txt").exists(),
|
|
"a_new.txt must not exist since commit aborted"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Regressions: read-only column edges
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn wdired_kind_char_edit_rejected() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
let msg: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local ls = h.edit.line_start_marks[1]:get()
|
|
local ok, err = pcall(function() h.buf:replace(ls, ls + 1, "d") end)
|
|
assert(not ok, "kind-char edit must be rejected")
|
|
return tostring(err)
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("kind-char eval");
|
|
assert!(
|
|
msg.contains("read-only"),
|
|
"rejection must name read-only: {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wdired_mtime_column_edit_rejected() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
let msg: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local ls = h.edit.line_start_marks[1]:get()
|
|
-- Mtime is at relative offset 22..38; pick 25.
|
|
local ok, err = pcall(function() h.buf:insert(ls + 25, "X") end)
|
|
assert(not ok, "mtime edit must be rejected")
|
|
return tostring(err)
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("mtime eval");
|
|
assert!(msg.contains("read-only"), "must name read-only: {msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn wdired_newline_insert_rejected() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
let msg: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local ls = h.edit.line_start_marks[1]:get()
|
|
local NAME_START = require("pmacs-dired")._test.NAME_START
|
|
-- Try to insert a newline in the editable name region;
|
|
-- still rejected because newlines split lines.
|
|
local ok, err = pcall(function() h.buf:insert(ls + NAME_START + 1, "\n") end)
|
|
assert(not ok, "newline insert must be rejected")
|
|
return tostring(err)
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("newline eval");
|
|
assert!(
|
|
msg.contains("newline"),
|
|
"rejection must name newline: {msg}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bullet 1 companion --- chmod via perms edit + commit produces chmod syscall
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn wdired_chmod_via_perms_edit_then_commit_changes_mode_on_disk() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let file = dir.path().join("file.txt");
|
|
std::fs::write(&file, b"x").expect("write");
|
|
// Set a known starting mode so we can detect the change.
|
|
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).expect("chmod 0o644");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// Replace the perms column "rw-r--r--" (0o644) with "rwxr-x---"
|
|
// (0o750). The perms region is [line_start + 1, line_start + 10).
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("chmod-edit"),
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local ls = h.edit.line_start_marks[1]:get()
|
|
h.buf:replace(ls + 1, ls + 10, "rwxr-x---")
|
|
"#,
|
|
)
|
|
.expect("chmod edit");
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-commit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert_eq!(outcome, "ok", "commit must succeed; got: {outcome}");
|
|
let mode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777;
|
|
assert_eq!(
|
|
mode, 0o750,
|
|
"chmod via wdired-commit must set mode 0o750; got 0o{mode:o}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bullet 1 companion --- empty name rejected at commit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn wdired_commit_rejects_empty_name() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// Replace the name with empty.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("blank-name"),
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local NAME_START = require("pmacs-dired")._test.NAME_START
|
|
local ls = h.edit.line_start_marks[1]:get()
|
|
h.buf:replace(ls + NAME_START, h.buf:len(), "")
|
|
"#,
|
|
)
|
|
.expect("blank-name edit");
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("commit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert!(
|
|
outcome.starts_with("failed: "),
|
|
"empty name must be rejected; got: {outcome}"
|
|
);
|
|
assert!(
|
|
outcome.contains("empty"),
|
|
"error must name 'empty'; got: {outcome}"
|
|
);
|
|
// File on disk untouched.
|
|
assert!(
|
|
dir.path().join("file.txt").exists(),
|
|
"file.txt must remain since commit was rejected"
|
|
);
|
|
}
|
|
|
|
// Slashed-name companion --- a basename containing '/' is forbidden
|
|
// (would escape the directory). Sibling of the empty-name test.
|
|
#[test]
|
|
fn wdired_commit_rejects_slashed_name() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("slashed"),
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local NAME_START = require("pmacs-dired")._test.NAME_START
|
|
local ls = h.edit.line_start_marks[1]:get()
|
|
h.buf:replace(ls + NAME_START, h.buf:len(), "foo/bar")
|
|
"#,
|
|
)
|
|
.expect("slashed-edit");
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("commit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert!(
|
|
outcome.starts_with("failed: "),
|
|
"slashed name must be rejected; got: {outcome}"
|
|
);
|
|
assert!(
|
|
outcome.contains("'/'") || outcome.contains("directory separator"),
|
|
"error must mention slash / directory separator: {outcome}"
|
|
);
|
|
// Disk untouched: original file still there, no foo/ subdir created.
|
|
assert!(dir.path().join("file.txt").exists(), "file.txt must remain");
|
|
assert!(!dir.path().join("foo").exists(), "foo/ must not be created");
|
|
assert!(
|
|
!dir.path().join("foo").join("bar").exists(),
|
|
"foo/bar must not be created"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Navigation rejected while editing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn wdired_navigation_commands_blocked_while_editing() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
for cmd in [
|
|
"pmacs-dired.parent",
|
|
"pmacs-dired.sort-name",
|
|
"pmacs-dired.sort-mtime",
|
|
"pmacs-dired.sort-size",
|
|
] {
|
|
let probe = format!(
|
|
r#"
|
|
local ok, err = pcall(function() pmacs.command.invoke("{cmd}") end)
|
|
assert(not ok, "{cmd} must be rejected while editing")
|
|
return tostring(err)
|
|
"#
|
|
);
|
|
let msg: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(&probe)
|
|
.eval()
|
|
.unwrap_or_else(|e| panic!("nav-block eval failed for {cmd}: {e}"));
|
|
assert!(
|
|
msg.contains("wdired-edit") && msg.contains("commit") && msg.contains("abandon"),
|
|
"rejection must point at commit/abandon; got from {cmd}: {msg}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// wdired-abandon restores the buffer to its pre-edit text
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn wdired_abandon_restores_pre_edit_buffer_text() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
|
|
let before = active_buffer_text(&mut state);
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("scribble"),
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local NAME_START = require("pmacs-dired")._test.NAME_START
|
|
local ls = h.edit.line_start_marks[1]:get()
|
|
h.buf:replace(ls + NAME_START, h.buf:len(), "scribbled.txt")
|
|
"#,
|
|
)
|
|
.expect("scribble");
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("abandon"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-abandon")"#,
|
|
)
|
|
.expect("abandon");
|
|
|
|
let after = active_buffer_text(&mut state);
|
|
assert_eq!(
|
|
before, after,
|
|
"wdired-abandon must restore the pre-edit text byte-for-byte"
|
|
);
|
|
// File on disk untouched.
|
|
assert!(
|
|
dir.path().join("file.txt").exists(),
|
|
"file.txt must be intact after abandon"
|
|
);
|
|
assert!(
|
|
!dir.path().join("scribbled.txt").exists(),
|
|
"no rename should have happened"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// wdired-edit twice errors instead of double-attaching
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn wdired_edit_twice_errors() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("first wdired-edit");
|
|
let msg: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local ok, err = pcall(function()
|
|
pmacs.command.invoke("pmacs-dired.wdired-edit")
|
|
end)
|
|
assert(not ok, "second wdired-edit must error")
|
|
return tostring(err)
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("second-edit eval");
|
|
assert!(
|
|
msg.contains("already in wdired-edit mode"),
|
|
"second wdired-edit error must say already in mode: {msg}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parse_perm_string: positional validation (lib-style test through seam)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn wdired_parse_perm_string_positional_validation() {
|
|
let (state, _c, _u) = editor_with_dired();
|
|
let lua = state.lua_host.lua();
|
|
// Valid: standard 0o755.
|
|
let mode: i64 = lua
|
|
.load(
|
|
r#"
|
|
local m, _ = require("pmacs-dired")._test.parse_perm_string("rwxr-xr-x")
|
|
return m
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("valid 755");
|
|
assert_eq!(mode, 0o755);
|
|
|
|
// Invalid: char in wrong position.
|
|
let invalid: String = lua
|
|
.load(
|
|
r#"
|
|
local m, err = require("pmacs-dired")._test.parse_perm_string("xrwr-xr-x")
|
|
if m then return "valid" else return tostring(err) end
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("xr in pos 0");
|
|
assert!(
|
|
invalid.contains("invalid perms char"),
|
|
"wrong-position char must surface: {invalid}"
|
|
);
|
|
|
|
// Wrong length.
|
|
let wrong_len: String = lua
|
|
.load(
|
|
r#"
|
|
local m, err = require("pmacs-dired")._test.parse_perm_string("rwxr-xr--")
|
|
if m == nil then return tostring(err) else return tostring(m) end
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("9-char with - in last pos is valid actually");
|
|
// "rwxr-xr--" is 0o744 (owner=7, group=4, other=4), valid.
|
|
let _ = wrong_len;
|
|
|
|
// Bad length (8 chars).
|
|
let too_short: String = lua
|
|
.load(
|
|
r#"
|
|
local m, err = require("pmacs-dired")._test.parse_perm_string("rwxr-xr-")
|
|
if m then return "ok" else return tostring(err) end
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("8 chars");
|
|
assert!(
|
|
too_short.contains("9 chars"),
|
|
"must complain length: {too_short}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pass-2 Finding High1 --- escaped names round-trip without spurious renames
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// A file whose real name contains a `\n` byte renders as `weird\nname.txt`
|
|
// (escape_displayable handles control chars and the backslash itself).
|
|
// Before the unescape step, commit would parse the rendered text as a NEW
|
|
// name with a literal backslash + 'n', and fire a rename to that new name.
|
|
// After the fix, a no-op commit is genuinely no-op.
|
|
|
|
#[test]
|
|
fn wdired_newline_in_filename_round_trips_without_spurious_rename() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let weird_name = "weird\nname.txt";
|
|
std::fs::write(dir.path().join(weird_name), b"x").expect("write weird");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// No edits. Just commit. Must succeed without renaming the file.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("commit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert_eq!(outcome, "ok", "no-op commit must succeed; got: {outcome}");
|
|
|
|
// The file with a real newline in its name must still exist.
|
|
assert!(
|
|
dir.path().join(weird_name).exists(),
|
|
"file with newline-in-name must still exist after no-op commit"
|
|
);
|
|
// The literal "weird\\nname.txt" (a name containing a backslash + 'n')
|
|
// must NOT exist on disk --- that would mean we mistook the rendered
|
|
// escape for the real name and fired a spurious rename.
|
|
assert!(
|
|
!dir.path().join("weird\\nname.txt").exists(),
|
|
"escape-as-real-name file must not have been created"
|
|
);
|
|
}
|
|
|
|
// Backslash-only name: tests the same round-trip path through a different
|
|
// escape (`\\`), and verifies unescape_displayable directly via the seam.
|
|
#[test]
|
|
fn wdired_unescape_displayable_round_trips_known_escapes() {
|
|
let (state, _c, _u) = editor_with_dired();
|
|
let lua = state.lua_host.lua();
|
|
|
|
// Round-trips for each recognized escape.
|
|
let result: String = lua
|
|
.load(
|
|
r#"
|
|
local te = require("pmacs-dired")._test
|
|
local cases = {
|
|
{ input = "plain.txt", expect = "plain.txt" },
|
|
{ input = "a\\nb", expect = "a\nb" },
|
|
{ input = "a\\rb", expect = "a\rb" },
|
|
{ input = "a\\tb", expect = "a\tb" },
|
|
{ input = "a\\\\b", expect = "a\\b" },
|
|
{ input = "x\\x00y", expect = "x\0y" },
|
|
{ input = "z\\xFFw", expect = "z\255w" },
|
|
}
|
|
for i, c in ipairs(cases) do
|
|
local out, err = te.unescape_displayable(c.input)
|
|
if out ~= c.expect then
|
|
return ("case " .. i .. " input=" .. c.input ..
|
|
" got=" .. tostring(out) ..
|
|
" want=" .. tostring(c.expect) ..
|
|
" err=" .. tostring(err))
|
|
end
|
|
end
|
|
return "ok"
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("unescape eval");
|
|
assert_eq!(result, "ok", "all escape round-trips must succeed");
|
|
|
|
// Bad escapes are rejected.
|
|
for bad in ["bad\\q", "bad\\", "bad\\xZZ", "bad\\x1"] {
|
|
let probe = format!(
|
|
r#"
|
|
local te = require("pmacs-dired")._test
|
|
local out, err = te.unescape_displayable({bad:?})
|
|
if out == nil then return tostring(err) end
|
|
return "accepted unexpectedly"
|
|
"#
|
|
);
|
|
let msg: String = lua
|
|
.load(&probe)
|
|
.eval()
|
|
.unwrap_or_else(|e| panic!("bad-escape eval failed for {bad}: {e}"));
|
|
assert!(
|
|
!msg.starts_with("accepted"),
|
|
"must reject bad escape '{bad}'; got: {msg}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pass-2 Finding High2 --- duplicate final-name rejection (overwrite case)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Renaming a -> b where b is unchanged in the same dired listing would have
|
|
// the kernel's rename() silently overwrite b. The commit planner must catch
|
|
// this before any syscall fires.
|
|
|
|
#[test]
|
|
fn wdired_duplicate_final_name_rejected_pre_syscall() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("a.txt"), b"A-content").expect("write a");
|
|
std::fs::write(dir.path().join("b.txt"), b"B-content").expect("write b");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// Sort is "name" by default, so a.txt is line 1 and b.txt is line 2.
|
|
// Edit a.txt's name to "b.txt": collides with the unchanged b.txt.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("collide"),
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local NAME_START = require("pmacs-dired")._test.NAME_START
|
|
local ls1 = h.edit.line_start_marks[1]:get()
|
|
local ls2 = h.edit.line_start_marks[2]:get()
|
|
local eol1 = ls2 - 1 -- '\n' between line 1 and line 2
|
|
h.buf:replace(ls1 + NAME_START, eol1, "b.txt")
|
|
"#,
|
|
)
|
|
.expect("collide-edit");
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("commit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert!(
|
|
outcome.starts_with("failed: "),
|
|
"collision must fail commit; got: {outcome}"
|
|
);
|
|
assert!(
|
|
outcome.contains("collide") || outcome.contains("final name"),
|
|
"error must describe collision: {outcome}"
|
|
);
|
|
// No syscall ran: both files still in their original state.
|
|
assert!(dir.path().join("a.txt").exists(), "a.txt must remain");
|
|
assert!(dir.path().join("b.txt").exists(), "b.txt must remain");
|
|
let a_content = std::fs::read(dir.path().join("a.txt")).unwrap();
|
|
assert_eq!(a_content, b"A-content", "a.txt content unchanged");
|
|
let b_content = std::fs::read(dir.path().join("b.txt")).unwrap();
|
|
assert_eq!(b_content, b"B-content", "b.txt content unchanged");
|
|
}
|
|
|
|
// Swap case: a -> b, b -> a. Final names are unique (no duplicate-final-name
|
|
// rejection), but a direct rename(a, b) would destroy b. The two-phase
|
|
// temp-name detour makes this safe.
|
|
#[test]
|
|
fn wdired_name_swap_via_two_phase_rename_succeeds() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("a.txt"), b"A-content").expect("write a");
|
|
std::fs::write(dir.path().join("b.txt"), b"B-content").expect("write b");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// Same-length swaps. Replacing same-length keeps the gravity-right
|
|
// line-start marks at their original byte positions.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("swap"),
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local NAME_START = require("pmacs-dired")._test.NAME_START
|
|
local ls1 = h.edit.line_start_marks[1]:get()
|
|
local ls2 = h.edit.line_start_marks[2]:get()
|
|
local eol1 = ls2 - 1
|
|
local eol2 = h.buf:len()
|
|
-- a.txt (line 1) -> "b.txt"
|
|
h.buf:replace(ls1 + NAME_START, eol1, "b.txt")
|
|
-- b.txt (line 2) -> "a.txt"
|
|
h.buf:replace(ls2 + NAME_START, eol2, "a.txt")
|
|
"#,
|
|
)
|
|
.expect("swap-edit");
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("commit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert_eq!(outcome, "ok", "swap commit must succeed; got: {outcome}");
|
|
|
|
// Disk shows swapped names with original contents.
|
|
let a_after = std::fs::read(dir.path().join("a.txt")).unwrap();
|
|
let b_after = std::fs::read(dir.path().join("b.txt")).unwrap();
|
|
assert_eq!(
|
|
a_after, b"B-content",
|
|
"a.txt now holds B's original content"
|
|
);
|
|
assert_eq!(
|
|
b_after, b"A-content",
|
|
"b.txt now holds A's original content"
|
|
);
|
|
|
|
// No leftover temp files.
|
|
let entries: Vec<String> = std::fs::read_dir(dir.path())
|
|
.unwrap()
|
|
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
|
|
.collect();
|
|
for e in &entries {
|
|
assert!(
|
|
!e.contains(".pmacs-wdired-tmp-"),
|
|
"no temp files must remain; got: {entries:?}"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
entries.len(),
|
|
2,
|
|
"only 2 entries expected; got: {entries:?}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pass-2 Finding High3 --- wdired-abandon refreshes from disk
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// The wdired-commit error guidance points users at wdired-abandon to "refresh
|
|
// the listing from disk". Abandon must therefore actually re-read the
|
|
// directory, not just repaint from the cached snapshot.
|
|
|
|
#[test]
|
|
fn wdired_abandon_refreshes_listing_from_disk() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("orig.txt"), b"x").expect("write");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// Externally rename orig.txt -> renamed.txt while the user is in
|
|
// wdired-edit mode --- the precise scenario abandon should resync.
|
|
std::fs::rename(dir.path().join("orig.txt"), dir.path().join("renamed.txt"))
|
|
.expect("external rename");
|
|
|
|
// Abandon: schedules an async refresh. Pump until the buffer
|
|
// shows the post-rename listing.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("abandon"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-abandon")"#,
|
|
)
|
|
.expect("abandon");
|
|
pump_until(&mut state, |s| {
|
|
s.lua_host
|
|
.lua()
|
|
.load(
|
|
r"
|
|
local buf = pmacs.window.buffer()
|
|
local text = buf:slice(0, buf:len())
|
|
return text:find('renamed.txt', 1, true) ~= nil
|
|
",
|
|
)
|
|
.eval::<bool>()
|
|
.unwrap_or(false)
|
|
});
|
|
|
|
let text = active_buffer_text(&mut state);
|
|
assert!(
|
|
text.contains("renamed.txt"),
|
|
"buffer must show post-disk listing after abandon; got: {text}"
|
|
);
|
|
assert!(
|
|
!text.contains("orig.txt"),
|
|
"buffer must not show pre-rename name after abandon; got: {text}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pass-2 Finding Med4 --- detect_external_changes catches mode change
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// The previous implementation compared only count + name set. An external
|
|
// chmod, truncate, or kind switch slipped through. This test pins the mode
|
|
// case; size / mtime / kind would all surface analogously.
|
|
|
|
#[test]
|
|
fn wdired_external_mode_change_at_commit_aborts() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let f = dir.path().join("file.txt");
|
|
std::fs::write(&f, b"x").expect("write");
|
|
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o644)).expect("chmod 0o644");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// External chmod between snapshot and commit. No user edit; commit
|
|
// is intent-empty but external-change detection must abort it.
|
|
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o600)).expect("external chmod");
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("commit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert!(
|
|
outcome.starts_with("failed: "),
|
|
"external mode change must fail commit; got: {outcome}"
|
|
);
|
|
assert!(
|
|
outcome.contains("mode change") && outcome.contains("file.txt"),
|
|
"error must describe the external mode change on file.txt: {outcome}"
|
|
);
|
|
// File mode on disk reflects the external chmod, not the snapshot.
|
|
let mode = std::fs::metadata(&f).unwrap().permissions().mode() & 0o777;
|
|
assert_eq!(mode, 0o600, "external chmod must remain on disk");
|
|
}
|
|
|
|
#[test]
|
|
fn wdired_external_same_size_same_second_rewrite_aborts() {
|
|
for _attempt in 0..25 {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let f = dir.path().join("file.txt");
|
|
std::fs::write(&f, b"aa").expect("write initial");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
let before = file_mtime_parts(&f);
|
|
std::fs::write(&f, b"bb").expect("same-size rewrite");
|
|
let after = file_mtime_parts(&f);
|
|
if before.0 != after.0 || before.1 == after.1 {
|
|
continue;
|
|
}
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("commit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert!(
|
|
outcome.starts_with("failed: "),
|
|
"same-second rewrite must fail commit; got: {outcome}"
|
|
);
|
|
assert!(
|
|
outcome.contains("mtime change") && outcome.contains("file.txt"),
|
|
"error must describe external mtime change on file.txt: {outcome}"
|
|
);
|
|
return;
|
|
}
|
|
panic!("could not produce same-second rewrite with distinct nanoseconds");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pass-2 Finding Med5 --- inserts in perms rejected (length-changing)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Inserting a char into the perms column would shift every subsequent column
|
|
// and break the fixed-width layout the commit parser relies on. The
|
|
// intercept must reject the insert at edit time, not let it through and
|
|
// fail later at parse time.
|
|
|
|
#[test]
|
|
fn wdired_perms_column_insert_rejected_at_intercept() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// Insert a valid alphabet char ('r') into the middle of the perms
|
|
// column. Bytes are valid; the rejection is for the length change.
|
|
let msg: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local ls = h.edit.line_start_marks[1]:get()
|
|
local ok, err = pcall(function()
|
|
h.buf:insert(ls + 3, "r")
|
|
end)
|
|
assert(not ok, "insert in perms must be rejected")
|
|
return tostring(err)
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("perms-insert eval");
|
|
assert!(
|
|
msg.contains("fixed-width") || msg.contains("name column"),
|
|
"rejection must explain fixed-width / name-only inserts; got: {msg}"
|
|
);
|
|
|
|
// Same check for delete inside perms: must also be rejected.
|
|
let del_msg: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local ls = h.edit.line_start_marks[1]:get()
|
|
local ok, err = pcall(function()
|
|
h.buf:replace(ls + 1, ls + 2, "") -- delete one perms char
|
|
end)
|
|
assert(not ok, "delete in perms must be rejected")
|
|
return tostring(err)
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("perms-delete eval");
|
|
assert!(
|
|
del_msg.contains("fixed-width") || del_msg.contains("preserve"),
|
|
"rejection must explain fixed-width / length preservation; got: {del_msg}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pass-2 Finding Med6 --- symlink target edits rejected at commit
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Symlink target editing is out of scope for v0.1 wdired. Previously the
|
|
// commit silently stripped the rendered " -> target" suffix and produced no
|
|
// op when the user edited only the target --- their edit was discarded with
|
|
// no signal. Now an edited target text different from the snapshot's
|
|
// rendering aborts the commit explicitly.
|
|
|
|
#[test]
|
|
fn wdired_symlink_target_edit_rejected_at_commit() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("real.txt"), b"contents").expect("write target");
|
|
std::os::unix::fs::symlink(dir.path().join("real.txt"), dir.path().join("link"))
|
|
.expect("create symlink");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// Find the symlink line and overwrite its name region with a
|
|
// different target text but the same basename. The commit must
|
|
// refuse: target edits are not supported in v0.1.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("target-edit"),
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local NAME_START = require("pmacs-dired")._test.NAME_START
|
|
local symlink_idx
|
|
for i, e in ipairs(h.edit.snapshot) do
|
|
if e.kind == "symlink" then symlink_idx = i; break end
|
|
end
|
|
assert(symlink_idx, "symlink must be in the snapshot")
|
|
local ls = h.edit.line_start_marks[symlink_idx]:get()
|
|
local line_end
|
|
if symlink_idx < #h.edit.line_start_marks then
|
|
line_end = h.edit.line_start_marks[symlink_idx + 1]:get() - 1
|
|
else
|
|
line_end = h.buf:len()
|
|
end
|
|
-- Replace the entire name region. The basename "link"
|
|
-- is unchanged but the target portion now reads
|
|
-- "elsewhere", which doesn't match the snapshot's
|
|
-- escape_displayable(real.txt) -- different bytes.
|
|
h.buf:replace(ls + NAME_START, line_end, "link -> elsewhere")
|
|
"#,
|
|
)
|
|
.expect("target-edit");
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("commit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert!(
|
|
outcome.starts_with("failed: "),
|
|
"symlink target edit must fail commit; got: {outcome}"
|
|
);
|
|
assert!(
|
|
outcome.contains("symlink target"),
|
|
"error must say 'symlink target': {outcome}"
|
|
);
|
|
|
|
// Disk symlink unchanged.
|
|
let target = std::fs::read_link(dir.path().join("link")).expect("readlink");
|
|
assert_eq!(
|
|
target,
|
|
dir.path().join("real.txt"),
|
|
"symlink target must not have been changed on disk"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pass-2 review High1 --- chmod on a symlink line is rejected at intercept
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Per src/fs.rs:349, `chmod(2)` follows symlinks: chmodding the displayed
|
|
// link line would silently mutate the *target* file's mode, while a refresh
|
|
// would then show the link's unchanged lstat perms (commonly 0o777). That
|
|
// asymmetry is too sharp an edge to leave editable in v0.1 wdired.
|
|
|
|
#[test]
|
|
fn wdired_symlink_perms_edit_rejected_at_intercept() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("real.txt"), b"contents").expect("write target");
|
|
std::os::unix::fs::symlink(dir.path().join("real.txt"), dir.path().join("link"))
|
|
.expect("create symlink");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// Find the symlink line and try to replace one perms char. Same-length
|
|
// replace + valid alphabet would normally pass --- the rejection here
|
|
// is specifically because the line is a symlink.
|
|
let msg: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local symlink_idx
|
|
for i, e in ipairs(h.edit.snapshot) do
|
|
if e.kind == "symlink" then symlink_idx = i; break end
|
|
end
|
|
assert(symlink_idx, "symlink must be in the snapshot")
|
|
local ls = h.edit.line_start_marks[symlink_idx]:get()
|
|
-- Position 1 is the first perms char of this line.
|
|
local ok, err = pcall(function()
|
|
h.buf:replace(ls + 1, ls + 2, "r")
|
|
end)
|
|
assert(not ok, "perms replace on symlink line must be rejected")
|
|
return tostring(err)
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("symlink-perms eval");
|
|
assert!(
|
|
msg.contains("symlink") && msg.contains("perms column"),
|
|
"rejection must mention symlink + perms column: {msg}"
|
|
);
|
|
assert!(
|
|
msg.contains("chmod follows symlinks"),
|
|
"rejection must explain why (chmod follows symlinks): {msg}"
|
|
);
|
|
|
|
// Disk untouched: target file's mode and the link itself unchanged.
|
|
let target_mode = std::fs::metadata(dir.path().join("real.txt"))
|
|
.unwrap()
|
|
.permissions()
|
|
.mode()
|
|
& 0o777;
|
|
let link_target =
|
|
std::fs::read_link(dir.path().join("link")).expect("readlink after rejection");
|
|
assert_eq!(
|
|
link_target,
|
|
dir.path().join("real.txt"),
|
|
"symlink unchanged"
|
|
);
|
|
// We didn't set an explicit mode on real.txt, so we don't assert a
|
|
// specific number; we just confirm the rejection path didn't fire a
|
|
// chmod (the commit was never reached).
|
|
let _ = target_mode;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pass-2 review High2 --- decoded names containing NUL are rejected
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// unescape_displayable accepts \x00, but POSIX filenames cannot contain NUL.
|
|
// Without this check, a chmod might fire on the source path before the
|
|
// rename syscall fails at the kernel boundary --- a partial commit. Reject
|
|
// at the planner.
|
|
|
|
#[test]
|
|
fn wdired_commit_rejects_nul_in_decoded_name() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// Replace name with literal "bad\x00name" --- escape_displayable would
|
|
// *render* such a real name as "bad\\x00name", and the user typing
|
|
// exactly that produces "bad<NUL>name" via unescape. Reject pre-syscall.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("nul-name"),
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local NAME_START = require("pmacs-dired")._test.NAME_START
|
|
local ls = h.edit.line_start_marks[1]:get()
|
|
h.buf:replace(ls + NAME_START, h.buf:len(), "bad\\x00name")
|
|
"#,
|
|
)
|
|
.expect("nul-name edit");
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("commit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert!(
|
|
outcome.starts_with("failed: "),
|
|
"NUL in decoded name must fail commit; got: {outcome}"
|
|
);
|
|
assert!(outcome.contains("NUL"), "error must mention NUL: {outcome}");
|
|
// Original file unchanged.
|
|
assert!(
|
|
dir.path().join("file.txt").exists(),
|
|
"file.txt must remain since commit was rejected"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pass-2 review Med3 --- symlink names containing literal " -> " round-trip
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// A symlink whose basename literally contains " -> " renders as
|
|
// `<name with arrow> -> <target>`. The naive "split at first ' -> '" decode
|
|
// would mistake everything after the first arrow for the target text and
|
|
// flag a no-op commit as a target edit. Suffix-stripping fixes this.
|
|
|
|
#[test]
|
|
fn wdired_symlink_basename_with_arrow_substring_round_trips() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("real.txt"), b"contents").expect("write target");
|
|
// Create a symlink whose basename literally contains " -> ".
|
|
let weird_link_name = "a -> b";
|
|
std::os::unix::fs::symlink(
|
|
dir.path().join("real.txt"),
|
|
dir.path().join(weird_link_name),
|
|
)
|
|
.expect("create symlink with arrow in name");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// No-op commit. Must succeed, no rename, link unchanged.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("commit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert_eq!(
|
|
outcome, "ok",
|
|
"no-op commit on weirdly-named symlink must succeed; got: {outcome}"
|
|
);
|
|
// Symlink with arrow-in-name still exists, still pointing at real.txt.
|
|
let link_target = std::fs::read_link(dir.path().join(weird_link_name))
|
|
.expect("link with arrow in name still readable");
|
|
assert_eq!(
|
|
link_target,
|
|
dir.path().join("real.txt"),
|
|
"symlink target unchanged"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pass-2 review Med4 --- syscall success and refresh failure are distinct
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// If all chmod / rename ops landed but the post-commit navigate_to fails
|
|
// (e.g., the parent dir vanished between the last syscall and the
|
|
// refresh), the disk changes are real --- reporting "failed:" would
|
|
// mislead the user. The new outcome shape is "applied; refresh failed: ...".
|
|
//
|
|
// We exercise the split by injecting a synthetic failure into
|
|
// `pmacs.fs.read_dir`'s *second* call within wdired-commit: the first
|
|
// is `detect_external_changes` (must pass to reach the syscall phase),
|
|
// the second is the post-commit `navigate_to` refresh (forced to fail).
|
|
// A no-op commit (nothing to chmod / rename) reaches the refresh
|
|
// without touching disk, so the test is deterministic --- no real
|
|
// syscalls race the hook.
|
|
|
|
#[test]
|
|
fn wdired_refresh_failure_after_apply_reports_applied_distinctly() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("only.txt"), b"x").expect("write only");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
// Hook pmacs.fs.read_dir: first call (external-change detection)
|
|
// delegates to the real impl, second + later calls return a future
|
|
// whose :await() raises a synthetic failure.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("install-readdir-hook"),
|
|
r#"
|
|
local real = pmacs.fs.read_dir
|
|
local calls = 0
|
|
pmacs.fs.read_dir = function(path)
|
|
calls = calls + 1
|
|
if calls >= 2 then
|
|
return setmetatable({}, {
|
|
__index = {
|
|
await = function()
|
|
error("synthetic refresh failure")
|
|
end,
|
|
},
|
|
})
|
|
end
|
|
return real(path)
|
|
end
|
|
"#,
|
|
)
|
|
.expect("hook install");
|
|
|
|
// No-op commit. Validation + planning succeed (no chmod / rename
|
|
// ops since nothing changed); detect_external_changes reads disk
|
|
// (call #1, real). do_wdired_commit returns cleanly. The async
|
|
// wrapper then calls navigate_to (call #2, forced failure) ---
|
|
// outcome must be "applied; refresh failed: ...".
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("commit-noop"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert!(
|
|
outcome.starts_with("applied; refresh failed: "),
|
|
"syscall success + refresh failure must use applied-prefix outcome; got: {outcome}"
|
|
);
|
|
assert!(
|
|
outcome.contains("synthetic refresh failure"),
|
|
"outcome must surface the underlying refresh error: {outcome}"
|
|
);
|
|
|
|
// Disk untouched (no-op commit). The original file is still there.
|
|
assert!(
|
|
dir.path().join("only.txt").exists(),
|
|
"no-op commit must not have touched disk"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wdired_commit_rejects_second_invocation_while_pending() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("only.txt"), b"x").expect("write only");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
let msg: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local ok1, err1 = pcall(function()
|
|
pmacs.command.invoke("pmacs-dired.wdired-commit")
|
|
end)
|
|
assert(ok1, "first commit should schedule cleanly: " .. tostring(err1))
|
|
local ok2, err2 = pcall(function()
|
|
pmacs.command.invoke("pmacs-dired.wdired-commit")
|
|
end)
|
|
assert(not ok2, "second commit while first is pending must be rejected")
|
|
return tostring(err2)
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("double commit eval");
|
|
assert!(
|
|
msg.contains("already in progress"),
|
|
"second commit error must explain in-progress commit; got: {msg}"
|
|
);
|
|
|
|
pump_until_commit_done(&mut state);
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert_eq!(
|
|
outcome, "ok",
|
|
"first commit should still complete; got: {outcome}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wdired_mid_batch_rename_failure_reports_partial_and_refreshes() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("a.txt"), b"a").expect("write a");
|
|
std::fs::write(dir.path().join("b.txt"), b"b").expect("write b");
|
|
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("wd-edit"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
|
|
)
|
|
.expect("wdired-edit");
|
|
|
|
edit_wdired_names(&mut state, &[("a.txt", "aa.txt"), ("b.txt", "bb.txt")]);
|
|
install_second_rename_failure_hook(&mut state);
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("commit-partial"),
|
|
r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
|
|
)
|
|
.expect("commit eval");
|
|
pump_until_commit_done(&mut state);
|
|
|
|
let outcome = read_commit_outcome(&mut state);
|
|
assert!(
|
|
outcome.starts_with("partially applied: "),
|
|
"mid-batch syscall failure must report partial application; got: {outcome}"
|
|
);
|
|
assert!(
|
|
outcome.contains("synthetic mid-batch rename failure") && outcome.contains("refreshed"),
|
|
"outcome must include original failure and refresh status; got: {outcome}"
|
|
);
|
|
|
|
assert!(
|
|
!wdired_edit_active(&mut state),
|
|
"partial application must leave wdired-edit mode"
|
|
);
|
|
|
|
assert!(
|
|
!dir.path().join("a.txt").exists(),
|
|
"first rename should have moved a.txt to a temp name before the injected failure"
|
|
);
|
|
assert!(
|
|
dir.path().join("b.txt").exists(),
|
|
"second rename should not have run the real syscall"
|
|
);
|
|
let entries: Vec<String> = std::fs::read_dir(dir.path())
|
|
.expect("read tempdir")
|
|
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
|
.collect();
|
|
assert!(
|
|
entries.iter().any(|e| e.contains(".pmacs-wdired-tmp-")),
|
|
"partial failure should leave the already-moved temp name visible; got: {entries:?}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// M8.4 audit finding 2 --- paint() must clear the painting flag on error
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// The `painting` flag is a bypass-flag pattern (M6.9 finding 7 shape) that
|
|
// tells the package's wdired intercept to let paint()'s own writes pass
|
|
// through without re-validating each as a user edit. If `:replace` raises
|
|
// while the flag is set, the flag must still clear --- otherwise the next
|
|
// user edit would silently bypass the intercept and corrupt the buffer
|
|
// without validation. The audit fix wraps the buffer write in pcall.
|
|
//
|
|
// To exercise the failure path deterministically, attach a *second*
|
|
// intercept that always raises. The package's intercept (when present)
|
|
// honors the painting flag; a foreign intercept does not, so it fires
|
|
// during paint() and triggers the error path.
|
|
|
|
#[test]
|
|
fn dired_paint_clears_painting_flag_on_buffer_replace_error() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
|
|
// Sort once to confirm the baseline (no error, paint clean).
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("baseline-sort"),
|
|
r#"pmacs.command.invoke("pmacs-dired.sort-name")"#,
|
|
)
|
|
.expect("baseline sort");
|
|
|
|
// Attach a foreign intercept that always raises. This intercept
|
|
// doesn't know about the package's `painting` flag, so it fires
|
|
// even when paint() is in progress.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("attach-failing-intercept"),
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
_G.M8_4_FOREIGN_INTERCEPT = pmacs.buffer.add_intercept(
|
|
h.buf,
|
|
function(op) error("synthetic intercept failure") end
|
|
)
|
|
"#,
|
|
)
|
|
.expect("attach intercept");
|
|
|
|
// Trigger paint via a sort command. Paint flips painting=true,
|
|
// calls :replace, the foreign intercept raises, the buffer write
|
|
// errors. The package's pcall wrap should restore painting=false
|
|
// before re-raising.
|
|
let painting_state: bool = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local h = require("pmacs-dired")._test.active_handle()
|
|
local ok, err = pcall(function()
|
|
pmacs.command.invoke("pmacs-dired.sort-mtime")
|
|
end)
|
|
assert(not ok, "sort must propagate the synthetic failure")
|
|
-- The flag must be cleared (false or nil --- both count).
|
|
return h.painting == true
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("paint-flag eval");
|
|
assert!(
|
|
!painting_state,
|
|
"painting flag must clear after a paint() error; if it stays \
|
|
set, subsequent user edits silently bypass the intercept"
|
|
);
|
|
|
|
// Detach the foreign intercept so the editor cleanup is clean.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("detach-foreign-intercept"),
|
|
"pmacs.buffer.remove_intercept(_G.M8_4_FOREIGN_INTERCEPT)",
|
|
)
|
|
.expect("detach intercept");
|
|
}
|
|
|
|
// Companion to the previous test, pinning the *eager-evaluation* case the
|
|
// initial pcall scope missed: Lua evaluates pcall's argument list before
|
|
// entering the protected scope, so `pcall(fn, a, b, buf:len())` would
|
|
// invoke `buf:len()` outside the pcall. If `:len()` raises (e.g., because
|
|
// the underlying buffer was removed via `pmacs.buffer.remove` between
|
|
// paint() entries), the painting flag would skip past its clear-step.
|
|
// The corrected fix wraps the *whole* op in a closure: `pcall(function()
|
|
// handle.buf:replace(0, handle.buf:len(), text) end)`. This test forces a
|
|
// `:len()` failure by stubbing `handle.buf` with a Lua table that errors
|
|
// on :len; the flag must still clear.
|
|
#[test]
|
|
fn dired_paint_clears_painting_flag_on_len_error_too() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
|
|
// Stub handle.buf so :len() raises before :replace is reached.
|
|
// We invoke paint() directly via the test seam (not through a
|
|
// command) to avoid `active_handle()`'s identity check, which
|
|
// matches handles by `h.buf == active_buffer` --- swapping
|
|
// h.buf for a stub table would break that lookup and the
|
|
// command would short-circuit before reaching paint().
|
|
// We restore h.buf before the assertions so editor teardown is clean.
|
|
let painting_state: bool = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local te = require("pmacs-dired")._test
|
|
local h = te.active_handle()
|
|
local original_buf = h.buf
|
|
h.buf = setmetatable({}, {
|
|
__index = function(_, k)
|
|
if k == "len" then
|
|
return function() error("synthetic len failure") end
|
|
end
|
|
if k == "replace" then
|
|
return function() error("should not reach replace") end
|
|
end
|
|
return original_buf[k]
|
|
end,
|
|
})
|
|
local ok, err = pcall(te.paint, h)
|
|
local stuck = (h.painting == true)
|
|
h.buf = original_buf
|
|
assert(not ok, "paint must propagate the synthetic len failure")
|
|
return stuck
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("paint-flag-on-len-error eval");
|
|
assert!(
|
|
!painting_state,
|
|
"painting flag must clear even when :len() fails before :replace; \
|
|
eager-evaluation of pcall arguments would otherwise skip the clear-step"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dired_active_handle_is_cleared_when_active_buffer_removed() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
|
|
let (mut state, _c, _u) = editor_with_dired();
|
|
open_dired_on(&mut state, &dir.path().display().to_string());
|
|
|
|
let active_cleared: bool = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local te = require("pmacs-dired")._test
|
|
local h = te.active_handle()
|
|
assert(h ~= nil, "dired handle should exist before remove")
|
|
pmacs.buffer.remove(h.buf)
|
|
return te.active_handle() == nil
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("active-handle-after-remove eval");
|
|
assert!(
|
|
active_cleared,
|
|
"active_handle must not return a stale handle after its buffer is removed"
|
|
);
|
|
}
|