// tests/m8_1_acceptance.rs --- T M8.1 filesystem worker primitives. //! Acceptance tests for the M8.1 deliverable: a worker-dispatched //! `pmacs.fs.*` surface that packages can use without reaching into //! the Rust core. The dired-class package (T M8.2) is the immediate //! consumer; magit-class (T M8.6) and outline-class (T M8.10) add to //! the case load. //! //! Each test drives a real `EditorState` so the dispatch goes through //! the production wiring: `pmacs._async._dispatch_fs_*` raw bindings, //! the worker pool, the bus reply path, and `tick_async`'s //! settle-detection loop. The Lua-side coroutine + `:await()` //! mechanics from M3 carry the result back to the test caller. use std::path::PathBuf; use std::time::{Duration, Instant}; use pmacs::editor::EditorState; /// Drive `tick_async` until `predicate` returns `true` or the deadline /// passes. Mirrors the helper in `tests/m4_acceptance.rs` so each /// test file owns its pump (avoids cross-test private-helper /// dependencies). fn pump_until bool>(state: &mut EditorState, predicate: F) { let deadline = Instant::now() + Duration::from_secs(2); while !predicate(state) { assert!( Instant::now() < deadline, "async pump deadline exceeded after 2s" ); state.tick_async(); std::thread::sleep(Duration::from_millis(2)); } } /// Spin up a fresh editor with no file open. The caller drives the /// fs API entirely from Lua chunks via `lua_host.eval`. fn fresh_editor() -> EditorState { EditorState::new_with_roots(&crate::iso::roots()) } /// Run a Lua chunk to completion (no `:await`); returns the chunk's /// return value as `T`. Convenience for chunks that don't dispatch /// async work. fn eval_sync(state: &mut EditorState, chunk: &str) -> T { state .lua_host .lua() .load(chunk) .eval::() .expect("eval sync chunk") } // --------------------------------------------------------------------------- // T M8.1 --- pmacs.fs.read_dir returns one entry per child with lstat // --------------------------------------------------------------------------- #[test] fn read_dir_returns_one_entry_per_child_with_lstat_metadata() { let dir = tempfile::tempdir().expect("tempdir"); std::fs::write(dir.path().join("a.txt"), b"hello").expect("write"); std::fs::write(dir.path().join("b.txt"), b"world!").expect("write"); std::fs::create_dir(dir.path().join("subdir")).expect("mkdir"); let mut state = fresh_editor(); let path_str = dir.path().display().to_string(); // Kick off pmacs.async-spawned coroutine that calls // pmacs.fs.read_dir(...):await() and stashes the result on a // global so the test can inspect it after pumping. let chunk = format!( r#" _G.M8_1_RESULT = nil _G.M8_1_ERR = nil pmacs.async(function() local ok, value = pcall(function() return pmacs.fs.read_dir("{path_str}"):await() end) if ok then _G.M8_1_RESULT = value else _G.M8_1_ERR = tostring(value) end end) "#, ); eval_sync::<()>(&mut state, &chunk); pump_until(&mut state, |s| { let lua = s.lua_host.lua(); lua.globals().get::("M8_1_RESULT").is_ok() && lua .globals() .get::("M8_1_RESULT") .is_ok_and(|v| !matches!(v, mlua::Value::Nil)) || lua .globals() .get::("M8_1_ERR") .is_ok_and(|v| !matches!(v, mlua::Value::Nil)) }); // Pull the entries back into Rust as a Vec<(name, kind, size)> // for shape assertions. let probe = r#" if _G.M8_1_ERR then return { error = _G.M8_1_ERR } end local out = {} for i, e in ipairs(_G.M8_1_RESULT) do out[i] = { name = e.name, kind = e.kind, size = e.size, has_mtime = type(e.mtime) == "number", has_mtime_nsec = type(e.mtime_nsec) == "number", has_mode = type(e.mode) == "number", has_target = e.symlink_target ~= nil, } end return out "#; let entries: mlua::Table = state.lua_host.lua().load(probe).eval().expect("probe"); if let Ok(err) = entries.get::("error") { panic!("read_dir errored: {err}"); } let len: usize = entries.len().map_or(0, |n| n as usize); assert_eq!(len, 3, "expected 3 entries, got {len}"); // Names are filesystem-iteration-ordered; gather and sort. let mut by_name: std::collections::BTreeMap = std::collections::BTreeMap::new(); for i in 1..=len { let row: mlua::Table = entries.get(i).unwrap(); let name: String = row.get("name").unwrap(); let kind: String = row.get("kind").unwrap(); let size: i64 = row.get("size").unwrap(); let has_mtime: bool = row.get("has_mtime").unwrap(); let has_mtime_nsec: bool = row.get("has_mtime_nsec").unwrap(); assert!(has_mtime, "{name} must include numeric mtime seconds"); assert!( has_mtime_nsec, "{name} must include numeric mtime nanoseconds" ); by_name.insert(name, (kind, size)); } let a = by_name.get("a.txt").expect("a.txt entry"); assert_eq!(a.0, "file"); assert_eq!(a.1, 5); let b = by_name.get("b.txt").expect("b.txt entry"); assert_eq!(b.0, "file"); assert_eq!(b.1, 6); let s = by_name.get("subdir").expect("subdir entry"); assert_eq!(s.0, "dir"); } // --------------------------------------------------------------------------- // T M8.1 --- symlink target is its own field, not collapsed into name // --------------------------------------------------------------------------- #[test] fn read_dir_reports_symlinks_with_separate_target_field() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().expect("tempdir"); std::fs::write(dir.path().join("real.txt"), b"x").expect("write"); symlink("real.txt", dir.path().join("link")).expect("symlink"); let mut state = fresh_editor(); let path_str = dir.path().display().to_string(); let chunk = format!( r#" _G.RESULT = nil pmacs.async(function() _G.RESULT = pmacs.fs.read_dir("{path_str}"):await() end) "#, ); eval_sync::<()>(&mut state, &chunk); pump_until(&mut state, |s| { s.lua_host .lua() .globals() .get::("RESULT") .is_ok_and(|v| !matches!(v, mlua::Value::Nil)) }); let probe = r#" local link, real for _, e in ipairs(_G.RESULT) do if e.name == "link" then link = e end if e.name == "real.txt" then real = e end end return { link_kind = link and link.kind, link_target = link and link.symlink_target, real_kind = real and real.kind, real_target = real and real.symlink_target, } "#; let row: mlua::Table = state.lua_host.lua().load(probe).eval().expect("probe"); assert_eq!( row.get::("link_kind").unwrap(), "symlink", "link entry must report kind=symlink" ); assert_eq!( row.get::("link_target").unwrap(), "real.txt", "link entry must carry the target via symlink_target" ); assert_eq!( row.get::("real_kind").unwrap(), "file", "real.txt is not a symlink" ); assert!( matches!( row.get::("real_target").unwrap(), mlua::Value::Nil ), "non-symlinks must not carry symlink_target" ); } // --------------------------------------------------------------------------- // T M8.1 --- supersede semantics: second read_dir under the same key // cancels the first // --------------------------------------------------------------------------- #[test] fn read_dir_supersede_cancels_in_flight_predecessor() { // A directory with enough entries that the cancel-poll boundary // is visible. Realistically supersede latency is bounded by // worker dispatch + a single readdir loop iteration; we just // need the first call to be in flight when the second comes in. let dir = tempfile::tempdir().expect("tempdir"); for i in 0..256 { std::fs::write(dir.path().join(format!("f{i}")), b"").expect("write"); } let mut state = fresh_editor(); let path_str = dir.path().display().to_string(); let chunk = format!( r#" _G.A_STATUS = nil _G.B_RESULT = nil local h_a = pmacs.fs.read_dir("{path_str}", {{ supersede = "dired" }}) pmacs.async(function() local ok, err = pcall(function() return h_a:await() end) if ok then _G.A_STATUS = "ok" elseif type(err) == "table" and err.tag == "cancelled" then _G.A_STATUS = "cancelled" else _G.A_STATUS = "other:" .. tostring(err) end end) local h_b = pmacs.fs.read_dir("{path_str}", {{ supersede = "dired" }}) pmacs.async(function() _G.B_RESULT = h_b:await() end) "#, ); eval_sync::<()>(&mut state, &chunk); pump_until(&mut state, |s| { let lua = s.lua_host.lua(); lua.globals() .get::("A_STATUS") .is_ok_and(|v| !matches!(v, mlua::Value::Nil)) && lua .globals() .get::("B_RESULT") .is_ok_and(|v| !matches!(v, mlua::Value::Nil)) }); let a_status: String = state.lua_host.lua().globals().get("A_STATUS").unwrap(); assert_eq!( a_status, "cancelled", "first read_dir must be superseded; got {a_status}" ); let b_len: i64 = state .lua_host .lua() .load("return #_G.B_RESULT") .eval() .unwrap(); assert_eq!(b_len, 256, "second read_dir should return all 256 entries"); } // --------------------------------------------------------------------------- // T M8.1 --- read_dir on a missing path surfaces a structured error // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // T M8.1 --- stat returns metadata for a single path // --------------------------------------------------------------------------- #[test] fn stat_returns_lstat_metadata_for_a_single_path() { let dir = tempfile::tempdir().expect("tempdir"); let p = dir.path().join("hello.txt"); std::fs::write(&p, b"hello world").expect("write"); let mut state = fresh_editor(); let path_str = p.display().to_string(); let chunk = format!( r#" _G.STAT = nil pmacs.async(function() _G.STAT = pmacs.fs.stat("{path_str}"):await() end) "#, ); eval_sync::<()>(&mut state, &chunk); pump_until(&mut state, |s| { s.lua_host .lua() .globals() .get::("STAT") .is_ok_and(|v| !matches!(v, mlua::Value::Nil)) }); let probe = r" return { name = _G.STAT.name, kind = _G.STAT.kind, size = _G.STAT.size, has_target = _G.STAT.symlink_target ~= nil, } "; let row: mlua::Table = state.lua_host.lua().load(probe).eval().expect("probe"); assert_eq!(row.get::("name").unwrap(), "hello.txt"); assert_eq!(row.get::("kind").unwrap(), "file"); assert_eq!(row.get::("size").unwrap(), 11); assert!(!row.get::("has_target").unwrap()); } // --------------------------------------------------------------------------- // T M8.1 --- rename moves a file on disk // --------------------------------------------------------------------------- #[test] fn rename_moves_a_file_and_settles_with_unit_status() { let dir = tempfile::tempdir().expect("tempdir"); let from = dir.path().join("a.txt"); let to = dir.path().join("b.txt"); std::fs::write(&from, b"x").expect("write"); let mut state = fresh_editor(); let from_s = from.display().to_string(); let to_s = to.display().to_string(); let chunk = format!( r#" _G.DONE = nil pmacs.async(function() pmacs.fs.rename("{from_s}", "{to_s}"):await() _G.DONE = true end) "#, ); eval_sync::<()>(&mut state, &chunk); pump_until(&mut state, |s| { s.lua_host .lua() .globals() .get::("DONE") .unwrap_or(false) }); assert!(!from.exists(), "source must be gone after rename"); assert!(to.exists(), "target must exist after rename"); } // --------------------------------------------------------------------------- // T M8.1 --- chmod replaces permission bits // --------------------------------------------------------------------------- #[test] fn chmod_replaces_permission_bits() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().expect("tempdir"); let p = dir.path().join("f.txt"); std::fs::write(&p, b"x").expect("write"); // Start at 0o644 so the test sees a real change. std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).expect("seed perms"); let mut state = fresh_editor(); let path_str = p.display().to_string(); let chunk = format!( r#" _G.DONE = nil pmacs.async(function() pmacs.fs.chmod("{path_str}", 0x1a4):await() -- 0o644 pmacs.fs.chmod("{path_str}", 0x180):await() -- 0o600 _G.DONE = true end) "#, ); eval_sync::<()>(&mut state, &chunk); pump_until(&mut state, |s| { s.lua_host .lua() .globals() .get::("DONE") .unwrap_or(false) }); let bits = std::fs::metadata(&p).unwrap().permissions().mode() & 0o7777; assert_eq!( bits, 0o600, "expected 0o600 after second chmod; got 0o{bits:o}" ); } // --------------------------------------------------------------------------- // T M8.1 --- remove deletes both files and empty directories // --------------------------------------------------------------------------- #[test] fn remove_deletes_files_and_empty_directories() { let dir = tempfile::tempdir().expect("tempdir"); let f = dir.path().join("f.txt"); let d = dir.path().join("empty"); std::fs::write(&f, b"x").expect("write"); std::fs::create_dir(&d).expect("mkdir"); let mut state = fresh_editor(); let f_s = f.display().to_string(); let d_s = d.display().to_string(); let chunk = format!( r#" _G.DONE = nil pmacs.async(function() pmacs.fs.remove("{f_s}"):await() pmacs.fs.remove("{d_s}"):await() _G.DONE = true end) "#, ); eval_sync::<()>(&mut state, &chunk); pump_until(&mut state, |s| { s.lua_host .lua() .globals() .get::("DONE") .unwrap_or(false) }); assert!(!f.exists(), "file removed"); assert!(!d.exists(), "empty dir removed"); } #[test] fn remove_of_nonempty_directory_surfaces_failed_status() { let dir = tempfile::tempdir().expect("tempdir"); let d = dir.path().join("populated"); std::fs::create_dir(&d).expect("mkdir"); std::fs::write(d.join("child"), b"x").expect("write child"); let mut state = fresh_editor(); let d_s = d.display().to_string(); let chunk = format!( r#" _G.STATUS = nil _G.MSG = nil pmacs.async(function() local ok, err = pcall(function() return pmacs.fs.remove("{d_s}"):await() end) if ok then _G.STATUS = "ok" else _G.STATUS = (type(err) == "table" and err.tag) or "raw" _G.MSG = (type(err) == "table" and err.message) or tostring(err) end end) "#, ); eval_sync::<()>(&mut state, &chunk); pump_until(&mut state, |s| { s.lua_host .lua() .globals() .get::("STATUS") .is_ok_and(|v| !matches!(v, mlua::Value::Nil)) }); let status: String = state.lua_host.lua().globals().get("STATUS").unwrap(); assert_eq!( status, "failed", "non-empty dir remove must surface failed; got {status}" ); let msg: String = state.lua_host.lua().globals().get("MSG").unwrap(); assert!(msg.contains("populated"), "error must name the path: {msg}"); assert!(d.exists(), "directory must still exist after failed remove"); } #[test] fn read_dir_on_missing_path_returns_failed_status() { let dir = tempfile::tempdir().expect("tempdir"); let missing: PathBuf = dir.path().join("does-not-exist"); let mut state = fresh_editor(); let path_str = missing.display().to_string(); let chunk = format!( r#" _G.STATUS = nil _G.MESSAGE = nil pmacs.async(function() local ok, err = pcall(function() return pmacs.fs.read_dir("{path_str}"):await() end) if ok then _G.STATUS = "ok" else if type(err) == "table" then _G.STATUS = err.tag or "table" _G.MESSAGE = err.message else _G.STATUS = "raw" _G.MESSAGE = tostring(err) end end end) "#, ); eval_sync::<()>(&mut state, &chunk); pump_until(&mut state, |s| { s.lua_host .lua() .globals() .get::("STATUS") .is_ok_and(|v| !matches!(v, mlua::Value::Nil)) }); let status: String = state.lua_host.lua().globals().get("STATUS").unwrap(); assert_eq!( status, "failed", "missing-path read_dir must surface tag = 'failed'; got {status}" ); let message: String = state.lua_host.lua().globals().get("MESSAGE").unwrap(); assert!( message.contains("does-not-exist"), "error message must name the offending path; got {message}" ); } #[test] fn fs_watch_reports_file_change_and_can_cancel() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("watched.txt"); std::fs::write(&path, b"one").expect("write initial"); let mut state = fresh_editor(); let path_str = path.display().to_string(); let chunk = format!( r#" _G.WATCH_EVENTS = {{}} _G.WATCH = pmacs.fs.watch("{path_str}", function(event) WATCH_EVENTS[#WATCH_EVENTS + 1] = event.kind end, {{ interval_ms = 5 }}) "#, ); eval_sync::<()>(&mut state, &chunk); // The watcher establishes its baseline snapshot asynchronously (an // in-flight fs.stat). `pmacs._async.pending_count() == 1` cannot // distinguish that in-flight baseline read from the steady-state // poll sleep, so a single post-gate write can race the baseline, // be absorbed into it, and never produce an event (the flake this // replaces). Instead, rewrite the file with distinct-length // content on every pump iteration: whatever snapshot the watcher // captured as its baseline, a subsequent distinct write // necessarily differs from it (size differs, so it holds even on // coarse-mtime filesystems) and fires a "changed" event. let writes = std::cell::Cell::new(0u32); pump_until(&mut state, |s| { let n = writes.get() + 1; writes.set(n); std::fs::write(&path, "v".repeat(n as usize + 1)).expect("rewrite changed"); let count: i64 = s .lua_host .lua() .load("return #WATCH_EVENTS") .eval() .expect("event count"); count >= 1 }); let first: String = state .lua_host .lua() .load("return WATCH_EVENTS[1]") .eval() .expect("first watch event"); assert_eq!(first, "changed"); eval_sync::<()>(&mut state, "WATCH:cancel()"); let cancelled: bool = state .lua_host .lua() .load("return WATCH:is_cancelled()") .eval() .expect("cancelled"); assert!(cancelled, "watch handle must report cancellation"); } // 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;