feat(persistence): state foundation + saveplace + recentf (Arc 3 phase 1)

Framing: docs/persistence-framing.md. The four Rust primitives the
Lua-vs-Rust scout said were unavoidable, plus two Lua policy modules.

Rust:
- src/state.rs: state_dir(xdg,home) returning .../pmacs (generalizes
  the baked-in history path). Deliberate empty-XDG fix (Q#PS2): a blank
  XDG_STATE_HOME fell through to a RELATIVE pmacs/... path (a cwd-write
  bug); now treated as absent so it falls to HOME. Confined key->file
  store: validate_name rejects absolute / .. / empty / // / control
  chars, plus a canonical-prefix belt; read/write/remove go through
  file_io::save_atomic, never raw io.open. A PMACS_STATE_HOME override
  lets CI / privacy-conscious users / integration harnesses redirect
  all state to a scratch dir. History routed through the shared
  resolver so it honors the override too.
- pmacs.state.{write,read,remove,path,available}: a no-op when the
  state dir is unconfigured (cfg(test) / no HOME), so default-on
  builtins write nothing in the lib suite. Configured once at startup
  like history_dir, skipped under cfg(test).
- pmacs.editor.goto_byte / set_view_top: byte-exact restore (switch
  zeroes the cursor).

Lua (builtin/runtime):
- saveplace.lua: record the active file's cursor+view_top on
  before-save / before-quit; restore on after-load. LRU-capped places
  state file. On by default; pmacs.saveplace.enable(false).
- recentf.lua: MRU record on after-load AND after-switch (re-visits
  refresh the order); deduped/capped recentf file; a recent-files
  command bound C-x C-r opens the minibuffer picker.

Tests: state.rs units (validate/resolve/round-trip/empty-XDG),
tests/persistence_acceptance.rs (state round-trip + confinement
rejections, inert-when-unconfigured, recentf MRU/dedup, saveplace
restore-on-reload, disable knob) injecting a tempdir state root. One
describe-hook test made robust to a builtin now subscribing to
buffer.before-save.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-08 17:57:01 -04:00
parent 349f13f674
commit 4dd4b9ab97
9 changed files with 801 additions and 17 deletions

View File

@ -0,0 +1,86 @@
-- recentf.lua --- a most-recently-visited file list (Arc 3 Q#PS4).
--
-- Records every file buffer opened (buffer.after-load) or re-visited
-- (buffer.after-switch) into a deduped, capped, MRU-ordered `recentf`
-- state file (newline-delimited paths). `M-x recent-files` (bound
-- C-x C-r) opens the list in the minibuffer picker and visits the
-- choice.
--
-- On by default; disable from init.lua with
-- `pmacs.recentf.enable(false)`. Inert when no state dir is configured
-- (cfg(test) / no HOME).
--
-- Framing: docs/persistence-framing.md.
pmacs.recentf = pmacs.recentf or {}
local STATE_KEY = "recentf"
local MAX_ENTRIES = 50
local enabled = true
function pmacs.recentf.enable(on)
enabled = (on ~= false)
end
local function load_list()
local out = {}
local text = pmacs.state.read(STATE_KEY)
if not text then return out end
for line in text:gmatch("([^\n]+)") do
out[#out + 1] = line
end
return out
end
-- Move `path` to the front (MRU), dedup, cap.
local function record(path)
if not (enabled and pmacs.state.available()) or not path then return end
local list = load_list()
local kept = { path }
for _, p in ipairs(list) do
if p ~= path and #kept < MAX_ENTRIES then
kept[#kept + 1] = p
end
end
pmacs.state.write(STATE_KEY, table.concat(kept, "\n") .. (#kept > 0 and "\n" or ""))
end
local function record_active()
pcall(record, pmacs.editor.file_path())
end
-- First open and every re-visit of an already-open file refresh MRU.
pmacs.hook.add("buffer.after-load", record_active)
pmacs.hook.add("buffer.after-switch", record_active)
-- The public list (MRU-first), for the picker or a user script.
function pmacs.recentf.list()
return load_list()
end
pmacs.command.define {
name = "recent-files",
description = "Visit a recently opened file (Arc 3).",
fn = function()
local list = load_list()
if #list == 0 then
pmacs.editor.set_status("recentf: no recent files")
return
end
pmacs.minibuffer.read {
prompt = "Recent file: ",
source = function() return list end,
history = "recent-files",
on_accept = function(path)
if path == nil or path == "" then return end
local ok, err = pcall(pmacs.buffer.find_or_open, path)
if not ok then
pmacs.editor.set_status("recentf: " .. tostring(err))
end
end,
}
end,
}
pmacs.keymap.bind { scope = "global", sequence = "C-x C-r", command = "recent-files" }

View File

@ -0,0 +1,91 @@
-- saveplace.lua --- remember the cursor position per file (Arc 3 Q#PS3b).
--
-- Records the active file buffer's (cursor byte, view_top) on save and
-- at quit, and restores it when the file is reopened. Storage is the
-- `places` state file, one `<cursor> <view_top> <path>` line per file
-- (numbers first so the path, which may contain spaces, is the
-- whitespace-split remainder). LRU-capped.
--
-- On by default; disable from init.lua with
-- `pmacs.saveplace.enable(false)`. Inert when no state dir is
-- configured (cfg(test) / no HOME), so the lib suite writes nothing.
--
-- Framing: docs/persistence-framing.md.
pmacs.saveplace = pmacs.saveplace or {}
local STATE_KEY = "places"
local MAX_ENTRIES = 200
local enabled = true
function pmacs.saveplace.enable(on)
enabled = (on ~= false)
end
local function active_ready()
return enabled and pmacs.state.available() and pmacs.editor.file_path() ~= nil
end
-- Load the places file into an ordered list of {path, cursor, view_top}
-- (most-recently-recorded first) plus a path->index lookup.
local function load_places()
local list, index = {}, {}
local text = pmacs.state.read(STATE_KEY)
if not text then return list, index end
for line in text:gmatch("([^\n]+)") do
-- "<cursor> <view_top> <path>"
local cur, vt, path = line:match("^(%d+)%s+(%d+)%s+(.+)$")
if path and not index[path] then
list[#list + 1] = { path = path, cursor = tonumber(cur), view_top = tonumber(vt) }
index[path] = #list
end
end
return list, index
end
local function save_places(list)
local lines = {}
for i = 1, math.min(#list, MAX_ENTRIES) do
local e = list[i]
lines[#lines + 1] = string.format("%d %d %s", e.cursor, e.view_top, e.path)
end
pmacs.state.write(STATE_KEY, table.concat(lines, "\n") .. (#lines > 0 and "\n" or ""))
end
-- Record the active buffer's place, moving it to the front (LRU).
local function record_active()
if not active_ready() then return end
local path = pmacs.editor.file_path()
local cursor = pmacs.editor.cursor()
local view_top = pmacs.editor.view_top and pmacs.editor.view_top() or 0
local list, index = load_places()
if index[path] then table.remove(list, index[path]) end
table.insert(list, 1, { path = path, cursor = cursor, view_top = view_top })
save_places(list)
end
-- Restore the just-loaded file's place, if we have one.
local function restore_active()
if not active_ready() then return end
local path = pmacs.editor.file_path()
local list, index = load_places()
local i = index[path]
if not i then return end
local e = list[i]
pmacs.editor.goto_byte(e.cursor)
if pmacs.editor.set_view_top then pmacs.editor.set_view_top(e.view_top) end
end
-- Record on save and on quit; restore on open. before-save /
-- before-quit are short-circuit hooks — returning nil never vetoes.
pmacs.hook.add("buffer.before-save", function()
pcall(record_active)
end)
pmacs.hook.add("editor.before-quit", function()
pcall(record_active)
end)
pmacs.hook.add("buffer.after-load", function()
pcall(restore_active)
end)

View File

@ -172,6 +172,15 @@ impl EditorState {
if let Some(dir) = crate::minibuffer::user_history_dir() {
core.borrow_mut().minibuffer.history_dir = Some(dir);
}
// Arc 3 (Q#PS2): configure the `pmacs.state.*` base dir. Its
// absence under `cfg(test)` (this whole block is skipped) is
// what keeps default-on saveplace/recentf from writing to a
// developer's real state dir during the lib suite.
if let Some(dir) = crate::state::user_state_dir() {
lua_host
.lua()
.set_app_data(crate::lua_bindings::StateDir(dir));
}
}
// The async runtime: install pmacs._async raw helpers, then
// load the friendly Lua surface (`pmacs.async`, Handle class,
@ -301,6 +310,22 @@ impl EditorState {
include_str!("../builtin/runtime/completion.lua"),
)
.expect("load completion builtin chunk");
// Arc 3: persistence builtins (saveplace + recentf). Load after
// the LSP/completion runtimes; they subscribe to buffer hooks
// and drive `pmacs.state` (inert until the state dir is
// configured — never in `cfg(test)`).
lua_host
.eval(
Some("@pmacs/builtin/runtime/saveplace.lua"),
include_str!("../builtin/runtime/saveplace.lua"),
)
.expect("load saveplace builtin chunk");
lua_host
.eval(
Some("@pmacs/builtin/runtime/recentf.lua"),
include_str!("../builtin/runtime/recentf.lua"),
)
.expect("load recentf builtin chunk");
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
// was loaded directly via `eval(include_str!(...))`; the
// M7.11 deliverable migrates it to the package system so it
@ -4128,9 +4153,13 @@ mod tests {
assert!(info.get::<String>("source").unwrap().contains(':'));
let callbacks: mlua::Table = info.get("callbacks").unwrap();
let len = callbacks.len().unwrap();
assert_eq!(len, 2, "expected 2 callbacks; describe says {len}");
let cb1: mlua::Table = callbacks.get(1).unwrap();
let cb2: mlua::Table = callbacks.get(2).unwrap();
// A builtin (saveplace) also subscribes to `buffer.before-save`,
// registered at startup, so it precedes the two the test adds.
// Assert on the *last two* callbacks — the ones this chunk just
// registered — rather than the exact total (robust to builtins).
assert!(len >= 2, "expected >= 2 callbacks; describe says {len}");
let cb1: mlua::Table = callbacks.get(len - 1).unwrap();
let cb2: mlua::Table = callbacks.get(len).unwrap();
let s1: String = cb1.get("source").unwrap();
let s2: String = cb2.get("source").unwrap();
// Both registrations come from the test chunk; the second

View File

@ -493,6 +493,27 @@ impl EditorCore {
self.active_window().view_top
}
/// Set the active window's cursor to a byte offset, clamped to the
/// buffer extent (Arc 3 Q#PS1 — saveplace/desktop restore). Resets
/// the goal column. Since `switch_active_buffer` zeroes the cursor,
/// restore calls this *after* the open/switch.
pub fn set_cursor_byte(&mut self, byte: u64) {
let clamped = byte.min(self.active_buffer_len());
let aw = self.active_window_mut();
aw.cursor = clamped;
aw.goal_col = None;
}
/// Set the active window's `view_top` (first visible source line),
/// clamped to the buffer's line count (Arc 3 Q#PS1 — desktop
/// restore). A file that shrank since the desktop was saved can't
/// scroll past its end.
pub fn set_view_top(&mut self, top: usize) {
let lines = self.active_window().text_view.line_count();
let clamped = top.min(lines.saturating_sub(1));
self.active_window_mut().view_top = clamped;
}
/// Active buffer's byte length.
#[must_use]
pub fn active_buffer_len(&self) -> u64 {

View File

@ -123,6 +123,7 @@ pub mod semantic_render;
pub mod semantic_tokens;
pub mod signature;
pub mod socket_path;
pub mod state;
pub mod symbol;
pub mod syntax;
pub mod text_view;

View File

@ -1961,10 +1961,82 @@ pub fn install(
pmacs.set("instance", install_instance_module(lua, registry)?)?;
pmacs.set("ansi", install_ansi_module(lua)?)?;
pmacs.set("packages", install_packages_module(lua)?)?;
pmacs.set("state", install_state_module(lua)?)?;
lua.globals().set("pmacs", pmacs)?;
Ok(())
}
/// The configured base state directory (Arc 3, Q#PS2). Present as Lua
/// app-data only when a real dir was resolved at startup; its absence
/// (the `cfg(test)` case, and any host without `HOME`/`XDG_STATE_HOME`)
/// makes every `pmacs.state.*` call a no-op, so default-on persistence
/// builtins never touch disk in `cargo test`.
pub struct StateDir(pub std::path::PathBuf);
/// `pmacs.state.{write,read,remove,path}` — the confined key→file store
/// (Q#PS2). All keys pass [`crate::state::validate_name`], so a state
/// call can never read or write outside the state directory. When the
/// state dir is unconfigured every call is inert: `write`/`remove`
/// return `false`, `read`/`path` return `nil`.
fn install_state_module(lua: &Lua) -> mlua::Result<Table> {
let m = lua.create_table()?;
m.set(
"write",
lua.create_function(|lua, (name, content): (String, mlua::String)| {
let Some(base) = lua.app_data_ref::<StateDir>() else {
return Ok(false);
};
crate::state::write(&base.0, &name, &content.as_bytes())
.map_err(mlua::Error::external)?;
Ok(true)
})?,
)?;
m.set(
"read",
lua.create_function(|lua, name: String| {
let Some(base) = lua.app_data_ref::<StateDir>() else {
return Ok(None);
};
crate::state::read(&base.0, &name).map_err(mlua::Error::external)
})?,
)?;
m.set(
"remove",
lua.create_function(|lua, name: String| {
let Some(base) = lua.app_data_ref::<StateDir>() else {
return Ok(false);
};
crate::state::remove(&base.0, &name).map_err(mlua::Error::external)?;
Ok(true)
})?,
)?;
m.set(
"path",
lua.create_function(|lua, name: String| {
let Some(base) = lua.app_data_ref::<StateDir>() else {
return Ok(None);
};
match crate::state::resolve(&base.0, &name) {
Ok(p) => Ok(Some(p.display().to_string())),
Err(e) => Err(mlua::Error::external(e)),
}
})?,
)?;
// True when a state directory is configured — lets Lua modules tell
// "unconfigured (test / no HOME)" from "configured but empty".
m.set(
"available",
lua.create_function(|lua, ()| Ok(lua.app_data_ref::<StateDir>().is_some()))?,
)?;
Ok(m)
}
/// Build the `pmacs.attach` Lua function (T M5.6d).
///
/// Init-time-only: refuses to run after [`InitCompleteFlag`] has been
@ -10681,6 +10753,34 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result
})?,
)?;
}
{
// goto_byte(pos): set the active cursor to a byte offset
// (clamped). The byte-exact restore saveplace/desktop need
// (Arc 3) — `move_to_line` is line-based, and switch zeroes the
// cursor, so restore sets it here after opening.
let cc = core.clone();
editor.set(
"goto_byte",
lua.create_function(move |_, pos: i64| {
let byte = u64::try_from(pos).map_err(mlua::Error::external)?;
cc.borrow_mut().set_cursor_byte(byte);
Ok(())
})?,
)?;
}
{
// set_view_top(line): set the first visible source line
// (clamped to the buffer's line count) — desktop restore.
let cc = core.clone();
editor.set(
"set_view_top",
lua.create_function(move |_, top: i64| {
let top = usize::try_from(top).map_err(mlua::Error::external)?;
cc.borrow_mut().set_view_top(top);
Ok(())
})?,
)?;
}
{
let cc = core.clone();
editor.set(

View File

@ -726,30 +726,25 @@ impl History {
/// var is set.
#[must_use]
pub fn user_history_dir() -> Option<PathBuf> {
resolve_history_dir(
std::env::var_os("XDG_STATE_HOME").as_deref(),
std::env::var_os("HOME").as_deref(),
)
// Route through the shared state-dir resolver so history honors the
// `PMACS_STATE_HOME` override too (Arc 3 Q#PS2).
crate::state::user_state_dir().map(|d| d.join("history"))
}
/// Pure helper for [`user_history_dir`], factored out so tests can
/// inject paths directly without touching the process environment
/// (R55: `unsafe_code = "forbid"` rules out `env::set_var`).
///
/// History lives under the shared editor state dir
/// ([`crate::state::state_dir`], Arc 3 Q#PS2) in a `history/`
/// subdirectory. A blank `XDG_STATE_HOME` now falls through to `HOME`
/// instead of yielding a relative path (the empty-XDG fix).
#[must_use]
pub fn resolve_history_dir(
xdg_state: Option<&std::ffi::OsStr>,
home: Option<&std::ffi::OsStr>,
) -> Option<PathBuf> {
if let Some(xdg) = xdg_state {
return Some(PathBuf::from(xdg).join("pmacs").join("history"));
}
home.map(|h| {
PathBuf::from(h)
.join(".local")
.join("state")
.join("pmacs")
.join("history")
})
crate::state::state_dir(xdg_state, home).map(|d| d.join("history"))
}
fn history_path(dir: &Path, bucket: &str) -> PathBuf {

294
src/state.rs Normal file
View File

@ -0,0 +1,294 @@
// state.rs --- persistent editor state directory (Arc 3, Q#PS2).
//! The `$XDG_STATE_HOME/pmacs/` base that all persisted editor state
//! lives under: minibuffer history (the original tenant), plus the
//! Arc 3 persistence features (recent files, saveplace, desktop, and
//! autosave recovery).
//!
//! Env is passed in as arguments, never read inline, so the pure
//! resolver is testable without touching the process environment
//! (`#![forbid(unsafe_code)]` rules out `env::set_var`).
use std::ffi::OsStr;
use std::path::PathBuf;
/// The base state directory `.../pmacs`, or `None` when neither
/// `XDG_STATE_HOME` nor `HOME` is usably set.
///
/// Order: `$XDG_STATE_HOME/pmacs`, then `$HOME/.local/state/pmacs`.
///
/// A **blank** `XDG_STATE_HOME` is treated as *absent* (Q#PS2 fix): the
/// prior history resolver returned a *relative* `pmacs/…` for
/// `Some("")`, which would write state into the process's current
/// directory — a latent bug. Here an empty (or all-whitespace) value
/// falls through to `HOME`, and a `HOME` that is itself blank yields
/// `None` rather than a relative path.
#[must_use]
pub fn state_dir(xdg_state: Option<&OsStr>, home: Option<&OsStr>) -> Option<PathBuf> {
if let Some(xdg) = xdg_state.filter(|s| !is_blank(s)) {
return Some(PathBuf::from(xdg).join("pmacs"));
}
home.filter(|s| !is_blank(s))
.map(|h| PathBuf::from(h).join(".local").join("state").join("pmacs"))
}
/// Resolve the base state directory from the process environment.
///
/// A `PMACS_STATE_HOME` override wins over `XDG_STATE_HOME`/`HOME` when
/// set (and non-blank): `.../pmacs` under it. This is the redirect a
/// test harness, CI, or a privacy-conscious user points at a scratch
/// dir so persistence never touches the real `~/.local/state/pmacs`
/// (integration tests link the lib without `cfg(test)`, so the
/// startup wiring runs — the override is how they stay clean).
#[must_use]
pub fn user_state_dir() -> Option<PathBuf> {
if let Some(over) = std::env::var_os("PMACS_STATE_HOME")
.as_deref()
.filter(|s| !is_blank(s))
{
return Some(PathBuf::from(over).join("pmacs"));
}
state_dir(
std::env::var_os("XDG_STATE_HOME").as_deref(),
std::env::var_os("HOME").as_deref(),
)
}
/// True when `s` is empty or all ASCII/Unicode whitespace — an
/// unusable env value we treat as unset.
fn is_blank(s: &OsStr) -> bool {
match s.to_str() {
Some(text) => text.trim().is_empty(),
// Non-UTF-8 path bytes are a real (if exotic) directory name;
// only the empty OsStr counts as blank there.
None => s.is_empty(),
}
}
// ---------------------------------------------------------------------------
// Confined key→file store (Q#PS2)
// ---------------------------------------------------------------------------
use std::path::Path;
/// Validate a state key so `pmacs.state.*` can never escape the state
/// directory (Q#PS2 path confinement). A key is a **relative** path of
/// one or more `/`-separated components, each non-empty and drawn from
/// `[A-Za-z0-9._-]`, and no component may be `.` or `..`. Everything
/// else — an absolute path, an empty key, a `.`/`..` component, `//`,
/// or any other byte (separators, control chars, spaces) — is rejected.
///
/// Without this, a state binding meant to *avoid* raw `io.open` would
/// become an arbitrary read/write anywhere on disk.
///
/// # Errors
/// Returns a static message describing the first rule the key violates.
pub fn validate_name(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("state key is empty");
}
// Reject a leading `/` up front so the split below can't be fooled.
if name.starts_with('/') {
return Err("state key must be relative");
}
let mut components = 0usize;
for part in name.split('/') {
if part.is_empty() {
return Err("state key has an empty path component");
}
if part == "." || part == ".." {
return Err("state key may not contain `.` or `..`");
}
if !part
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return Err("state key component has a disallowed character");
}
components += 1;
}
if components == 0 {
return Err("state key is empty");
}
Ok(())
}
/// Resolve a validated key to its absolute path under `base`, with a
/// canonical-prefix belt: the joined path must still start with `base`.
///
/// # Errors
/// Propagates [`validate_name`], or errors if the join escapes `base`
/// (which [`validate_name`] already prevents — this is defense in depth).
pub fn resolve(base: &Path, name: &str) -> Result<PathBuf, &'static str> {
validate_name(name)?;
let path = base.join(name);
if !path.starts_with(base) {
return Err("state key escapes the state directory");
}
Ok(path)
}
/// Read a state file's contents, or `Ok(None)` when it does not exist.
///
/// # Errors
/// Invalid key, or an IO error other than not-found.
pub fn read(base: &Path, name: &str) -> Result<Option<String>, StateError> {
let path = resolve(base, name).map_err(StateError::Name)?;
match std::fs::read_to_string(&path) {
Ok(s) => Ok(Some(s)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(StateError::Io(e)),
}
}
/// Atomically write `content` to a state file (creating parents),
/// via [`crate::file_io::save_atomic`] — same durability the editor's
/// own saves get, and no raw `io.open`.
///
/// # Errors
/// Invalid key, or a save failure.
pub fn write(base: &Path, name: &str, content: &[u8]) -> Result<(), StateError> {
let path = resolve(base, name).map_err(StateError::Name)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(StateError::Io)?;
}
crate::file_io::save_atomic(&path, content).map_err(StateError::Save)?;
Ok(())
}
/// Remove a state file. Missing file is success (idempotent).
///
/// # Errors
/// Invalid key, or an IO error other than not-found.
pub fn remove(base: &Path, name: &str) -> Result<(), StateError> {
let path = resolve(base, name).map_err(StateError::Name)?;
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(StateError::Io(e)),
}
}
/// Error from a confined state operation.
#[derive(Debug)]
pub enum StateError {
/// The key failed [`validate_name`].
Name(&'static str),
/// An underlying IO failure (read / remove).
Io(std::io::Error),
/// An atomic-write failure.
Save(crate::file_io::SaveError),
}
impl std::fmt::Display for StateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StateError::Name(m) => write!(f, "invalid state key: {m}"),
StateError::Io(e) => write!(f, "{e}"),
StateError::Save(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for StateError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefers_xdg_state_home() {
let d = state_dir(Some(OsStr::new("/x/state")), Some(OsStr::new("/home/u"))).unwrap();
assert_eq!(d, PathBuf::from("/x/state/pmacs"));
}
#[test]
fn falls_back_to_home_local_state() {
let d = state_dir(None, Some(OsStr::new("/home/u"))).unwrap();
assert_eq!(d, PathBuf::from("/home/u/.local/state/pmacs"));
}
#[test]
fn none_when_neither_is_set() {
assert!(state_dir(None, None).is_none());
}
#[test]
fn validate_name_accepts_keys_and_subpaths() {
for ok in ["recentf", "places", "autosave/deadbeef", "a.b_c-1/x2"] {
assert!(validate_name(ok).is_ok(), "{ok:?} should be accepted");
}
}
#[test]
fn validate_name_rejects_escapes() {
for bad in [
"",
"/etc/passwd",
"..",
"../x",
"a/../b",
"a//b",
"a/",
"/a",
".",
"a/.",
"with space",
"tab\t",
"null\0",
"sub/../../x",
"..\\x",
] {
assert!(validate_name(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn resolve_stays_under_base() {
let base = PathBuf::from("/state/pmacs");
assert_eq!(
resolve(&base, "autosave/x").unwrap(),
PathBuf::from("/state/pmacs/autosave/x")
);
assert!(resolve(&base, "../escape").is_err());
}
#[test]
fn write_read_remove_round_trip() {
let dir = std::env::temp_dir().join(format!("pmacs-state-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert!(read(&dir, "recentf").unwrap().is_none(), "absent → None");
write(&dir, "recentf", b"a\nb\n").unwrap();
assert_eq!(read(&dir, "recentf").unwrap().as_deref(), Some("a\nb\n"));
// Subpath creates its parent dir.
write(&dir, "autosave/h1", b"x").unwrap();
assert_eq!(read(&dir, "autosave/h1").unwrap().as_deref(), Some("x"));
remove(&dir, "recentf").unwrap();
assert!(read(&dir, "recentf").unwrap().is_none(), "removed → None");
remove(&dir, "recentf").unwrap(); // idempotent
// An invalid key errors rather than escaping.
assert!(read(&dir, "../x").is_err());
assert!(write(&dir, "/abs", b"x").is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn blank_xdg_falls_through_to_home_not_a_relative_path() {
// The Q#PS2 fix: an empty / whitespace XDG_STATE_HOME must NOT
// produce a relative `pmacs/...` (which would write into the
// cwd). It falls through to HOME instead.
for blank in ["", " ", "\t"] {
let d = state_dir(Some(OsStr::new(blank)), Some(OsStr::new("/home/u"))).unwrap();
assert_eq!(
d,
PathBuf::from("/home/u/.local/state/pmacs"),
"blank XDG {blank:?} must fall through to HOME"
);
assert!(d.is_absolute(), "state dir is never relative");
}
// Blank XDG and no HOME → None, not a relative path.
assert!(state_dir(Some(OsStr::new("")), None).is_none());
// A blank HOME is likewise unusable.
assert!(state_dir(None, Some(OsStr::new(" "))).is_none());
}
}

View File

@ -0,0 +1,167 @@
//! Persistence phase 1 acceptance (Arc 3): the `pmacs.state` confined
//! store, saveplace (cursor restored on reopen), and recentf (MRU
//! record + dedup + picker), driven through the real Lua surface.
//!
//! Integration tests link the lib without `cfg(test)`, so
//! `EditorState::new()` configures the state dir from the environment.
//! Each test **overrides that with a private tempdir** (via the
//! `StateDir` app-data) before touching any file, so the suite never
//! reads or writes a developer's real `~/.local/state/pmacs`.
//!
//! Framing: docs/persistence-framing.md.
use pmacs::editor::EditorState;
use pmacs::lua_bindings::StateDir;
use std::path::PathBuf;
/// A fresh editor whose state dir is a private, empty tempdir (unique
/// per call so parallel tests never share or wipe each other's dirs).
fn editor_with_state_dir() -> (EditorState, PathBuf) {
use std::sync::atomic::{AtomicUsize, Ordering};
static SEQ: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"pmacs-persist-{}-{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).expect("mk state tempdir");
let s = EditorState::new();
// Override whatever startup configured with our tempdir.
s.lua_host.lua().remove_app_data::<StateDir>();
s.lua_host.lua().set_app_data(StateDir(dir.clone()));
(s, dir)
}
/// Write a real file under `dir` and return its path string.
fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String {
let p = dir.join(name);
std::fs::write(&p, body).expect("write test file");
p.display().to_string()
}
#[test]
fn state_round_trips_and_rejects_escapes() {
let (s, dir) = editor_with_state_dir();
let out: (bool, Option<String>, bool, bool) = s
.lua_host
.lua()
.load(
r#"
local wrote = pmacs.state.write("recentf", "a\nb\n")
local back = pmacs.state.read("recentf")
-- Confinement: an escaping key must error (pcall false).
local esc_ok = pcall(pmacs.state.write, "../escape", "x")
local abs_ok = pcall(pmacs.state.read, "/etc/passwd")
return wrote, back, esc_ok, abs_ok
"#,
)
.eval()
.expect("state round-trip");
assert!(out.0, "write returned true");
assert_eq!(
out.1.as_deref(),
Some("a\nb\n"),
"read returns what was written"
);
assert!(!out.2, "`../escape` key is rejected");
assert!(!out.3, "absolute key is rejected");
// And it actually landed under our tempdir, nowhere else.
assert!(dir.join("recentf").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn state_is_inert_when_unconfigured() {
let s = EditorState::new();
// Simulate no state dir (the cfg(test) lib case, or no HOME).
s.lua_host.lua().remove_app_data::<StateDir>();
let (avail, wrote, read): (bool, bool, Option<String>) = s
.lua_host
.lua()
.load(
r#"
return pmacs.state.available(),
pmacs.state.write("recentf", "should not persist"),
pmacs.state.read("recentf")
"#,
)
.eval()
.expect("inert state");
assert!(!avail, "unconfigured → not available");
assert!(!wrote, "write is a no-op (returns false)");
assert_eq!(read, None, "read returns nil");
}
#[test]
fn recentf_records_dedups_and_orders_mru() {
let (s, dir) = editor_with_state_dir();
let a = write_file(&dir, "a.rs", "fn a() {}\n");
let b = write_file(&dir, "b.rs", "fn b() {}\n");
// Open a, then b, then a again — MRU should be [a, b].
for path in [&a, &b, &a] {
s.lua_host
.lua()
.load(format!("pmacs.buffer.find_or_open({path:?})"))
.exec()
.expect("open file");
}
let list: Vec<String> = s
.lua_host
.lua()
.load("return pmacs.recentf.list()")
.eval()
.expect("recentf list");
assert_eq!(list, vec![a.clone(), b.clone()], "MRU-first, deduped");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn saveplace_restores_cursor_on_reopen() {
let (s, dir) = editor_with_state_dir();
let f = write_file(&dir, "place.rs", "line0\nline1\nline2\nline3\n");
// Open, move to byte 12 ("line2"), save (before-save records the
// place), then KILL the buffer — so the reopen is a fresh load
// (buffer.after-load), the cross-session path saveplace targets.
let cursor: i64 = s
.lua_host
.lua()
.load(format!(
r#"
local b = pmacs.buffer.find_or_open({f:?})
pmacs.editor.goto_byte(12)
pmacs.command.invoke("buffer.save")
pmacs.buffer.kill(b)
-- Reopen from scratch: after-load fires saveplace restores.
pmacs.buffer.find_or_open({f:?})
return pmacs.editor.cursor()
"#
))
.eval()
.expect("place + save + kill + reopen");
assert_eq!(cursor, 12, "saveplace restored the cursor byte on reload");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn saveplace_can_be_disabled() {
let (s, dir) = editor_with_state_dir();
let f = write_file(&dir, "off.rs", "aaaa\nbbbb\ncccc\n");
let cursor: i64 = s
.lua_host
.lua()
.load(format!(
r#"
pmacs.saveplace.enable(false)
local b = pmacs.buffer.find_or_open({f:?})
pmacs.editor.goto_byte(10)
pmacs.command.invoke("buffer.save")
pmacs.buffer.kill(b)
pmacs.buffer.find_or_open({f:?})
return pmacs.editor.cursor()
"#
))
.eval()
.expect("disabled saveplace flow");
assert_eq!(cursor, 0, "disabled saveplace leaves the cursor at the top");
std::fs::remove_dir_all(&dir).ok();
}