From 85e4ee03bbc7820ff7fde60a9183ab2710ba592c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 18:48:07 -0400 Subject: [PATCH 1/6] feat(bootstrap): make the ambient storage roots a parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EditorState::new` resolves two storage roots from the process environment before it returns: the data root, which the bundled-package materialization then WRITES into unconditionally (outside every `cfg` guard), and the config root, from which `init.lua` is read. The `#[cfg(not(test))]` guard on the second was written to stop the crate's own unit tests picking up a developer's real `init.lua`. It does exactly that and nothing more: `cfg(test)` is set only while compiling the lib's own tests, so an integration test in `tests/` — compiled without it — reads the real config and writes the real data root. On a machine with a real `~/.config/pmacs/init.lua`, that is 11 deterministic failures in `compile_mode_acceptance`, attributed to whatever branch is checked out. Tests cannot fix that themselves: `std::env::set_var` is `unsafe` and this crate is `#![forbid(unsafe_code)]` — the same constraint that produced `Installer::with_install_root_override`. So isolation arrives as a parameter. `BootstrapRoots` names the four storage roots (config, data, state, cache). `ambient()` leaves every one `None` and every resolution goes to the environment exactly as today, so production is unchanged. `new_with_roots` and `open_with_roots` take it — both, because `open` calls `Self::new()` internally and a constructor-only parameter would leave every open-path test ambient. `install_state_dirs` consults it too: it runs after construction, so resolving from the environment there would reopen the hole the constructor closed. The redirected branch changes WHICH directory is read, never WHETHER the block runs. Config loading shares one conditional with `set_init_complete()`, and `tests/m8_2_acceptance.rs:75` documents its dependence on integration-test construction finishing init-complete. `child_env()` translates the same value into the environment a spawned `pmacs` needs. Five variables, not four: `PMACS_STATE_HOME` outranks `XDG_STATE_HOME`, so a child given only the XDG four still resolves an inherited state override — invisible on a machine that exports none. The `src/editor.rs` comment claimed a protection it does not provide and said nothing about the write above it; both are corrected in place. The guard is deliberately NOT widened to cover integration tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- src/bootstrap.rs | 391 +++++++++++++++++++++++++++++++++++++++++++++++ src/editor.rs | 123 ++++++++++++++- src/lib.rs | 1 + 3 files changed, 509 insertions(+), 6 deletions(-) create mode 100644 src/bootstrap.rs diff --git a/src/bootstrap.rs b/src/bootstrap.rs new file mode 100644 index 0000000..87857e2 --- /dev/null +++ b/src/bootstrap.rs @@ -0,0 +1,391 @@ +// src/bootstrap.rs --- explicit bootstrap storage roots. + +//! Where pmacs stores things at startup, as a value rather than as an +//! ambient property of the process environment. +//! +//! # Why this exists +//! +//! `EditorState::new` resolves two storage roots from the environment +//! before it returns: +//! +//! * the **data** root, which +//! [`crate::builtin_packages::bundled_runtime_dir`] resolves from +//! `XDG_DATA_HOME` (else `$HOME/.local/share`) and which +//! [`crate::builtin_packages::materialize_all`] then **writes into** +//! --- unconditionally, outside any `cfg` guard; and +//! * the **config** root, which [`crate::config::user_config_dir`] +//! resolves from `XDG_CONFIG_HOME` (else `$HOME/.config`) and from +//! which `init.lua` is read. +//! +//! An integration test in `tests/` links this crate as an ordinary +//! dependency, so it is compiled **without** `cfg(test)`: the +//! `#[cfg(not(test))]` guard around config loading is inactive for +//! every one of them. They read the developer's real `init.lua` and +//! write into the developer's real data root. +//! +//! They cannot fix that themselves. `std::env::set_var` has been +//! `unsafe` since Rust 2024 and this crate is `#![forbid(unsafe_code)]` +//! --- the same constraint that produced +//! [`crate::packages::installer::Installer::with_install_root_override`] +//! and [`crate::lua_bindings::PackageInstallOverride`]. So isolation has +//! to arrive as a **parameter**, which is what this type is. +//! +//! # Contract +//! +//! [`BootstrapRoots::ambient()`] is production: every root stays `None` +//! and every resolution goes to the environment exactly as before. A +//! root that is `Some` replaces the environment lookup for that root and +//! **only** that root. +//! +//! # Scope: storage roots only +//! +//! This type covers the four roots that decide where pmacs *stores* +//! things: config, data, state and cache. It deliberately does not +//! cover: +//! +//! * **`HOME`'s non-storage semantics.** `expand_tilde` +//! ([`crate::editor_core`]) resolves a leading `~` for ordinary path +//! entry, and `tests/find_file_acceptance.rs` consumes `HOME` on +//! purpose to pin that expansion. Redirecting a storage root is the +//! right fix for a storage root and the wrong fix for a +//! path-expansion root. +//! * **`XDG_RUNTIME_DIR`**, which addresses sockets rather than stored +//! data. + +use std::path::{Path, PathBuf}; + +/// The bootstrap storage roots an [`crate::editor::EditorState`] is +/// constructed against. +/// +/// Each field is the *base* directory --- the value `XDG__HOME` +/// would hold --- not the `pmacs/` subdirectory under it. `None` means +/// "resolve from the environment", which is what production does. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BootstrapRoots { + config: Option, + data: Option, + state: Option, + cache: Option, +} + +/// The version-keyed leaf `bundled_runtime_dir` materializes into. +fn bundled_leaf() -> String { + format!("v{}", env!("CARGO_PKG_VERSION")) +} + +impl BootstrapRoots { + /// Production: every root resolves from the process environment. + #[must_use] + pub fn ambient() -> Self { + Self::default() + } + + /// Every storage root redirected under `base`, into sibling + /// `config/`, `data/`, `state/` and `cache/` directories. + /// + /// The layout mirrors the XDG one so a caller that also spawns a + /// child process can hand the same four paths to `XDG_CONFIG_HOME`, + /// `XDG_DATA_HOME`, `XDG_STATE_HOME` and `XDG_CACHE_HOME` (plus + /// `PMACS_STATE_HOME`, which outranks `XDG_STATE_HOME`) and get the + /// same tree from an in-process and a spawned editor. + #[must_use] + pub fn isolated_under(base: &Path) -> Self { + Self { + config: Some(base.join("config")), + data: Some(base.join("data")), + state: Some(base.join("state")), + cache: Some(base.join("cache")), + } + } + + /// Builder: redirect the config root (the `XDG_CONFIG_HOME` value). + #[must_use] + pub fn with_config_root(mut self, root: PathBuf) -> Self { + self.config = Some(root); + self + } + + /// Builder: redirect the data root (the `XDG_DATA_HOME` value). + #[must_use] + pub fn with_data_root(mut self, root: PathBuf) -> Self { + self.data = Some(root); + self + } + + /// Builder: redirect the state root (the `XDG_STATE_HOME` value). + #[must_use] + pub fn with_state_root(mut self, root: PathBuf) -> Self { + self.state = Some(root); + self + } + + /// Builder: redirect the cache root (the `XDG_CACHE_HOME` value). + #[must_use] + pub fn with_cache_root(mut self, root: PathBuf) -> Self { + self.cache = Some(root); + self + } + + /// True when nothing is redirected --- i.e. this is production's + /// [`Self::ambient`]. + #[must_use] + pub fn is_ambient(&self) -> bool { + self.config.is_none() && self.data.is_none() && self.state.is_none() && self.cache.is_none() + } + + /// The config *base*, if redirected. + #[must_use] + pub fn config_root(&self) -> Option<&Path> { + self.config.as_deref() + } + + /// The data *base*, if redirected. + #[must_use] + pub fn data_root(&self) -> Option<&Path> { + self.data.as_deref() + } + + /// The state *base*, if redirected. + #[must_use] + pub fn state_root(&self) -> Option<&Path> { + self.state.as_deref() + } + + /// The cache *base*, if redirected. + #[must_use] + pub fn cache_root(&self) -> Option<&Path> { + self.cache.as_deref() + } + + /// The directory `init.lua` is read from --- `/pmacs`, the + /// same shape [`crate::config::user_config_dir`] builds. + #[must_use] + pub fn config_dir(&self) -> Option { + self.config.as_ref().map(|p| p.join("pmacs")) + } + + /// Where bundled packages are materialized --- + /// `/pmacs/builtin-packages/v`, the same shape + /// [`crate::builtin_packages::bundled_runtime_dir`] builds. + #[must_use] + pub fn bundled_runtime_dir(&self) -> Option { + self.data.as_ref().map(|p| { + p.join("pmacs") + .join("builtin-packages") + .join(bundled_leaf()) + }) + } + + /// The user-scope package install root --- `/pmacs/packages`. + #[must_use] + pub fn package_install_root(&self) -> Option { + self.data.as_ref().map(|p| p.join("pmacs").join("packages")) + } + + /// The package fetcher's bare-mirror cache --- `/pmacs/git`. + #[must_use] + pub fn package_cache_dir(&self) -> Option { + self.cache.as_ref().map(|p| p.join("pmacs").join("git")) + } + + /// The editor state directory --- `/pmacs`, the same shape + /// [`crate::state::user_state_dir`] builds. + #[must_use] + pub fn state_dir(&self) -> Option { + self.state.as_ref().map(|p| p.join("pmacs")) + } + + /// The minibuffer history directory --- `/pmacs/history`. + #[must_use] + pub fn history_dir(&self) -> Option { + self.state_dir().map(|d| d.join("history")) + } + + /// The environment a **child** `pmacs` process must be given so it + /// resolves the same roots this value names. + /// + /// An in-process caller passes the value; a caller that spawns + /// `pmacs --daemon` (or re-execs a test binary) cannot, and has to + /// go through the environment instead. This is that translation, so + /// the two paths cannot drift. + /// + /// **Five variables, not four.** `PMACS_STATE_HOME` outranks + /// `XDG_STATE_HOME` ([`crate::state::user_state_dir`]), so a child + /// given only the four XDG variables still resolves the *inherited* + /// `PMACS_STATE_HOME` if the launching environment exports one --- a + /// hole that is invisible on a machine that does not. + /// + /// A root left ambient emits no variable, so the child inherits it. + #[must_use] + pub fn child_env(&self) -> Vec<(&'static str, PathBuf)> { + let mut out = Vec::with_capacity(5); + if let Some(p) = &self.config { + out.push(("XDG_CONFIG_HOME", p.clone())); + } + if let Some(p) = &self.data { + out.push(("XDG_DATA_HOME", p.clone())); + } + if let Some(p) = &self.state { + out.push(("XDG_STATE_HOME", p.clone())); + out.push(("PMACS_STATE_HOME", p.clone())); + } + if let Some(p) = &self.cache { + out.push(("XDG_CACHE_HOME", p.clone())); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ambient_redirects_nothing() { + let roots = BootstrapRoots::ambient(); + assert!(roots.is_ambient()); + assert_eq!(roots.config_dir(), None); + assert_eq!(roots.bundled_runtime_dir(), None); + assert_eq!(roots.state_dir(), None); + assert_eq!(roots.package_cache_dir(), None); + } + + #[test] + fn isolated_under_mirrors_the_xdg_layout() { + let roots = BootstrapRoots::isolated_under(Path::new("/scratch")); + assert!(!roots.is_ambient()); + assert_eq!( + roots.config_dir().unwrap(), + Path::new("/scratch/config/pmacs") + ); + assert_eq!( + roots.bundled_runtime_dir().unwrap(), + Path::new("/scratch/data/pmacs/builtin-packages").join(bundled_leaf()) + ); + assert_eq!( + roots.package_install_root().unwrap(), + Path::new("/scratch/data/pmacs/packages") + ); + assert_eq!( + roots.package_cache_dir().unwrap(), + Path::new("/scratch/cache/pmacs/git") + ); + assert_eq!( + roots.state_dir().unwrap(), + Path::new("/scratch/state/pmacs") + ); + assert_eq!( + roots.history_dir().unwrap(), + Path::new("/scratch/state/pmacs/history") + ); + } + + /// A builder that sets one root leaves the other three ambient --- + /// the "only that root" half of the contract. + #[test] + fn a_single_builder_leaves_the_other_roots_ambient() { + let roots = BootstrapRoots::ambient().with_config_root(PathBuf::from("/only/config")); + assert!(!roots.is_ambient()); + assert_eq!(roots.config_dir().unwrap(), Path::new("/only/config/pmacs")); + assert_eq!(roots.bundled_runtime_dir(), None); + assert_eq!(roots.state_dir(), None); + assert_eq!(roots.package_cache_dir(), None); + } + + /// The five-variable contract, asserted as content: naming only the + /// four XDG variables leaves `PMACS_STATE_HOME` --- which outranks + /// `XDG_STATE_HOME` --- pointing wherever the launching environment + /// left it. + #[test] + fn child_env_names_all_five_storage_variables() { + let roots = BootstrapRoots::isolated_under(Path::new("/scratch")); + let env = roots.child_env(); + let names: Vec<&str> = env.iter().map(|(k, _)| *k).collect(); + assert_eq!( + names, + vec![ + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "PMACS_STATE_HOME", + "XDG_CACHE_HOME", + ] + ); + let value = |name: &str| { + env.iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| v.clone()) + .unwrap() + }; + assert_eq!(value("XDG_CONFIG_HOME"), Path::new("/scratch/config")); + assert_eq!(value("XDG_DATA_HOME"), Path::new("/scratch/data")); + assert_eq!(value("XDG_STATE_HOME"), Path::new("/scratch/state")); + assert_eq!(value("PMACS_STATE_HOME"), Path::new("/scratch/state")); + assert_eq!(value("XDG_CACHE_HOME"), Path::new("/scratch/cache")); + } + + /// An ambient root emits no variable — the child inherits it. A + /// blanket five-variable emission would silently redirect roots the + /// caller deliberately left alone. + #[test] + fn child_env_emits_nothing_for_ambient_roots() { + assert!(BootstrapRoots::ambient().child_env().is_empty()); + let only_config = BootstrapRoots::ambient().with_config_root(PathBuf::from("/only/config")); + assert_eq!( + only_config.child_env(), + vec![("XDG_CONFIG_HOME", PathBuf::from("/only/config"))] + ); + } + + /// `child_env` and the in-process resolvers must describe the same + /// tree: the spawned daemon and the in-process editor are both + /// supposed to land in the isolated roots, and a mismatch would let + /// one of them escape while the other looked fine. + #[test] + fn child_env_agrees_with_the_in_process_resolvers() { + let roots = BootstrapRoots::isolated_under(Path::new("/scratch")); + let env = roots.child_env(); + let value = |name: &str| { + env.iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| v.clone()) + .unwrap() + }; + assert_eq!( + roots.config_dir().unwrap(), + value("XDG_CONFIG_HOME").join("pmacs") + ); + assert_eq!( + roots.package_install_root().unwrap(), + value("XDG_DATA_HOME").join("pmacs").join("packages") + ); + assert_eq!( + roots.state_dir().unwrap(), + value("PMACS_STATE_HOME").join("pmacs") + ); + assert_eq!( + roots.package_cache_dir().unwrap(), + value("XDG_CACHE_HOME").join("pmacs").join("git") + ); + } + + /// The isolated bundled dir must agree with the ambient resolver's + /// shape, version leaf included: a mismatch would make an isolated + /// editor materialize somewhere production never looks. + #[test] + fn bundled_leaf_matches_the_ambient_resolver() { + let roots = BootstrapRoots::isolated_under(Path::new("/scratch")); + let isolated = roots.bundled_runtime_dir().unwrap(); + // `bundled_runtime_dir()` reads the live environment, so compare + // only the tail that does not depend on it. + let ambient = crate::builtin_packages::bundled_runtime_dir(); + let tail = |p: &Path| { + p.components() + .rev() + .take(3) + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + }; + assert_eq!(tail(&isolated), tail(&ambient)); + } +} diff --git a/src/editor.rs b/src/editor.rs index b089973..bf897b4 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -273,6 +273,14 @@ pub struct EditorState { /// steal or clear another's in-flight gesture, and concurrent drags /// are perfectly legal. window_drag: HashMap, + /// The bootstrap storage roots this session was constructed + /// against (`docs/test-ambient-config-isolation-framing.md`). + /// + /// [`BootstrapRoots::ambient`] in production. Retained past + /// construction because [`Self::install_state_dirs`] resolves a root + /// too and runs *after* the constructor --- resolving it from the + /// environment there would reopen the hole the constructor closed. + bootstrap_roots: crate::bootstrap::BootstrapRoots, } #[derive(Default)] @@ -345,6 +353,24 @@ impl EditorState { /// Panics only if Lua initialization or the builtin command/keymap /// chunks fail to load --- both indicate broken builds. #[must_use] + pub fn new() -> Self { + Self::new_with_roots(&crate::bootstrap::BootstrapRoots::ambient()) + } + + /// Construct a fresh editor against explicit bootstrap storage + /// roots (`docs/test-ambient-config-isolation-framing.md` §1.4). + /// + /// [`Self::new`] is this with [`BootstrapRoots::ambient`], so + /// production behaviour is unchanged. An integration test passes + /// redirected roots instead: it links this crate without + /// `cfg(test)`, so the guard below is live for it, and without a + /// parameter it would read the developer's real `init.lua` and + /// write bundled packages into the developer's real data root. + /// `std::env::set_var` is not an alternative --- it is `unsafe` and + /// this crate is `#![forbid(unsafe_code)]`. + /// + /// [`BootstrapRoots::ambient`]: crate::bootstrap::BootstrapRoots::ambient + #[must_use] #[allow( clippy::too_many_lines, reason = "linear bootstrap sequence: registry → core → LuaHost → \ @@ -352,7 +378,7 @@ impl EditorState { Splitting into helpers fragments the wiring without removing \ any single decision the reader needs to follow." )] - pub fn new() -> Self { + pub fn new_with_roots(roots: &crate::bootstrap::BootstrapRoots) -> Self { // Build the buffer registry first so EditorCore and LuaHost // share the same `Rc`. Both reach buffers through this handle; // multi-window dispatch (T M2.8) requires that ids resolve to @@ -738,9 +764,34 @@ impl EditorState { // Depends on `pmacs.buffer.add_intercept` (T M6.4 Stage 1) // and `pmacs.ansi.parser()` (T M6.4 Stage 2), both available // by the time `attach_editor` returns above. - let bundled_root = crate::builtin_packages::bundled_runtime_dir(); + // + // `materialize_all` CREATES DIRECTORIES AND WRITES FILES, and + // it is outside every `cfg` guard — so this, not config + // loading, is the write half of the ambient-roots exposure + // (`docs/test-ambient-config-isolation-framing.md` §1.6). The + // redirected root is consulted first for exactly that reason. + let bundled_root = roots + .bundled_runtime_dir() + .unwrap_or_else(crate::builtin_packages::bundled_runtime_dir); let bundled_packages = crate::builtin_packages::materialize_all(&bundled_root) .expect("materialize bundled packages"); + // Redirected roots also redirect where `pmacs.packages.install` + // would later fetch and install, so a caller that isolated its + // storage cannot reach the real `$XDG_CACHE_HOME/pmacs/git` or + // `$XDG_DATA_HOME/pmacs/packages` through Lua either. Installed + // before user config runs, so an `init.lua` that installs a + // package lands inside the isolated tree. Production leaves the + // slot empty (ambient roots take this branch not at all). + if !roots.is_ambient() { + let mut override_ = crate::lua_bindings::PackageInstallOverride::new(); + if let Some(cache) = roots.package_cache_dir() { + override_ = override_.with_cache_dir(cache); + } + if let Some(install) = roots.package_install_root() { + override_ = override_.with_user_install_root(install); + } + lua_host.set_package_install_override(override_); + } { let slot = lua_host .lua() @@ -772,17 +823,43 @@ impl EditorState { // to exercise config loading do so explicitly via // [`crate::config::load_user_config_at`]. // + // **`cfg(test)` covers the lib's unit tests and NOTHING ELSE.** + // An integration test in `tests/` links this crate as an + // ordinary dependency, compiled without `cfg(test)`, so this + // block is fully live for all ~96 of them: `cargo test --lib` is + // protected, `cargo test --test ` is not. That is what the + // `roots` parameter is for — an integration test redirects the + // config root instead of relying on a guard that does not reach + // it. (Nor is this the only ambient root: the bundled-package + // materialization above runs unconditionally and WRITES.) See + // `docs/test-ambient-config-isolation-framing.md` §1.2, §1.6. + // + // The guard is deliberately NOT widened to cover integration + // tests: production deciding it is under test is how a suite + // passes against behaviour production never runs (Q#TI1). + // // The init-complete flip happens here too so lifecycle-gated // Lua APIs (e.g. `pmacs.attach`, M5.6d+) become inert after // user config returns. Tests that need post-init semantics flip // the flag explicitly via [`crate::lua::LuaHost::set_init_complete`]; // see the option-(A) discussion in the M5.6c survey. + // + // Redirected roots change *which directory* is read, never + // *whether* the block runs: `tests/m8_2_acceptance.rs:75` + // documents its dependence on integration-test construction + // finishing init-complete, so skipping the block for isolated + // construction would leave every such test permanently in the + // init phase (framing §1.8). #[cfg(not(test))] { - crate::config::load_user_config(&mut lua_host); + match roots.config_dir() { + Some(dir) => crate::config::load_user_config_at(&mut lua_host, &dir), + None => crate::config::load_user_config(&mut lua_host), + } lua_host.set_init_complete(); } Self { + bootstrap_roots: roots.clone(), core, lua_host, dispatchers: HashMap::new(), @@ -879,10 +956,24 @@ impl EditorState { /// `~/.local/state/pmacs`. Honors the `PMACS_STATE_HOME` override /// (see [`crate::state::user_state_dir`]). pub fn install_state_dirs(&self) { - if let Some(dir) = crate::minibuffer::user_history_dir() { + // Redirected roots win over the environment here too. This runs + // after construction, so resolving from the environment would + // hand an isolated session the developer's real state dir --- and + // `PMACS_STATE_HOME` outranks `XDG_STATE_HOME`, so an + // environment-side fix would have to cover five variables, not + // four (framing §1.6a). + let history = self + .bootstrap_roots + .history_dir() + .or_else(crate::minibuffer::user_history_dir); + if let Some(dir) = history { self.core.borrow_mut().minibuffer.history_dir = Some(dir); } - if let Some(dir) = crate::state::user_state_dir() { + let state = self + .bootstrap_roots + .state_dir() + .or_else(crate::state::user_state_dir); + if let Some(dir) = state { self.lua_host .lua() .set_app_data(crate::lua_bindings::StateDir(dir)); @@ -1029,7 +1120,27 @@ impl EditorState { behavioral gain" )] pub fn open(path: PathBuf) -> io::Result { - let mut state = Self::new(); + Self::open_with_roots(path, &crate::bootstrap::BootstrapRoots::ambient()) + } + + /// [`Self::open`] against explicit bootstrap storage roots. + /// + /// `open` calls `Self::new()` internally, so a roots parameter on + /// the constructor alone leaves every open-path test ambient + /// (`docs/test-ambient-config-isolation-framing.md` §1.7). The two + /// entry points therefore gain the parameter together, and `open` + /// stays a thin ambient caller of this — the golden-journey ratchet + /// requires that exact public entry point to have a production + /// caller shape. + #[allow( + clippy::needless_pass_by_value, + reason = "mirrors `open`'s stable signature; see the note there" + )] + pub fn open_with_roots( + path: PathBuf, + roots: &crate::bootstrap::BootstrapRoots, + ) -> io::Result { + let mut state = Self::new_with_roots(roots); let resolved = state .core .borrow_mut() diff --git a/src/lib.rs b/src/lib.rs index e3971a3..43c2a3b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,7 @@ pub mod attach_dispatch; pub mod attach_reconnect; pub mod audit; pub mod autosave; +pub mod bootstrap; pub mod buffer; pub mod buffer_registry; pub mod builtin_packages; From fcaf0b36fad24455c8450fd16653bddfc1c35bb3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 18:48:26 -0400 Subject: [PATCH 2/6] test(isolation): census, hostile-environment proof, adoption ratchet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The census first, because it decides how large the mechanical edit is (framing §7). Every occurrence was listed with its enclosing context and read; a grep for the bare name over-counts, which is how revision 1 reported 18 by grepping `Editor::new` — a pattern that does not match the real constructor. in-process 342 calls in 66 of 97 files (330 of 334 `EditorState::new()` occurrences; 4 are prose) (12 of 14 `EditorState::open(` occurrences; 2 are strings) spawned 14 real `pmacs` spawns in 8 files (of 36 `CARGO_BIN_EXE_pmacs` hits, 18 are the fake-LSP and fake-MCP siblings and 4 are path derivations for `pmacs-gpu`, not spawns) mixed 5 files are both, so sites — not files — are the unit The full census, with per-site attribution, is the module doc of `tests/ambient_isolation_acceptance.rs`. Four things it pins: * Isolated construction still finishes initialization, asserted twice — the flag, and the behaviour it gates (`pmacs.attach` must refuse). Falsified by wrapping the config block in `if roots.is_ambient()`; `m8_2_acceptance` does NOT catch that, because reopening an already-open init phase is a no-op. * The writes land in the redirected data root — content produced, not an invariant preserved. A "the real root did not change" check would pass vacuously wherever it already holds identical bytes, since `write_if_changed` is content-gated. * Bet 3, in two children with opposite jobs. The positive control proves the hostile environment IS hostile (an ambient editor loads its `init.lua` and writes its data root); without it the isolation half asserts nothing. The isolated child then stays green under the same environment and leaves its hostile root byte-identical. * A durable adoption ratchet, not a one-time census: a source scan that fails when a new ambient constructor appears outside a named allowlist, plus a check that no allowlist entry has gone dead. Its scanner strips comments, strings and raw strings, and that stripping has its own pin — the corpus contains all three shapes, and a grep-shaped answer already cost this lane a review round. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- tests/ambient_isolation_acceptance.rs | 657 ++++++++++++++++++++++++++ tests/common/iso.rs | 68 +++ tests/common/mod.rs | 6 + 3 files changed, 731 insertions(+) create mode 100644 tests/ambient_isolation_acceptance.rs create mode 100644 tests/common/iso.rs diff --git a/tests/ambient_isolation_acceptance.rs b/tests/ambient_isolation_acceptance.rs new file mode 100644 index 0000000..58e12a6 --- /dev/null +++ b/tests/ambient_isolation_acceptance.rs @@ -0,0 +1,657 @@ +// tests/ambient_isolation_acceptance.rs --- integration tests must not +// read or write the developer's real ambient roots. + +//! Acceptance for `docs/test-ambient-config-isolation-framing.md`. +//! +//! # The defect +//! +//! `src/editor.rs` guards user-config loading with `#[cfg(not(test))]`. +//! `cfg(test)` is set only while compiling the crate's *own* unit tests; +//! an integration test in `tests/` links `pmacs` as an ordinary +//! dependency, so the guard is inactive for all of them. `cargo test +//! --lib` is protected, `cargo test --test ` is not. And config +//! loading is only the read half: `EditorState::new` materializes +//! bundled packages into `$XDG_DATA_HOME/pmacs` (else +//! `$HOME/.local/share`) **unconditionally**, outside every `cfg` guard, +//! creating directories and writing files. +//! +//! # The census (framing acceptance 1), read at `54a092e` +//! +//! Method: every occurrence was listed with its enclosing context and +//! read. A grep for the bare name over-counts — the framing's revision 1 +//! reported 18 by grepping `Editor::new`, which does not even match the +//! real constructor `EditorState::new`. +//! +//! **In-process construction — 342 sites in 66 of 97 files.** +//! +//! * `EditorState::new()` — 334 textual occurrences, of which **330 are +//! calls**. The other 4 are prose: `persistence_acceptance.rs:6` and +//! `:76`, `m7_11_acceptance.rs:92`, `m8_2_acceptance.rs:75`. A fifth +//! file, `m5_6_acceptance.rs:94`, names the constructor only to say it +//! deliberately does **not** use it — the third place in the tree +//! documenting this same `cfg(test)` gap. +//! * `EditorState::open(` — 14 textual occurrences in 3 files, of which +//! **12 are calls** (`journey_acceptance` 7, `m4_acceptance` 4, +//! `theme_faces_acceptance` 1). The other 2 are assertion-message +//! strings in `journey_acceptance.rs:2030,2038`. +//! * Only 329 of the 330 `new()` calls are `let` bindings; the odd one +//! is `m8_1_acceptance.rs:39`, a bare tail expression in a +//! `fresh_editor()` helper. Sites, not files, are the unit. +//! +//! **Spawned `pmacs` — 14 sites in 8 files.** `grep CARGO_BIN_EXE_pmacs` +//! reports 36 sites in 26 files, but 18 of those are the *sibling* +//! binaries `CARGO_BIN_EXE_pmacs_fake_lsp` / `_fake_mcp`, which are LSP +//! and MCP stubs and not pmacs at all. Reading each occurrence: +//! +//! * `tests/common/daemon.rs:158` — the shared `--daemon` harness. +//! * `tests/common/pty.rs:111` — the shared real-PTY spawner. +//! * `m5_7_acceptance.rs` ×5, `m5_8_acceptance.rs` ×3, +//! `m5_5_acceptance.rs` ×1, `m5_perf_acceptance.rs` ×1, +//! `gpu_invocation_acceptance.rs` ×2 — direct `Command::new`. +//! * 4 further occurrences are **path derivations, not spawns**: +//! `gpu_invocation_acceptance.rs:115`, `vterm_stage3_acceptance.rs:644` +//! and `:1180`, `bottom_panel_stage2b_gpu_acceptance.rs:513` all take +//! `CARGO_BIN_EXE_pmacs`'s *parent* to locate the `pmacs-gpu` sibling. +//! +//! **Mixed files are real: 5 files are both in-process and spawned** — +//! `vterm_stage3_acceptance` constructs an editor at `:159` and reaches +//! a daemon at `:665`. A file-level partition cannot represent them, +//! which is why the ratchet below keys on sites. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use pmacs::bootstrap::BootstrapRoots; +use pmacs::editor::EditorState; + +#[path = "common/iso.rs"] +mod iso; + +// --------------------------------------------------------------------------- +// Isolated construction still finishes initialization (framing §1.8) +// --------------------------------------------------------------------------- + +/// **N** — isolated construction skips the ambient *reads* and still +/// flips the init-complete gate. +/// +/// Config loading and `set_init_complete()` share one conditional block, +/// so the tempting fix — skip the block when roots are redirected — +/// would leave every isolated test permanently in the init phase. +/// `tests/m8_2_acceptance.rs:75` documents its dependence on +/// integration-test construction being init-complete, so that is not a +/// hypothetical. +/// +/// Falsified by wrapping the block in `if roots.is_ambient()`. +#[test] +fn isolated_construction_is_init_complete() { + let state = EditorState::new_with_roots(&iso::roots()); + assert!( + state.lua_host.is_init_complete(), + "isolated construction must still leave the init phase, or every \ + suite that reopens it (m8_2) breaks" + ); + // The paired half: the ambient constructor is unchanged. + let ambient = EditorState::new(); + assert!(ambient.lua_host.is_init_complete()); +} + +/// **N** — the init phase is genuinely closed, not merely reported +/// closed. +/// +/// A flag read is one bool; this asserts the *behaviour* the flag gates, +/// so a fix that sets the flag without the surrounding block having run +/// cannot pass. `pmacs.attach` is init-only and must now refuse. +#[test] +fn isolated_construction_closes_the_init_only_lua_surface() { + let state = EditorState::new_with_roots(&iso::roots()); + let err = state + .lua_host + .lua() + .load(r#"pmacs.attach { target = "local:/run/pmacs/x.sock" }"#) + .exec() + .expect_err("pmacs.attach must refuse after init"); + let text = err.to_string(); + assert!( + text.contains("init"), + "the refusal must name the init phase; got {text}" + ); +} + +// --------------------------------------------------------------------------- +// Isolated construction redirects the writes (framing §1.6) +// --------------------------------------------------------------------------- + +/// **N** — the bundled-package materialization lands in the redirected +/// data root. +/// +/// Asserts content produced, not an invariant preserved: the package +/// tree has to actually exist under the isolated root. A test that only +/// checked "the real root was not modified" would pass on a machine +/// where the real root already held identical bytes, because +/// `write_if_changed` is content-gated. +#[test] +fn isolated_construction_materializes_into_the_redirected_data_root() { + let base = Path::new(env!("CARGO_TARGET_TMPDIR")).join("materialize-probe"); + let _ = std::fs::remove_dir_all(&base); + let roots = BootstrapRoots::isolated_under(&base); + let dir = roots.bundled_runtime_dir().expect("redirected data root"); + assert!(!dir.exists(), "the probe root must start absent"); + + let _state = EditorState::new_with_roots(&roots); + + let manifest = dir.join("repl").join("pmacs.toml"); + assert!( + manifest.is_file(), + "bundled packages must materialize under the redirected data \ + root; {} is missing", + manifest.display() + ); + let text = std::fs::read_to_string(&manifest).expect("read materialized manifest"); + assert!( + text.contains("repl"), + "the materialized manifest must be the bundled package's own; got {text:?}" + ); +} + +/// **N** — `install_state_dirs` honours the redirected state root. +/// +/// It runs *after* the constructor, so a constructor-only parameter +/// would leave it resolving `PMACS_STATE_HOME` / `XDG_STATE_HOME` from +/// the environment and hand an isolated session the developer's real +/// state dir. +#[test] +fn install_state_dirs_honours_the_redirected_state_root() { + let base = Path::new(env!("CARGO_TARGET_TMPDIR")).join("state-probe"); + let roots = BootstrapRoots::isolated_under(&base); + let state = EditorState::new_with_roots(&roots); + state.install_state_dirs(); + + let dir = state + .lua_host + .lua() + .app_data_ref::() + .expect("install_state_dirs must configure a state dir") + .0 + .clone(); + assert_eq!( + dir, + roots.state_dir().unwrap(), + "state dir must be redirected" + ); + let history = state.core.borrow().minibuffer.history_dir.clone(); + assert_eq!( + history, + roots.history_dir(), + "minibuffer history must be redirected too" + ); +} + +// --------------------------------------------------------------------------- +// Hostile ambient environment (framing Bet 3, acceptance 7) +// --------------------------------------------------------------------------- + +/// Names the isolated base for the isolated child; its presence is the +/// signal that this process *is* that child. +const ISOLATED_CHILD_BASE: &str = "PMACS_AMBIENT_ISOLATION_CHILD_BASE"; +/// Presence marks the ambient positive-control child. +const AMBIENT_CONTROL_CHILD: &str = "PMACS_AMBIENT_ISOLATION_CONTROL"; + +/// An `init.lua` that leaves a mark an assertion can see. The real +/// developer `init.lua` that produced this lane broke +/// `compile_mode_acceptance` by redefining a command pmacs already +/// defines; a marker global is the same exposure with a cheaper failure +/// mode, and it discriminates read-vs-not-read directly. +const HOSTILE_INIT_LUA: &str = "_G.HOSTILE_INIT_RAN = true\n"; + +fn hostile_root(name: &str) -> PathBuf { + let root = Path::new(env!("CARGO_TARGET_TMPDIR")).join(name); + let _ = std::fs::remove_dir_all(&root); + let config = root.join("pmacs"); + std::fs::create_dir_all(&config).expect("create hostile config dir"); + std::fs::write(config.join("init.lua"), HOSTILE_INIT_LUA).expect("write hostile init.lua"); + // Pre-seed the data root the ambient resolver would use, so a write + // into it is visible as a *change*, not just as a new tree. + let seeded = root + .join("pmacs") + .join("builtin-packages") + .join(format!("v{}", env!("CARGO_PKG_VERSION"))); + std::fs::create_dir_all(&seeded).expect("seed hostile data root"); + std::fs::write(seeded.join("SEED"), b"pre-seeded\n").expect("write seed marker"); + root +} + +/// Flat snapshot of a tree: relative path → contents (directories map to +/// the empty vector). Sorted, so comparison is order-independent. +fn snapshot(root: &Path) -> BTreeMap> { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let rel = path + .strip_prefix(root) + .expect("entry is under root") + .to_path_buf(); + if path.is_dir() { + out.insert(rel, Vec::new()); + walk(root, &path, out); + } else { + out.insert(rel, std::fs::read(&path).unwrap_or_default()); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +/// The five storage variables plus `HOME`, all aimed at `root`. `HOME` +/// is included here — and only here — because this is the adversary: a +/// machine that leaves an XDG variable unset falls back to it, and the +/// point of a hostile environment is to leave no path out. +fn hostile_env(root: &Path) -> Vec<(&'static str, PathBuf)> { + vec![ + ("HOME", root.to_path_buf()), + ("XDG_CONFIG_HOME", root.to_path_buf()), + ("XDG_DATA_HOME", root.to_path_buf()), + ("XDG_STATE_HOME", root.to_path_buf()), + ("PMACS_STATE_HOME", root.to_path_buf()), + ("XDG_CACHE_HOME", root.to_path_buf()), + ] +} + +fn run_child(test_name: &str, env: Vec<(&'static str, PathBuf)>) -> (bool, String) { + let exe = std::env::current_exe().expect("current test binary"); + let output = Command::new(exe) + .args(["--exact", test_name, "--nocapture", "--test-threads=1"]) + .envs(env) + .output() + .unwrap_or_else(|e| panic!("re-exec `{test_name}`: {e}")); + let mut log = String::new(); + log.push_str("--- stdout ---\n"); + log.push_str(&String::from_utf8_lossy(&output.stdout)); + log.push_str("--- stderr ---\n"); + log.push_str(&String::from_utf8_lossy(&output.stderr)); + assert!( + log.contains("1 passed") || !output.status.success(), + "child `{test_name}` ran no test — the `--exact` filter went stale\n{log}" + ); + (output.status.success(), log) +} + +/// **Positive control.** Under the hostile environment, the *ambient* +/// constructor really is captured by it. +/// +/// Without this the isolation assertion below is unfalsifiable: an +/// `init.lua` that never loads under any circumstances would satisfy +/// "the isolated editor did not load it" while proving nothing. +/// +/// Runs only as a re-exec'd child (marker set), so an ordinary suite run +/// does not construct an ambient editor. +#[test] +fn ambient_construction_under_a_hostile_environment_is_captured_by_it() { + if std::env::var_os(AMBIENT_CONTROL_CHILD).is_none() { + return; + } + let state = EditorState::new(); + let ran: bool = state + .lua_host + .lua() + .load("return _G.HOSTILE_INIT_RAN == true") + .eval() + .expect("read the hostile marker"); + assert!( + ran, + "the ambient constructor must load the hostile init.lua — if it \ + does not, the isolation assertion proves nothing" + ); + // And the write half: the ambient constructor materializes into the + // hostile data root. + let dir = pmacs::builtin_packages::bundled_runtime_dir(); + assert!( + dir.join("repl").join("pmacs.toml").is_file(), + "the ambient constructor must write into the hostile data root; \ + {} is empty", + dir.display() + ); +} + +/// The isolated child: same hostile environment, redirected roots. +#[test] +fn isolated_construction_under_a_hostile_environment_ignores_it() { + let Some(base) = std::env::var_os(ISOLATED_CHILD_BASE) else { + return; + }; + let base = PathBuf::from(base); + let roots = BootstrapRoots::isolated_under(&base); + let state = EditorState::new_with_roots(&roots); + let ran: bool = state + .lua_host + .lua() + .load("return _G.HOSTILE_INIT_RAN == true") + .eval() + .expect("read the hostile marker"); + assert!(!ran, "the hostile init.lua must not have been loaded"); + assert!( + state.lua_host.is_init_complete(), + "and initialization must still have finished" + ); + // Content produced, in the right place. + let dir = roots.bundled_runtime_dir().expect("redirected data root"); + assert!( + dir.join("repl").join("pmacs.toml").is_file(), + "bundled packages must land under the isolated root; {} is empty", + dir.display() + ); +} + +/// **N** — the whole of Bet 3: green under a hostile environment, and +/// the hostile root byte-identical afterwards. +/// +/// Two children, because the two halves need opposite environments to be +/// meaningful: the positive control must be *captured* by its hostile +/// root (and so modifies it), while the isolated child must leave its +/// own hostile root untouched. +#[test] +fn a_hostile_ambient_environment_is_neither_read_nor_written() { + // Half 1 — the control. Its hostile root is expected to change. + let control = hostile_root("hostile-control"); + let before_control = snapshot(&control); + let (ok, log) = run_child( + "ambient_construction_under_a_hostile_environment_is_captured_by_it", + { + let mut env = hostile_env(&control); + env.push((AMBIENT_CONTROL_CHILD, PathBuf::from("1"))); + env + }, + ); + assert!(ok, "the ambient positive control must be captured\n{log}"); + let after_control = snapshot(&control); + assert_ne!( + before_control, after_control, + "the control's hostile root must have been written into — if it \ + was not, this environment is not hostile and the isolated half \ + below asserts nothing" + ); + + // Half 2 — the isolated child. Its hostile root must be untouched. + let hostile = hostile_root("hostile-isolated"); + let isolated_base = Path::new(env!("CARGO_TARGET_TMPDIR")).join("hostile-isolated-roots"); + let _ = std::fs::remove_dir_all(&isolated_base); + let before = snapshot(&hostile); + assert!(!before.is_empty(), "the hostile root must not be empty"); + let (ok, log) = run_child( + "isolated_construction_under_a_hostile_environment_ignores_it", + { + let mut env = hostile_env(&hostile); + env.push((ISOLATED_CHILD_BASE, isolated_base.clone())); + env + }, + ); + assert!(ok, "the isolated child must stay green\n{log}"); + let after = snapshot(&hostile); + assert_eq!( + before, after, + "the hostile root must be byte-identical afterwards — a green \ + suite that still wrote into it has not demonstrated isolation" + ); + // And the writes went somewhere: the isolated tree exists. + assert!( + isolated_base.join("data").join("pmacs").is_dir(), + "the isolated data root must have been written instead" + ); +} + +// --------------------------------------------------------------------------- +// Adoption ratchet (framing acceptance 12) +// --------------------------------------------------------------------------- + +/// Files permitted to construct an editor through the **ambient** entry +/// points. +/// +/// `journey_acceptance` is ambient on purpose: it is the golden-journey +/// ratchet, and its whole claim is that the production entry point +/// `pmacs FILE` calls has a caller. It is isolated by re-execing itself +/// with controlled roots instead (framing §1.10). This file is ambient +/// only inside the positive control above, which never runs except as a +/// deliberately re-exec'd child. +const AMBIENT_ALLOWLIST: &[&str] = &["journey_acceptance.rs", "ambient_isolation_acceptance.rs"]; + +/// Strip comments and string-literal *contents* from Rust source, so a +/// scan counts calls rather than mentions. +/// +/// Both matter here. `m5_6_acceptance.rs:94` names `EditorState::new` in +/// a comment only to say it deliberately does not call it, and +/// `journey_acceptance.rs:2030` carries it inside an assertion message. +/// Raw strings (`r#"..."#`) are pervasive in this suite, so they are +/// handled rather than hoped about. +fn strip_comments_and_strings(src: &str) -> String { + let b: Vec = src.chars().collect(); + let mut out = String::with_capacity(src.len()); + let mut i = 0; + while i < b.len() { + // Line comment. + if b[i] == '/' && i + 1 < b.len() && b[i + 1] == '/' { + while i < b.len() && b[i] != '\n' { + i += 1; + } + continue; + } + // Block comment (Rust's nest). + if b[i] == '/' && i + 1 < b.len() && b[i + 1] == '*' { + let mut depth = 1; + i += 2; + while i < b.len() && depth > 0 { + if b[i] == '/' && i + 1 < b.len() && b[i + 1] == '*' { + depth += 1; + i += 2; + } else if b[i] == '*' && i + 1 < b.len() && b[i + 1] == '/' { + depth -= 1; + i += 2; + } else { + i += 1; + } + } + out.push(' '); + continue; + } + // Raw string: r, then any number of #, then ". + if b[i] == 'r' { + let mut j = i + 1; + let mut hashes = 0; + while j < b.len() && b[j] == '#' { + hashes += 1; + j += 1; + } + if j < b.len() && b[j] == '"' { + j += 1; + loop { + if j >= b.len() { + break; + } + if b[j] == '"' { + let mut k = j + 1; + let mut seen = 0; + while k < b.len() && b[k] == '#' && seen < hashes { + seen += 1; + k += 1; + } + if seen == hashes { + j = k; + break; + } + } + j += 1; + } + out.push(' '); + i = j; + continue; + } + } + // Ordinary string. + if b[i] == '"' { + i += 1; + while i < b.len() { + if b[i] == '\\' { + i += 2; + continue; + } + if b[i] == '"' { + i += 1; + break; + } + i += 1; + } + out.push(' '); + continue; + } + out.push(b[i]); + i += 1; + } + out +} + +fn tests_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests") +} + +/// Every `.rs` file under `tests/`, including `tests/common/`. +fn test_sources() -> Vec<(String, String)> { + let mut out = Vec::new(); + let dir = tests_dir(); + let push_dir = |d: &Path, out: &mut Vec<(String, String)>| { + for entry in std::fs::read_dir(d).expect("read tests dir").flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "rs") { + let name = path + .file_name() + .expect("file name") + .to_string_lossy() + .into_owned(); + out.push((name, std::fs::read_to_string(&path).expect("read source"))); + } + } + }; + push_dir(&dir, &mut out); + push_dir(&dir.join("common"), &mut out); + out +} + +/// **N** — a *durable* adoption ratchet, not a one-time census. +/// +/// One self-spawning hostile-environment test proves the seam works; it +/// cannot notice a raw `EditorState::new()` added to a different binary +/// next month. This can. Falsified by adding an ambient constructor to +/// any non-allowlisted suite. +#[test] +fn no_test_outside_the_allowlist_constructs_an_ambient_editor() { + let sources = test_sources(); + // A broken glob must not read as a clean tree. + assert!( + sources.len() > 90, + "expected the whole tests/ corpus; found only {} files", + sources.len() + ); + let needles = [ + concat!("EditorState::", "new()"), + concat!("EditorState::", "open("), + ]; + let mut offenders: Vec = Vec::new(); + let mut seen_allowlisted: Vec<&str> = Vec::new(); + for (name, src) in &sources { + let code = strip_comments_and_strings(src); + let hits: usize = needles.iter().map(|n| code.matches(n).count()).sum(); + if hits == 0 { + continue; + } + if AMBIENT_ALLOWLIST.contains(&name.as_str()) { + seen_allowlisted.push( + AMBIENT_ALLOWLIST + .iter() + .find(|a| **a == name.as_str()) + .expect("just matched"), + ); + } else { + offenders.push(format!("{name} ({hits} site(s))")); + } + } + offenders.sort(); + assert!( + offenders.is_empty(), + "these suites construct an editor through the ambient entry \ + points, so they read the developer's real init.lua and write \ + into their real data root: {offenders:?}\n\ + Use `EditorState::new_with_roots(&crate::iso::roots())` (see \ + tests/common/iso.rs), or add the file to AMBIENT_ALLOWLIST with \ + a reason.", + ); + // Dead allowlist entries are how a ratchet rots: an entry that no + // longer needs to be there silently licenses a future regression. + let mut missing: Vec<&&str> = AMBIENT_ALLOWLIST + .iter() + .filter(|a| !seen_allowlisted.contains(&**a)) + .collect(); + missing.sort_unstable(); + assert!( + missing.is_empty(), + "allowlisted files that no longer construct an ambient editor — \ + remove them: {missing:?}" + ); +} + +/// **N** — the ratchet's scanner is not fooled by prose. +/// +/// A grep-shaped answer is what cost this lane a review round; the scan +/// above only ratchets if it distinguishes a call from a mention. Pinned +/// with the exact shapes the corpus actually contains. +#[test] +fn the_ratchet_scanner_counts_calls_not_mentions() { + let sample = r##" +//! `EditorState::new()` in a doc comment. +// `EditorState::new()` in a line comment. +/* `EditorState::new()` in a block comment. */ +fn f() { + let msg = "EditorState::open(file) must not greet"; + let raw = r#"EditorState::new() inside a raw string"#; + let _ = EditorState::new(); +} +"##; + let code = strip_comments_and_strings(sample); + assert_eq!( + code.matches(concat!("EditorState::", "new()")).count(), + 1, + "only the call survives; got {code:?}" + ); + assert_eq!( + code.matches(concat!("EditorState::", "open(")).count(), + 0, + "the assertion-message mention must not count; got {code:?}" + ); +} + +/// **N** — the seam actually reached the corpus. +/// +/// The ratchet above is an absence check, and an absence check passes on +/// a tree where nobody constructs an editor at all. This asserts the +/// positive: the isolated constructor has broad adoption. +#[test] +fn the_isolated_constructor_has_been_adopted_across_the_corpus() { + let sources = test_sources(); + let adopters: Vec<&String> = sources + .iter() + .filter(|(_, src)| { + let code = strip_comments_and_strings(src); + code.contains(concat!("EditorState::new", "_with_roots(")) + || code.contains(concat!("EditorState::open", "_with_roots(")) + }) + .map(|(name, _)| name) + .collect(); + assert!( + adopters.len() >= 60, + "expected the whole in-process population to have migrated; only \ + {} files did", + adopters.len() + ); +} diff --git a/tests/common/iso.rs b/tests/common/iso.rs new file mode 100644 index 0000000..bdc6f8f --- /dev/null +++ b/tests/common/iso.rs @@ -0,0 +1,68 @@ +// tests/common/iso.rs --- isolated bootstrap roots for integration tests. + +//! The integration suite's side of `pmacs::bootstrap`. +//! +//! An integration test links `pmacs` as an ordinary dependency, so it is +//! compiled **without** `cfg(test)` and the `#[cfg(not(test))]` guard in +//! `EditorState::new` is live for it: a raw `EditorState::new()` reads +//! the developer's real `~/.config/pmacs/init.lua` and writes bundled +//! packages into the developer's real `~/.local/share/pmacs`. It cannot +//! fix that by setting an environment variable --- `std::env::set_var` +//! is `unsafe` and `pmacs` is `#![forbid(unsafe_code)]`. So it passes +//! [`roots`] to `EditorState::new_with_roots` instead. +//! +//! See `docs/test-ambient-config-isolation-framing.md`. +//! +//! Included per-file rather than through `mod common;` so a suite that +//! needs no daemon or PTY fixture does not compile them: +//! +//! ```ignore +//! #[path = "common/iso.rs"] +//! mod iso; +//! ``` +//! +//! [`roots`] is a **pure function of the build environment** --- no +//! counter, no `OnceLock`. Two copies of this module in one test binary +//! (one via `mod common;`, one via `#[path]`) therefore return the same +//! value rather than racing over a shared counter. + +#![allow(dead_code)] // not every including suite uses every helper + +use std::path::PathBuf; + +use pmacs::bootstrap::BootstrapRoots; + +/// The shared isolated base, under Cargo's per-package integration-test +/// temp directory. +/// +/// `CARGO_TARGET_TMPDIR` (`target//tmp`) rather than `/tmp`: +/// nothing here is ever unlinked --- there is no libtest teardown hook +/// to unlink it from --- so the tree has to live somewhere `cargo clean` +/// owns instead of leaking into the system temp dir once per run. +/// +/// Deliberately shared across tests and across test binaries. +/// `materialize_all` is content-gated and idempotent, so after the first +/// construction every later one is a no-op read; a per-test directory +/// would repeat the whole materialization ~330 times per run for no +/// isolation gain (the tree is byte-identical for every caller). +#[must_use] +pub fn base() -> PathBuf { + PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("ambient-isolation") +} + +/// Isolated bootstrap roots for an in-process editor. +/// +/// Pass to `EditorState::new_with_roots` / `open_with_roots`. +#[must_use] +pub fn roots() -> BootstrapRoots { + let base = base(); + let roots = BootstrapRoots::isolated_under(&base); + // Create the config dir eagerly. `load_user_config_at` registers it + // on Lua's `package.path` whether or not `init.lua` exists, and a + // suite that later writes a config chunk into it should not have to + // know the layout. + if let Some(dir) = roots.config_dir() { + std::fs::create_dir_all(&dir).expect("create isolated config dir"); + } + roots +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index e157208..7d2e4ca 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -14,8 +14,14 @@ //! - [`daemon`]: `pmacs --daemon` subprocess fixture. First //! consumer M5.5 acceptance suite; second consumer M10.11 //! doubled-PTY tests. +//! - [`iso`]: isolated bootstrap storage roots, so an in-process +//! editor neither reads the developer's real `init.lua` nor writes +//! into their real data root. Most suites include it directly via +//! `#[path = "common/iso.rs"] mod iso;` rather than through this +//! module; it is re-exported here for `daemon`'s use. #![allow(dead_code)] // not every integration-test file uses every helper pub mod daemon; +pub mod iso; pub mod pty; From fb14dc9ec3ff98741743464bad5cd9322b79142a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 18:48:45 -0400 Subject: [PATCH 3/6] test(isolation): migrate the corpus off the ambient roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanical half, riding on the census in the previous commit. * 342 in-process construction sites in 65 files now take `new_with_roots` / `open_with_roots` with `iso::roots()`. The isolated base is a pure function of `CARGO_TARGET_TMPDIR` — no counter, no `OnceLock` — so two copies of the module in one binary agree instead of racing, and the tree lives somewhere `cargo clean` owns rather than leaking into `/tmp` once per run. It is shared deliberately: materialization is content-gated and idempotent, so a per-test directory would repeat it ~330 times per run for a byte-identical result. * `journey_acceptance` keeps the ambient `EditorState::open`, because proving the production entry point has a caller is the whole of what that ratchet is for. Rev 2's "isolated by the environment its binary is launched with" was not a mechanism — cargo launches each test binary with the caller's environment, and a binary cannot re-point its own roots before its tests run. Each test is now a thin parent that re-execs this binary for its own name with controlled roots, and the child runs the body against production's call. Two pins guard it: the child asserts all four roots resolve inside the controlled base, and the suite asserts against its own source that it has not quietly taken the seam. The parent also asserts the child ran `1 passed` — a stale `--exact` filter would otherwise hollow the whole thing out silently. * The shared spawners take all five storage variables. `spawn_daemon_process_with_env` set `HOME` and `XDG_CONFIG_HOME` only; `HOME` is a FALLBACK, so it isolates a root only while the matching `XDG_*` is unset — the harness's apparent adequacy was a property of one developer's environment. The PTY spawner backfills whichever of the five its caller did not pin. The 10 direct `Command::new` daemon and attach spawns get the same treatment. Three suites had `mod common;` behind `#[cfg(feature = "crdt")]`; `common::iso` is needed in every build, so those are ungated. Files that already pull in `common` reach `iso` through a `use` rather than a second `#[path]` declaration — loading one file as two modules is `clippy::duplicate_mod`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- tests/auto_indent_acceptance.rs | 9 +- tests/auto_pair_acceptance.rs | 11 +- tests/autosave_acceptance.rs | 9 +- tests/bottom_panel_stage1_acceptance.rs | 11 +- tests/bottom_panel_stage2a_acceptance.rs | 9 +- .../bottom_panel_stage2b_daemon_acceptance.rs | 11 +- tests/comment_toggle_acceptance.rs | 9 +- tests/common/daemon.rs | 24 +- tests/common/pty.rs | 15 + tests/compile_mode_acceptance.rs | 9 +- tests/completion_popup_acceptance.rs | 25 +- tests/config_registry_acceptance.rs | 9 +- tests/cua_region_acceptance.rs | 17 +- tests/desktop_acceptance.rs | 9 +- tests/dired_acceptance.rs | 9 +- tests/editops_acceptance.rs | 19 +- tests/find_file_acceptance.rs | 13 +- tests/folding_acceptance.rs | 27 +- tests/folding_stage2_acceptance.rs | 9 +- tests/gpu_font_acceptance.rs | 13 +- tests/gpu_invocation_acceptance.rs | 10 + tests/injection_acceptance.rs | 13 +- tests/journey_acceptance.rs | 299 ++++++++++++++++++ tests/kill_ring_acceptance.rs | 9 +- tests/lean4_server_acceptance.rs | 9 +- tests/lean4_stage1_acceptance.rs | 13 +- tests/lean_input_acceptance.rs | 13 +- tests/listview_acceptance.rs | 39 ++- tests/lsp_dispatch_seams_acceptance.rs | 9 +- tests/lsp_multi_root_acceptance.rs | 9 +- tests/lsp_spawn_guidance_acceptance.rs | 9 +- tests/m11_5_semantic_acceptance.rs | 10 +- tests/m4_acceptance.rs | 245 +++++++------- tests/m5_5_acceptance.rs | 4 + tests/m5_7_acceptance.rs | 20 ++ tests/m5_8_acceptance.rs | 12 + tests/m5_perf_acceptance.rs | 4 + tests/m6_4_repl_acceptance.rs | 13 +- tests/m6_5_repl_acceptance.rs | 11 +- tests/m6_7_scrollback_acceptance.rs | 9 +- tests/m6_8_multi_repl_acceptance.rs | 23 +- tests/m6_perf_acceptance.rs | 17 +- tests/m7_11_acceptance.rs | 11 +- tests/m8_10_acceptance.rs | 9 +- tests/m8_1_acceptance.rs | 9 +- tests/m8_2_acceptance.rs | 9 +- tests/m8_3_acceptance.rs | 9 +- tests/m8_5_acceptance.rs | 9 +- tests/m8_6_acceptance.rs | 9 +- tests/m8_7_acceptance.rs | 9 +- tests/m8_9_acceptance.rs | 9 +- tests/m9_1_acceptance.rs | 11 +- tests/m9_2_acceptance.rs | 9 +- tests/m9_3_acceptance.rs | 9 +- tests/m9_4_acceptance.rs | 9 +- tests/m9_5_acceptance.rs | 9 +- tests/m9_6_acceptance.rs | 9 +- tests/m9_7_acceptance.rs | 9 +- tests/m9_8_acceptance.rs | 9 +- tests/overlay_reattach_acceptance.rs | 11 +- tests/persistence_acceptance.rs | 13 +- tests/query_replace_acceptance.rs | 39 ++- tests/resource_reconciliation_acceptance.rs | 9 +- tests/save_clobber_guard_acceptance.rs | 19 +- tests/statusline_segments_acceptance.rs | 15 +- tests/terminal_config_acceptance.rs | 31 +- tests/terminal_copy_mode_acceptance.rs | 45 +-- tests/theme_faces_acceptance.rs | 15 +- tests/typed_edit_chain_acceptance.rs | 11 +- tests/vterm_stage1_acceptance.rs | 21 +- tests/vterm_stage2_acceptance.rs | 14 +- tests/vterm_stage3_acceptance.rs | 18 +- tests/worker_shutdown_acceptance.rs | 13 +- 73 files changed, 1176 insertions(+), 322 deletions(-) diff --git a/tests/auto_indent_acceptance.rs b/tests/auto_indent_acceptance.rs index 5d40590..17c8403 100644 --- a/tests/auto_indent_acceptance.rs +++ b/tests/auto_indent_acceptance.rs @@ -74,7 +74,7 @@ fn status(s: &EditorState) -> String { /// Fresh editor whose active scratch buffer holds `body`, cursor at 0. fn editor_with(body: &str) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); if !body.is_empty() { exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})")); } @@ -503,3 +503,10 @@ fn isearch_ret_accepts_instead_of_inserting() { "RET during isearch accepts; no newline is inserted" ); } + +// 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; diff --git a/tests/auto_pair_acceptance.rs b/tests/auto_pair_acceptance.rs index 29291b3..7a20b06 100644 --- a/tests/auto_pair_acceptance.rs +++ b/tests/auto_pair_acceptance.rs @@ -29,7 +29,7 @@ fn fresh_state_dir() -> PathBuf { } fn editor(state_dir: &std::path::Path) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host .lua() @@ -114,7 +114,7 @@ fn status(s: &EditorState) -> String { /// Fresh scratch-buffer editor whose buffer holds `body`, cursor at 0. /// No state dir / no files: scratch pairing uses the `default` set. fn editor_with(body: &str) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); if !body.is_empty() { exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})")); } @@ -1378,3 +1378,10 @@ fn relocated_closer_first_did_change_carries_the_complete_effective_text() { text in the first didChange — never an opener-only intermediate" ); } + +// 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; diff --git a/tests/autosave_acceptance.rs b/tests/autosave_acceptance.rs index 4567f96..8433f28 100644 --- a/tests/autosave_acceptance.rs +++ b/tests/autosave_acceptance.rs @@ -24,7 +24,7 @@ fn fresh_state_dir() -> PathBuf { } fn editor(state_dir: &std::path::Path) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host .lua() @@ -861,3 +861,10 @@ fn before_quit_sweeps_synchronously_without_vetoing() { ); std::fs::remove_dir_all(&dir).ok(); } + +// Isolated bootstrap storage roots (see the module docs): an +// integration test is compiled without `cfg(test)`, so a raw +// `EditorState::new()` would read the developer's real `init.lua` and +// write into their real data root. +#[path = "common/iso.rs"] +mod iso; diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs index fb3a4ad..400754a 100644 --- a/tests/bottom_panel_stage1_acceptance.rs +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -39,7 +39,7 @@ const COLS: u32 = 60; const AREA_ROWS: u32 = ROWS - 1; fn editor() -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); // Geometry is authoritative state, and a grid frontend's real frame // size IS its declaration. Every test that does not render declares @@ -2571,7 +2571,7 @@ fn panel_hidden_never_describes_a_panel_that_no_longer_exists() { #[test] fn unknown_geometry_is_not_twenty_four_by_eighty() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); let fid = FrontendId(77); attach_frontend(&s, fid, false); @@ -2619,3 +2619,10 @@ fn cell_coord_helper_is_used() { // Keeps the CellCoord import honest for grid assertions above. assert_eq!(CellCoord::new(1, 2).row, 1); } + +// 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; diff --git a/tests/bottom_panel_stage2a_acceptance.rs b/tests/bottom_panel_stage2a_acceptance.rs index d39d804..1e5ed05 100644 --- a/tests/bottom_panel_stage2a_acceptance.rs +++ b/tests/bottom_panel_stage2a_acceptance.rs @@ -22,7 +22,7 @@ const ROWS: u32 = 24; const COLS: u32 = 60; fn editor() -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); s @@ -896,3 +896,10 @@ fn a_provider_closing_the_document_split_still_clears_the_statusline() { the phase-1 document identity, even when a callback closed that window; got {msgs:?}" ); } + +// 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; diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs index 0bbf8d0..cc80422 100644 --- a/tests/bottom_panel_stage2b_daemon_acceptance.rs +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -56,7 +56,7 @@ impl Session { /// A semantic, panel-capable frontend with one document window and /// no geometry declared yet. fn new() -> Self { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, "pmacs.lsp.config = {}"); let document = { let mut core = state.core.borrow_mut(); @@ -659,7 +659,7 @@ fn a2b1_a_rejected_declaration_reconciles_nothing() { fn a2b1_grid_allocator_exhaustion_clears_the_declaration_and_hides() { // The grid/LOCAL allocator, which mints its own epochs. `LOCAL` is // panel-capable, so this is the production path for a TUI. - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, "pmacs.lsp.config = {}"); state.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); exec( @@ -1340,3 +1340,10 @@ fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() { ); exec(&session.state, "pmacs.terminal.terminate(TERM_BUF)"); } + +// 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; diff --git a/tests/comment_toggle_acceptance.rs b/tests/comment_toggle_acceptance.rs index 0e00f21..caa06e1 100644 --- a/tests/comment_toggle_acceptance.rs +++ b/tests/comment_toggle_acceptance.rs @@ -25,7 +25,7 @@ fn fresh_state_dir() -> PathBuf { } fn editor(state_dir: &std::path::Path) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host .lua() @@ -366,3 +366,10 @@ fn toggle_between_kills_breaks_the_kill_chain() { "C-k, M-;, C-k yields two ring entries (chain broken)" ); } + +// 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; diff --git a/tests/common/daemon.rs b/tests/common/daemon.rs index 1c8bc0d..1e67642 100644 --- a/tests/common/daemon.rs +++ b/tests/common/daemon.rs @@ -159,9 +159,31 @@ pub fn spawn_daemon_process_with_env(socket_path: &Path, env_vars: &[(&str, &str cmd.args(["--daemon", "--socket"]) .arg(socket_path) .env("HOME", isolated_home) - .env("XDG_CONFIG_HOME", isolated_home) .stdout(Stdio::null()) .stderr(Stdio::from(stderr)); + // All FIVE storage variables, not just `XDG_CONFIG_HOME` + // (`docs/test-ambient-config-isolation-framing.md` §1.6a). + // + // `HOME` above is only a *fallback*: it isolates a root only while + // the corresponding `XDG_*` variable is unset. On a developer + // machine that exports `XDG_DATA_HOME`, a daemon given `HOME` alone + // still materializes bundled packages into the real data root — so + // the harness's apparent adequacy was a property of one machine's + // environment, not of the harness. And `PMACS_STATE_HOME` outranks + // `XDG_STATE_HOME`, so the four XDG variables alone leave a + // higher-precedence state override live. + // + // Flat layout (every root at the socket's tempdir) so + // `spawn_with_config`'s `/pmacs/init.lua` keeps loading + // through the real `load_user_config` path. + let roots = pmacs::bootstrap::BootstrapRoots::ambient() + .with_config_root(isolated_home.to_path_buf()) + .with_data_root(isolated_home.to_path_buf()) + .with_state_root(isolated_home.to_path_buf()) + .with_cache_root(isolated_home.to_path_buf()); + for (key, value) in roots.child_env() { + cmd.env(key, value); + } for (key, value) in env_vars { cmd.env(key, value); } diff --git a/tests/common/pty.rs b/tests/common/pty.rs index a1f16c8..e8573e1 100644 --- a/tests/common/pty.rs +++ b/tests/common/pty.rs @@ -112,6 +112,21 @@ pub fn spawn_pmacs_in_pty(args: &[&str], envs: &[(&str, &Path)], rows: u16, cols for arg in args { cmd.arg(arg); } + // Backfill any of the five storage variables the caller did not set + // (`docs/test-ambient-config-isolation-framing.md` §1.6a). Callers + // pin the roots their assertions read (a per-test `XDG_CONFIG_HOME`, + // a `PMACS_STATE_HOME` they inspect); the rest would otherwise be + // inherited, and the child would materialize bundled packages into + // the developer's real data root. Caller entries win: this only + // fills holes. + let fallback = pmacs::bootstrap::BootstrapRoots::isolated_under(&super::iso::base()); + for (key, value) in fallback.child_env() { + if envs.iter().any(|(k, _)| *k == key) { + continue; + } + std::fs::create_dir_all(&value).expect("create isolated root for PTY child"); + cmd.env(key, value.as_os_str()); + } for (k, v) in envs { let mut value = OsString::new(); value.push(v); diff --git a/tests/compile_mode_acceptance.rs b/tests/compile_mode_acceptance.rs index e7f21e0..a223406 100644 --- a/tests/compile_mode_acceptance.rs +++ b/tests/compile_mode_acceptance.rs @@ -82,7 +82,7 @@ fn errors_buffer(s: &EditorState) -> String { /// Fresh editor with LSP spawning disabled (language detection still /// works; the after-load hook must not exec real servers). fn editor() -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); s } @@ -2853,3 +2853,10 @@ fn j1b1_context_reports_the_cwd_a_run_would_use() { ); assert_eq!(kind, "rust", "the kind is detected from the given cwd"); } + +// 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; diff --git a/tests/completion_popup_acceptance.rs b/tests/completion_popup_acceptance.rs index f15a705..1867fc2 100644 --- a/tests/completion_popup_acceptance.rs +++ b/tests/completion_popup_acceptance.rs @@ -52,7 +52,7 @@ fn probe(s: &EditorState) -> (String, bool, i64) { /// the prefix is replaced by the candidate in one step. #[test] fn typing_opens_popup_and_tab_accepts() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello_world "); let (_, visible, _) = probe(&s); assert!( @@ -95,7 +95,7 @@ fn typing_opens_popup_and_tab_accepts() { /// candidate wins. Uses a custom provider for a deterministic order. #[test] fn ctrl_n_navigates_then_ret_accepts_second_candidate() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host .lua() .load( @@ -133,7 +133,7 @@ fn ctrl_n_navigates_then_ret_accepts_second_candidate() { /// key self-inserts normally (the shadow is partial, not modal). #[test] fn esc_dismisses_and_typing_falls_through() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello_world he"); let (_, visible, _) = probe(&s); assert!(visible); @@ -154,7 +154,7 @@ fn esc_dismisses_and_typing_falls_through() { /// anchor. #[test] fn motion_before_anchor_closes_popup() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello_world he"); let (_, visible, _) = probe(&s); assert!(visible); @@ -169,7 +169,7 @@ fn motion_before_anchor_closes_popup() { /// the Q#C9 single-char signature rejects paste-shaped deltas. #[test] fn yank_shaped_edit_does_not_auto_open() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello_world hello"); // Select the trailing word and cut it (C-w): the popup that was // open over `hello` closes as its word dies. @@ -198,7 +198,7 @@ fn yank_shaped_edit_does_not_auto_open() { /// auto-open prefix threshold. #[test] fn at_point_command_opens_below_threshold() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello_world h"); let (_, visible, _) = probe(&s); assert!(!visible, "a 1-char prefix stays below the auto-open bar"); @@ -223,7 +223,7 @@ fn at_point_command_opens_below_threshold() { /// `C-g` abort) reaches the dispatcher instead of the popup shadow. #[test] fn pending_prefix_dismisses_popup_and_keeps_dispatcher_control() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello_world he"); let (_, visible, _) = probe(&s); assert!(visible); @@ -258,7 +258,7 @@ fn pending_prefix_dismisses_popup_and_keeps_dispatcher_control() { /// index sweep meant the server was never asked). #[test] fn empty_sync_sweep_still_leaves_a_pending_session() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); // A buffer whose only word is the one being typed: dabbrev is // structurally empty, no LSP attached. The popup cannot open... type_str(&mut s, "qz"); @@ -282,7 +282,7 @@ fn empty_sync_sweep_still_leaves_a_pending_session() { /// because the real LSP store is Rust-side. #[test] fn collect_scopes_lsp_candidates_to_ctx_uri() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let (scoped, unscoped): (u64, u64) = s .lua_host .lua() @@ -322,3 +322,10 @@ fn collect_scopes_lsp_candidates_to_ctx_uri() { assert_eq!(scoped, 1, "ctx.uri reaches Lua providers (9th arg)"); assert_eq!(unscoped, 2, "no uri → all documents"); } + +// 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; diff --git a/tests/config_registry_acceptance.rs b/tests/config_registry_acceptance.rs index 41749fa..e194003 100644 --- a/tests/config_registry_acceptance.rs +++ b/tests/config_registry_acceptance.rs @@ -47,7 +47,7 @@ fn fresh_state_dir() -> PathBuf { } fn editor(state_dir: &std::path::Path) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host .lua() @@ -507,3 +507,10 @@ fn describe_setting_shows_a_buffer_local_override_when_one_exists() { "an existing buffer-local override must be reported, got {text:?}" ); } + +// 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; diff --git a/tests/cua_region_acceptance.rs b/tests/cua_region_acceptance.rs index c4c16d7..89f22a8 100644 --- a/tests/cua_region_acceptance.rs +++ b/tests/cua_region_acceptance.rs @@ -50,7 +50,7 @@ fn probe(s: &EditorState) -> (String, bool, i64) { #[test] fn backspace_deletes_the_shift_selected_region() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello"); // Shift+Left three times: region [2, 5), cursor at 2. @@ -85,7 +85,7 @@ fn backspace_deletes_the_shift_selected_region() { /// chorded deletion keys to this same dispatch path. #[test] fn ctrl_backspace_deletes_the_previous_word() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "alpha beta"); s.dispatch_key( @@ -111,7 +111,7 @@ fn ctrl_backspace_deletes_the_previous_word() { #[test] fn typing_replaces_the_shift_selected_region() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello"); // Select "llo" (region [2, 5), cursor at 2), then type 'X': @@ -149,7 +149,7 @@ fn typing_replaces_the_shift_selected_region() { #[test] fn type_over_is_a_single_undo_step() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello"); // Select "llo" (region [2, 5)) and type 'X' → "heX". @@ -177,7 +177,7 @@ fn type_over_is_a_single_undo_step() { #[test] fn delete_forward_deletes_the_shift_selected_region() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "world"); // Shift+Home-equivalent: extend left over the whole word. @@ -200,3 +200,10 @@ fn delete_forward_deletes_the_shift_selected_region() { assert_eq!(text, "b", "no region ⇒ plain forward delete at cursor"); assert_eq!(cursor, 0); } + +// 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; diff --git a/tests/desktop_acceptance.rs b/tests/desktop_acceptance.rs index 61cab86..dffe516 100644 --- a/tests/desktop_acceptance.rs +++ b/tests/desktop_acceptance.rs @@ -32,7 +32,7 @@ fn fresh_state_dir() -> PathBuf { } fn editor(state_dir: &std::path::Path) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host .lua() @@ -428,3 +428,10 @@ fn startup_gate_respects_file_arg_and_arming() { ); std::fs::remove_dir_all(&dir).ok(); } + +// Isolated bootstrap storage roots (see the module docs): an +// integration test is compiled without `cfg(test)`, so a raw +// `EditorState::new()` would read the developer's real `init.lua` and +// write into their real data root. +#[path = "common/iso.rs"] +mod iso; diff --git a/tests/dired_acceptance.rs b/tests/dired_acceptance.rs index bff9134..cacfdea 100644 --- a/tests/dired_acceptance.rs +++ b/tests/dired_acceptance.rs @@ -116,7 +116,7 @@ fn eval(s: &EditorState, src: &str) -> T { /// frame size *is* its geometry declaration, and the panel tests need /// one before any side window can be placed). fn editor() -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); s @@ -1997,3 +1997,10 @@ fn dired_the_fold_refusal_names_the_read_only_lock() { "and not the sentence that is no longer true; got {st:?}" ); } + +// 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; diff --git a/tests/editops_acceptance.rs b/tests/editops_acceptance.rs index 0a30f95..f58f53a 100644 --- a/tests/editops_acceptance.rs +++ b/tests/editops_acceptance.rs @@ -114,7 +114,7 @@ fn status(s: &EditorState) -> String { /// typing RET would route through edit.newline-and-indent and clone /// leading whitespace), cursor at 0. fn editor_with(text: &str) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, &format!( @@ -1155,7 +1155,7 @@ fn temp_path(name: &str) -> std::path::PathBuf { fn trim_on_save_defaults_off_and_writes_untouched_bytes() { let path = temp_path("off.txt"); std::fs::write(&path, "x \ny\t\n").unwrap(); - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let on: bool = eval(&s, "return pmacs.editops.trim_on_save()"); assert!(!on, "default off"); exec( @@ -1175,7 +1175,7 @@ fn trim_on_save_defaults_off_and_writes_untouched_bytes() { fn trim_on_save_trims_the_written_bytes_before_later_callbacks() { let path = temp_path("on.txt"); std::fs::write(&path, "x \ny\t\n").unwrap(); - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.editops.trim_on_save(true)"); // A callback registered AFTER editops' (load-time) hook observes // the fan-out order saveplace sees: post-trim text. @@ -1215,7 +1215,7 @@ fn trim_on_save_trims_the_written_bytes_before_later_callbacks() { fn trim_on_save_failure_never_vetoes_the_save() { let path = temp_path("veto-immune.txt"); std::fs::write(&path, "x \n").unwrap(); - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.editops.trim_on_save(true)"); exec( &s, @@ -1246,7 +1246,7 @@ fn trim_on_save_failure_never_vetoes_the_save() { fn trim_on_save_unexpected_error_reports_and_still_saves() { let path = temp_path("unexpected.txt"); std::fs::write(&path, "x \n").unwrap(); - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.editops.trim_on_save(true)"); // Capture the pmacs.error log (the m9_6 stub pattern — the // `if pmacs.error` branch is a no-op without it). @@ -1293,7 +1293,7 @@ fn trim_on_save_unexpected_error_reports_and_still_saves() { fn another_callbacks_veto_is_not_masked_by_trim() { let path = temp_path("veto.txt"); std::fs::write(&path, "x \n").unwrap(); - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.editops.trim_on_save(true)"); exec( &s, @@ -1318,3 +1318,10 @@ fn another_callbacks_veto_is_not_masked_by_trim() { ); let _ = std::fs::remove_file(&path); } + +// 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; diff --git a/tests/find_file_acceptance.rs b/tests/find_file_acceptance.rs index fb793e3..f290a3a 100644 --- a/tests/find_file_acceptance.rs +++ b/tests/find_file_acceptance.rs @@ -90,7 +90,7 @@ fn status(s: &EditorState) -> String { fn editor_in(dir: &std::path::Path) -> EditorState { let anchor = dir.join("anchor.txt"); std::fs::write(&anchor, b"anchor\n").expect("write anchor"); - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); let anchor_str = anchor.display().to_string(); exec( @@ -279,7 +279,7 @@ fn find_file_accepting_a_directory_reports_instead_of_raising() { /// stable, real candidate there. #[test] fn find_file_without_a_backing_path_roots_at_the_process_cwd() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.reopen_init_phase_for_testing(); assert!( active_path(&s).is_none(), @@ -352,7 +352,7 @@ fn find_file_expands_a_leading_tilde() { return; } - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.reopen_init_phase_for_testing(); open_prompt(&mut s); // Contains a '/', so the typed text reaches on_accept verbatim. @@ -371,3 +371,10 @@ fn find_file_expands_a_leading_tilde() { "the expansion must use $HOME; got {path} with HOME={home}" ); } + +// 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; diff --git a/tests/folding_acceptance.rs b/tests/folding_acceptance.rs index 6df069a..01de6a5 100644 --- a/tests/folding_acceptance.rs +++ b/tests/folding_acceptance.rs @@ -301,7 +301,7 @@ fn insert_into(s: &EditorState, id: BufferId, text: &str) { #[test] fn command_path_self_insert_unfolds_at_point() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let id = active_id(&s); insert_into(&s, id, "line0\nline1\nline2\nline3\n"); // Fold the interior of lines 1..2: [end of line0, end of line2]. @@ -330,7 +330,7 @@ fn self_insert_at_head_line_end_does_not_unfold() { // `(start, end]` containment: a self-insert exactly at the end of the // head line (== range.start) is outside the fold — it must NOT unfold, // and the translator shifts the fold right so the char lands visible. - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let id = active_id(&s); insert_into(&s, id, "line0\nline1\nline2\nline3\n"); let store = { @@ -389,7 +389,7 @@ fn install_rust_parse(s: &EditorState, id: BufferId) { #[test] fn folding_moves_point_to_head_line() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let id = active_id(&s); let src = "fn foo() {\n let x = 1;\n let y = 2;\n}\n"; insert_into(&s, id, src); @@ -413,7 +413,7 @@ fn folding_moves_point_to_head_line() { #[test] fn data_api_validation() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); // A plain document buffer with four lines. exec( &s, @@ -465,7 +465,7 @@ fn data_api_validation() { #[test] fn forget_drops_store_and_detaches_view() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let id = active_id(&s); insert_into(&s, id, "line0\nline1\nline2\n"); let store = { @@ -500,7 +500,7 @@ fn killing_a_buffer_through_the_real_path_purges_its_fold_store() { // assertion reads through the DEAD id on purpose: BufferIds are never // reused, so a stale id cannot alias a later buffer. Mirrors // config_registry's `killing_a_buffer_through_the_real_path_...`. - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, "b = pmacs.buffer.from_bytes('kill.rs', 'aaa\\nbbb\\nccc\\nddd\\n')", @@ -530,7 +530,7 @@ fn close_all_command_moves_point_to_enclosing_head() { // Q#FD3 through the command surface: `fold.close-all` is interactive, // so when it collapses a top-level fold around the invoking point, the // point moves to that fold's head line (Finding 3, round 1). - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let id = active_id(&s); let src = "fn first() {\n a();\n b();\n}\nfn second() {\n c();\n d();\n}\n"; insert_into(&s, id, src); @@ -553,7 +553,7 @@ fn close_all_command_moves_point_to_enclosing_head() { fn stale_parse_tree_refuses_fold() { // Q#FD10: an edit after the parse leaves `pending_edit_count() > 0`, // so a fold command refuses (the settled coordinates are stale). - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let id = active_id(&s); let src = "fn foo() {\n let x = 1;\n let y = 2;\n}\n"; insert_into(&s, id, src); @@ -571,7 +571,7 @@ fn stale_parse_tree_refuses_fold() { fn read_only_buffer_is_rejected() { // Q#FD11's "normal document buffer" guard: terminals are read-only, so // a read-only buffer is not foldable. - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, "b = pmacs.buffer.from_bytes('ro.rs', 'aaa\\nbbb\\nccc\\n')", @@ -592,7 +592,7 @@ fn read_only_buffer_is_rejected() { #[test] fn unfold_normalizes_an_arbitrary_range_to_a_stored_fold() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, "b = pmacs.buffer.from_bytes('doc.rs', 'aaa\\nbbb\\nccc\\nddd\\n')", @@ -608,3 +608,10 @@ fn unfold_normalizes_an_arbitrary_range_to_a_stored_fold() { let n: i64 = eval(&s, "return #pmacs.fold.folds(b)"); assert_eq!(n, 0); } + +// 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; diff --git a/tests/folding_stage2_acceptance.rs b/tests/folding_stage2_acceptance.rs index 79b41c8..473be53 100644 --- a/tests/folding_stage2_acceptance.rs +++ b/tests/folding_stage2_acceptance.rs @@ -61,7 +61,7 @@ fn end_of(line: usize) -> u64 { } fn editor() -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); s } @@ -1772,3 +1772,10 @@ fn wheel_over_an_inactive_unfolded_pane_uses_that_windows_map() { "the folded pane did not scroll" ); } + +// 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; diff --git a/tests/gpu_font_acceptance.rs b/tests/gpu_font_acceptance.rs index 264f25d..fe773ed 100644 --- a/tests/gpu_font_acceptance.rs +++ b/tests/gpu_font_acceptance.rs @@ -18,7 +18,8 @@ use pmacs::editor::EditorState; use pmacs::protocol::{ByteRange, FrontendId, InstanceMessage}; use pmacs::semantic_render::SemanticRenderState; -#[cfg(feature = "crdt")] +// Ungated: `common::iso` (isolated bootstrap roots) is needed in every +// build, not only the CRDT one. mod common; // --------------------------------------------------------------------------- @@ -43,7 +44,7 @@ fn eval(s: &EditorState, src: &str) -> T { /// Fresh editor with LSP spawning disabled. fn editor() -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); s } @@ -490,3 +491,11 @@ fn preference_set_before_attach_ships_on_the_first_frame() { "the first frame ships the pre-attach preference" ); } + +// 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. Re-exported rather than re-declared +// with `#[path]` — this file already pulls in `common`, and loading one +// source file as two modules is `clippy::duplicate_mod`. +use common::iso; diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 153ed6f..5a3bb88 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -38,6 +38,11 @@ fn non_crdt_root_rejects_gpu_before_socket_io_discovery_or_spawn() { .arg("--gpu") .env(TEST_GPU_OVERRIDE, &fake_gpu) .env("PMACS_TEST_MARKER", &marker) + .env("XDG_CONFIG_HOME", temp.path()) + .env("XDG_DATA_HOME", temp.path()) + .env("XDG_STATE_HOME", temp.path()) + .env("PMACS_STATE_HOME", temp.path()) + .env("XDG_CACHE_HOME", temp.path()) .env("XDG_RUNTIME_DIR", &runtime) .output() .expect("run non-CRDT pmacs --gpu"); @@ -61,6 +66,11 @@ fn non_crdt_root_rejects_gpu_before_socket_io_discovery_or_spawn() { .arg(&occupied_socket) .env(TEST_GPU_OVERRIDE, &fake_gpu) .env("PMACS_TEST_MARKER", &marker) + .env("XDG_CONFIG_HOME", temp.path()) + .env("XDG_DATA_HOME", temp.path()) + .env("XDG_STATE_HOME", temp.path()) + .env("PMACS_STATE_HOME", temp.path()) + .env("XDG_CACHE_HOME", temp.path()) .output() .expect("run non-CRDT pmacs --gpu against occupied socket"); assert!(!occupied.status.success()); diff --git a/tests/injection_acceptance.rs b/tests/injection_acceptance.rs index 88a314f..ac113e9 100644 --- a/tests/injection_acceptance.rs +++ b/tests/injection_acceptance.rs @@ -35,7 +35,7 @@ fn pump_async bool>(state: &mut EditorState, predicate: F /// resolver would leave the Lua write-through + snapshot bridge unproven. #[test] fn lua_alias_override_resolves_on_async_parse() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); // Add a bespoke fence alias from Lua (write-through to the registry). state @@ -98,7 +98,7 @@ fn lua_alias_override_resolves_on_async_parse() { /// alias discriminates the fix: with the empty map it would not resolve. #[test] fn sync_parse_now_resolves_alias() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let src = b"# Doc\n\n```py\nx = 1\n```\n"; let buf_id = state .lua_host @@ -136,7 +136,7 @@ fn sync_parse_now_resolves_alias() { /// the file drops below the cap and exceeds it again. #[test] fn injection_cap_surfaced_once_and_rearms_via_lua() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); // Capture pmacs.error messages into a Lua global. state .lua_host @@ -305,3 +305,10 @@ fn many_paragraph_settle_under_budget_with_tail_covered() { "the final paragraph still receives an inline layer (no tail loss)" ); } + +// 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; diff --git a/tests/journey_acceptance.rs b/tests/journey_acceptance.rs index db88942..a9529ac 100644 --- a/tests/journey_acceptance.rs +++ b/tests/journey_acceptance.rs @@ -209,6 +209,85 @@ fn launch(path: &Path) -> EditorState { s } +// --------------------------------------------------------------------------- +// Ambient-roots isolation +// --------------------------------------------------------------------------- +// +// This suite is the one place that must NOT take the +// `EditorState::new_with_roots` / `open_with_roots` seam +// (`docs/test-ambient-config-isolation-framing.md` §1.7). Its whole job +// is to prove the production entry point is wired: an `open` arm with no +// production caller passes every direct-call test, so the ratchet drives +// the same `EditorState::open` that `pmacs FILE` does — parameterless, +// ambient, exactly as production calls it. +// +// That leaves nothing in the process able to isolate it. Cargo launches +// each integration-test binary with the caller's environment, and a +// binary cannot re-point its own roots before its tests run: +// `std::env::set_var` is `unsafe` and pmacs is +// `#![forbid(unsafe_code)]`. "Isolated by the environment it is launched +// with" is not a mechanism — it is a hope that whoever typed `cargo +// test` wrapped it, which is the external workaround this lane exists to +// delete. +// +// So each test is a thin **parent** that re-execs this same binary for +// its own test name, with controlled roots in the child's environment. +// The child sees the marker, runs the real body, and calls the ambient +// entry point — production's call, against a controlled tree. + +/// Marker the parent sets on the child. Its presence, not its value, is +/// the signal. +const ISOLATED_CHILD: &str = "PMACS_JOURNEY_ISOLATED_CHILD"; + +/// Re-exec this test binary for `name` under controlled bootstrap +/// storage roots. +/// +/// Returns `true` in the **parent** — the child has already run and been +/// asserted, so the caller must return without doing anything itself — +/// and `false` in the **child**, whose job is to run the body. +#[must_use] +fn reexec_isolated(name: &str) -> bool { + if std::env::var_os(ISOLATED_CHILD).is_some() { + return false; + } + // Under `target/`, not `/tmp`: nothing unlinks this (libtest has no + // teardown hook), so it belongs somewhere `cargo clean` owns. + let base = Path::new(env!("CARGO_TARGET_TMPDIR")) + .join("journey-isolation") + .join(name); + let _ = std::fs::remove_dir_all(&base); + let roots = pmacs::bootstrap::BootstrapRoots::isolated_under(&base); + let child_env = roots.child_env(); + for (_, dir) in &child_env { + std::fs::create_dir_all(dir).expect("create controlled root"); + } + let exe = std::env::current_exe().expect("current test binary"); + let output = std::process::Command::new(exe) + .args(["--exact", name, "--nocapture", "--test-threads=1"]) + .env(ISOLATED_CHILD, "1") + .envs(child_env) + .output() + .unwrap_or_else(|e| panic!("re-exec `{name}`: {e}")); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "isolated child for `{name}` failed ({})\n--- child stdout ---\n{stdout}\ + \n--- child stderr ---\n{stderr}", + output.status + ); + // A filter that matches nothing exits 0. Without this the parent + // would pass while running no test at all — the failure mode a + // renamed test produces, and the one that would quietly hollow out + // the whole ratchet. + assert!( + stdout.contains("1 passed"), + "isolated child for `{name}` ran no test — the `--exact` filter went \ + stale against the function name\n--- child stdout ---\n{stdout}" + ); + true +} + // --------------------------------------------------------------------------- // Step 2 — launch unconfigured // --------------------------------------------------------------------------- @@ -216,6 +295,9 @@ fn launch(path: &Path) -> EditorState { /// **N** — the editor starts with no configuration and no arguments. #[test] fn journey_step2_launches_unconfigured_into_scratch() { + if reexec_isolated("journey_step2_launches_unconfigured_into_scratch") { + return; + } let s = EditorState::new(); assert_eq!(active_name(&s), "*scratch*"); assert!( @@ -236,6 +318,9 @@ fn journey_step2_launches_unconfigured_into_scratch() { /// `Err(EISDIR)` and `main` exited 1. #[test] fn journey_step3_opening_a_directory_lists_it() { + if reexec_isolated("journey_step3_opening_a_directory_lists_it") { + return; + } let td = project(); let s = launch(td.path()); @@ -259,6 +344,9 @@ fn journey_step3_opening_a_directory_lists_it() { /// the assertion above while `pmacs .` still printed a diagnostic. #[test] fn journey_step3_directory_startup_reports_no_error() { + if reexec_isolated("journey_step3_directory_startup_reports_no_error") { + return; + } let td = project(); let s = launch(td.path()); assert!( @@ -274,6 +362,9 @@ fn journey_step3_directory_startup_reports_no_error() { #[test] fn journey_step3_unreadable_directory_reports_without_failing_startup() { use std::os::unix::fs::PermissionsExt; + if reexec_isolated("journey_step3_unreadable_directory_reports_without_failing_startup") { + return; + } let td = tempfile::tempdir().expect("tempdir"); let locked = td.path().join("locked"); std::fs::create_dir(&locked).expect("mkdir"); @@ -301,6 +392,9 @@ fn journey_step3_unreadable_directory_reports_without_failing_startup() { /// because no buffer is created and `set_buffer_path` never runs. #[test] fn journey_directory_resolver_receives_a_canonical_path() { + if reexec_isolated("journey_directory_resolver_receives_a_canonical_path") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -331,6 +425,9 @@ fn journey_directory_resolver_receives_a_canonical_path() { /// fallback is a clearable slot rather than a builtin hook subscription. #[test] fn journey_unclaimed_directory_starts_successfully_with_a_status() { + if reexec_isolated("journey_unclaimed_directory_starts_successfully_with_a_status") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -360,6 +457,9 @@ fn journey_unclaimed_directory_starts_successfully_with_a_status() { /// a claim suppresses the fallback. #[test] fn journey_resolver_chain_is_first_claimant_wins() { + if reexec_isolated("journey_resolver_chain_is_first_claimant_wins") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -395,6 +495,9 @@ fn journey_resolver_chain_is_first_claimant_wins() { /// `proceed == false`. #[test] fn journey_a_raising_resolver_suppresses_the_fallback_and_reports() { + if reexec_isolated("journey_a_raising_resolver_suppresses_the_fallback_and_reports") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -458,6 +561,9 @@ const COMPETITOR: FrontendId = FrontendId(7); /// ambient display: the file then appears in the competitor's window. #[test] fn commit_to_delivers_to_the_requesting_frontend_not_the_ambient_one() { + if reexec_isolated("commit_to_delivers_to_the_requesting_frontend_not_the_ambient_one") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -519,6 +625,9 @@ fn commit_to_delivers_to_the_requesting_frontend_not_the_ambient_one() { /// `acting_frontend`, or by reordering it after the interactive origin. #[test] fn commit_to_outranks_an_interactive_origin() { + if reexec_isolated("commit_to_outranks_an_interactive_origin") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -586,6 +695,9 @@ fn commit_to_outranks_an_interactive_origin() { /// `display`, or by reading `prev` from the ambient window. #[test] fn a_background_open_uses_the_captured_window_not_the_selected_one() { + if reexec_isolated("a_background_open_uses_the_captured_window_not_the_selected_one") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -652,6 +764,9 @@ fn a_background_open_uses_the_captured_window_not_the_selected_one() { /// competitor and the assertion fails from the other direction). #[test] fn commit_to_scopes_and_restores_on_a_normal_return() { + if reexec_isolated("commit_to_scopes_and_restores_on_a_normal_return") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -701,6 +816,9 @@ fn commit_to_scopes_and_restores_on_a_normal_return() { /// through the scope, or by restoring on the success path only. #[test] fn commit_to_restores_when_the_callback_raises() { + if reexec_isolated("commit_to_restores_when_the_callback_raises") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -744,6 +862,9 @@ fn commit_to_restores_when_the_callback_raises() { /// ``. #[test] fn commit_to_refuses_an_await_and_restores() { + if reexec_isolated("commit_to_refuses_an_await_and_restores") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -795,6 +916,9 @@ fn commit_to_refuses_an_await_and_restores() { /// userdata after invoking the callback. #[test] fn commit_to_refuses_a_forged_destination() { + if reexec_isolated("commit_to_refuses_a_forged_destination") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -831,6 +955,9 @@ fn commit_to_refuses_a_forged_destination() { /// a shared mutable table. #[test] fn a_declining_listener_cannot_redirect_the_destination() { + if reexec_isolated("a_declining_listener_cannot_redirect_the_destination") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -907,6 +1034,9 @@ fn a_declining_listener_cannot_redirect_the_destination() { /// distinguish "validates" from "validates in time". #[test] fn preservation_a_failed_precondition_never_reaches_the_callback() { + if reexec_isolated("preservation_a_failed_precondition_never_reaches_the_callback") { + return; + } // (label, Lua that breaks the precondition, expected reason fragment) let cases: [(&str, &str, &str); 4] = [ ( @@ -994,6 +1124,9 @@ fn preservation_a_failed_precondition_never_reaches_the_callback() { /// (window-only validation). The dired buffer then replaces the user's. #[test] fn preservation_a_stale_destination_loses_to_the_users_newer_buffer() { + if reexec_isolated("preservation_a_stale_destination_loses_to_the_users_newer_buffer") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -1062,6 +1195,11 @@ fn preservation_a_stale_destination_loses_to_the_users_newer_buffer() { /// `*competitor*`. #[test] fn preservation_dired_captures_prev_from_the_destination_not_the_ambient_frontend() { + if reexec_isolated( + "preservation_dired_captures_prev_from_the_destination_not_the_ambient_frontend", + ) { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -1117,6 +1255,9 @@ fn preservation_dired_captures_prev_from_the_destination_not_the_ambient_fronten /// the read-only contract rather than pin the journey. #[test] fn journey_step5_editing_a_file_reached_through_the_directory() { + if reexec_isolated("journey_step5_editing_a_file_reached_through_the_directory") { + return; + } let td = project(); let mut s = launch(td.path()); assert!(active_name(&s).starts_with("*dired:")); @@ -1169,6 +1310,9 @@ fn journey_step5_editing_a_file_reached_through_the_directory() { /// preserve is which window shows the file, and that is what this pins. #[test] fn preservation_opening_a_file_shows_it_in_the_active_window() { + if reexec_isolated("preservation_opening_a_file_shows_it_in_the_active_window") { + return; + } let td = project(); let target = td.path().join("alpha.txt"); let s = EditorState::open(target.clone()).expect("open"); @@ -1204,6 +1348,9 @@ fn preservation_opening_a_file_shows_it_in_the_active_window() { /// failure mode is a hard error on a perfectly ordinary gesture. #[test] fn preservation_a_missing_path_becomes_a_new_file_buffer() { + if reexec_isolated("preservation_a_missing_path_becomes_a_new_file_buffer") { + return; + } let td = project(); let fresh = td.path().join("not-yet.txt"); let s = EditorState::open(fresh.clone()).expect("a missing path is not an error"); @@ -1223,6 +1370,9 @@ fn preservation_a_missing_path_becomes_a_new_file_buffer() { #[test] fn preservation_an_unreadable_file_reports_with_its_path() { use std::os::unix::fs::PermissionsExt; + if reexec_isolated("preservation_an_unreadable_file_reports_with_its_path") { + return; + } let td = project(); let locked = td.path().join("locked.txt"); std::fs::write(&locked, b"secret\n").expect("write"); @@ -1270,6 +1420,9 @@ fn preservation_an_unreadable_file_reports_with_its_path() { /// real accept path; this one pins the primitive and the window state. #[test] fn preservation_display_file_still_refuses_a_directory() { + if reexec_isolated("preservation_display_file_still_refuses_a_directory") { + return; + } let td = project(); let mut s = EditorState::open(td.path().join("alpha.txt")).expect("open"); exec(&s, "pmacs.lsp.config = {}"); @@ -1440,6 +1593,9 @@ fn walk_to_open_file(dir: &Path, name: &str) -> EditorState { /// `COHERENCE.md` says is missing. #[test] fn journey_step9_the_compile_chord_opens_the_prompt() { + if reexec_isolated("journey_step9_the_compile_chord_opens_the_prompt") { + return; + } let td = cargo_project(); let mut s = walk_to_open_file(td.path(), "Cargo.toml"); press_compile_chord(&mut s); @@ -1453,6 +1609,9 @@ fn journey_step9_the_compile_chord_opens_the_prompt() { /// **N** — the prompt is prefilled from the detected project kind. #[test] fn journey_step9_the_prompt_is_prefilled_for_a_cargo_project() { + if reexec_isolated("journey_step9_the_prompt_is_prefilled_for_a_cargo_project") { + return; + } let td = cargo_project(); let mut s = walk_to_open_file(td.path(), "Cargo.toml"); press_compile_chord(&mut s); @@ -1472,6 +1631,9 @@ fn journey_step9_the_prompt_is_prefilled_for_a_cargo_project() { /// which is what re-resolving at accept time looks like. #[test] fn journey_step9_the_prompt_runs_in_the_directory_it_captured() { + if reexec_isolated("journey_step9_the_prompt_runs_in_the_directory_it_captured") { + return; + } let a = cargo_project(); let b = project(); let mut s = walk_to_open_file(a.path(), "Cargo.toml"); @@ -1521,6 +1683,9 @@ fn journey_step9_the_prompt_runs_in_the_directory_it_captured() { /// real process. #[test] fn journey_step9_the_offered_command_builds_the_project() { + if reexec_isolated("journey_step9_the_offered_command_builds_the_project") { + return; + } if !binary_available("cargo") { assert!( std::env::var_os("PMACS_REQUIRE_CARGO_BUILD").is_none(), @@ -1574,6 +1739,9 @@ fn journey_step9_the_offered_command_builds_the_project() { /// from it yields `node` again and this pin stays green. #[test] fn journey_step9_a_nested_project_gets_its_own_kind_not_the_outer_one() { + if reexec_isolated("journey_step9_a_nested_project_gets_its_own_kind_not_the_outer_one") { + return; + } let outer = cargo_project(); let sub = outer.path().join("sub"); std::fs::create_dir_all(&sub).expect("mkdir sub"); @@ -1610,6 +1778,9 @@ fn journey_step9_a_nested_project_gets_its_own_kind_not_the_outer_one() { /// runner's cwd and pinning it would pin the environment. #[test] fn journey_step9_the_compile_context_is_total_even_with_no_file_open() { + if reexec_isolated("journey_step9_the_compile_context_is_total_even_with_no_file_open") { + return; + } let td = cargo_project(); let s = launch(td.path()); assert!( @@ -1638,6 +1809,9 @@ fn journey_step9_the_compile_context_is_total_even_with_no_file_open() { /// is reordering the precedence chain to put `defaults[kind]` first. #[test] fn journey_step9_preservation_the_last_command_outranks_the_default() { + if reexec_isolated("journey_step9_preservation_the_last_command_outranks_the_default") { + return; + } let td = cargo_project(); let mut s = walk_to_open_file(td.path(), "Cargo.toml"); exec(&s, "pmacs.compile.run('true')"); @@ -1660,6 +1834,9 @@ fn journey_step9_preservation_the_last_command_outranks_the_default() { /// over one of these sequences. #[test] fn journey_step9_preservation_the_existing_compile_bindings_survive() { + if reexec_isolated("journey_step9_preservation_the_existing_compile_bindings_survive") { + return; + } let td = cargo_project(); let s = walk_to_open_file(td.path(), "Cargo.toml"); for (sequence, command) in [ @@ -1705,6 +1882,9 @@ fn binary_available(name: &str) -> bool { #[cfg(unix)] #[test] fn journey_step9_the_compile_directory_is_detection_canonical() { + if reexec_isolated("journey_step9_the_compile_directory_is_detection_canonical") { + return; + } let parent = tempfile::tempdir().expect("tempdir"); let real = parent.path().join("real"); std::fs::create_dir_all(real.join("src")).expect("mkdir real"); @@ -1804,6 +1984,9 @@ fn start_local() -> EditorState { /// `prepare_startup` — the mutation every by-hand pin would survive. #[test] fn journey_step4_a_no_target_launch_greets_in_scratch() { + if reexec_isolated("journey_step4_a_no_target_launch_greets_in_scratch") { + return; + } let s = start_local(); // Preconditions asserted, not assumed (framing §3.2b): a developer @@ -1835,6 +2018,9 @@ fn journey_step4_a_no_target_launch_greets_in_scratch() { /// is two chords and nothing in the rendered text marks the boundary. #[test] fn journey_step4_every_advertised_key_is_bound() { + if reexec_isolated("journey_step4_every_advertised_key_is_bound") { + return; + } let s = start_local(); let entries = welcome_entries(&s); assert!( @@ -1858,6 +2044,9 @@ fn journey_step4_every_advertised_key_is_bound() { /// Pin 2 alone would pass if rendering silently dropped one. #[test] fn journey_step4_the_rendered_welcome_contains_every_entry() { + if reexec_isolated("journey_step4_the_rendered_welcome_contains_every_entry") { + return; + } let s = start_local(); let text = active_text(&s); for (keys, label) in welcome_entries(&s) { @@ -1884,6 +2073,9 @@ fn journey_step4_the_rendered_welcome_contains_every_entry() { /// so nothing about the accepted value survives afterwards. #[test] fn journey_step4_m_x_help_renders_the_cheat_sheet() { + if reexec_isolated("journey_step4_m_x_help_renders_the_cheat_sheet") { + return; + } let mut s = start_local(); s.dispatch_key( @@ -1922,6 +2114,9 @@ fn journey_step4_m_x_help_renders_the_cheat_sheet() { /// would lift read-only, discard history, and fail the insert. #[test] fn journey_step4_preservation_the_greeted_scratch_is_editable_and_clean() { + if reexec_isolated("journey_step4_preservation_the_greeted_scratch_is_editable_and_clean") { + return; + } let mut s = start_local(); assert!( eval::( @@ -1942,6 +2137,9 @@ fn journey_step4_preservation_the_greeted_scratch_is_editable_and_clean() { /// **P** — a file target does not greet. #[test] fn journey_step4_preservation_a_file_target_does_not_greet() { + if reexec_isolated("journey_step4_preservation_a_file_target_does_not_greet") { + return; + } let td = project(); let path = td.path().join("alpha.txt"); let s = match pmacs::editor::prepare_startup(Some(path.clone())).expect("startup") { @@ -1965,6 +2163,9 @@ fn journey_step4_preservation_a_file_target_does_not_greet() { /// still exists to be wrongly greeted. #[test] fn journey_step4_preservation_a_directory_target_does_not_greet() { + if reexec_isolated("journey_step4_preservation_a_directory_target_does_not_greet") { + return; + } let td = project(); let mut s = match pmacs::editor::prepare_startup(Some(td.path().to_path_buf())).expect("startup") { @@ -1979,6 +2180,9 @@ fn journey_step4_preservation_a_directory_target_does_not_greet() { /// **P** — a non-empty `*scratch*` is never overwritten. #[test] fn journey_step4_preservation_existing_scratch_content_survives() { + if reexec_isolated("journey_step4_preservation_existing_scratch_content_survives") { + return; + } let mut s = EditorState::new(); exec(&s, "pmacs.window.buffer():insert(0, 'user content')"); s.finalize_local_launch(false); @@ -1993,6 +2197,9 @@ fn journey_step4_preservation_existing_scratch_content_survives() { /// desktop restore having put something else in front. #[test] fn journey_step4_preservation_a_backgrounded_scratch_is_not_greeted() { + if reexec_isolated("journey_step4_preservation_a_backgrounded_scratch_is_not_greeted") { + return; + } let td = project(); let mut s = EditorState::new(); exec( @@ -2019,6 +2226,9 @@ fn journey_step4_preservation_a_backgrounded_scratch_is_not_greeted() { /// reached in production. #[test] fn journey_step4_preservation_constructors_never_greet() { + if reexec_isolated("journey_step4_preservation_constructors_never_greet") { + return; + } let bare = EditorState::new(); assert_eq!(scratch_text(&bare), "", "EditorState::new must not greet"); @@ -2078,6 +2288,9 @@ fn lsp_segment(s: &EditorState) -> Option { /// vacuous. #[test] fn journey_step6_a_missing_language_server_is_reported_not_swallowed() { + if reexec_isolated("journey_step6_a_missing_language_server_is_reported_not_swallowed") { + return; + } let td = tempfile::tempdir().expect("tempdir"); std::fs::write(td.path().join("Cargo.toml"), b"[package]\nname=\"x\"\n").expect("write toml"); std::fs::write(td.path().join("main.rs"), b"fn main() {}\n").expect("write rs"); @@ -2138,6 +2351,9 @@ fn journey_step6_a_missing_language_server_is_reported_not_swallowed() { #[test] fn journey_step4_the_welcome_paints_as_multiple_rows_on_the_first_frame() { use pmacs::cell::{Cell, CellGrid, CellSize, Glyph}; + if reexec_isolated("journey_step4_the_welcome_paints_as_multiple_rows_on_the_first_frame") { + return; + } let s = start_local(); let (rows, cols) = (12u32, 100u32); @@ -2192,3 +2408,86 @@ fn journey_step4_the_welcome_paints_as_multiple_rows_on_the_first_frame() { row_text(0) ); } + +// --------------------------------------------------------------------------- +// The isolation mechanism itself +// --------------------------------------------------------------------------- + +/// **N** — the re-exec of §1.10 actually redirects the child's roots. +/// +/// Every other test in this file is *protected* by the mechanism; none +/// of them fails if it silently stops working, because they assert about +/// editor behaviour and a developer's `init.lua` usually leaves that +/// alone. This one asserts the mechanism's own output: the roots the +/// child resolves, from inside the child. +/// +/// Falsified by dropping `.envs(child_env)` from `reexec_isolated` — the +/// child then resolves the developer's real `~/.config/pmacs` and +/// `~/.local/share`, and all four assertions below fail. +#[test] +fn journey_isolated_child_resolves_the_controlled_roots() { + if reexec_isolated("journey_isolated_child_resolves_the_controlled_roots") { + return; + } + let base = Path::new(env!("CARGO_TARGET_TMPDIR")) + .join("journey-isolation") + .join("journey_isolated_child_resolves_the_controlled_roots"); + let under = |p: Option, what: &str| { + let p = p.unwrap_or_else(|| panic!("{what} must resolve in the child")); + assert!( + p.starts_with(&base), + "{what} resolved to {} — outside the controlled base {}", + p.display(), + base.display() + ); + }; + under(pmacs::config::user_config_dir(), "the config dir"); + under( + Some(pmacs::builtin_packages::bundled_runtime_dir()), + "the bundled-package dir", + ); + // `PMACS_STATE_HOME` is the fifth variable: it outranks + // `XDG_STATE_HOME`, so a child given only the four XDG ones inherits + // whatever the launching shell exported. + under(pmacs::state::user_state_dir(), "the state dir"); + under(pmacs::minibuffer::user_history_dir(), "the history dir"); +} + +/// **N** — the ambient entry point is still what this suite drives. +/// +/// The isolation seam has a `new_with_roots` / `open_with_roots` sibling +/// that every other suite in the tree now takes. Taking it *here* would +/// be a silent regression of what the ratchet exists to prove — a +/// production entry point with no caller — and would leave every +/// assertion in this file green. Asserted against this file's own source +/// rather than left as an obvious-by-inspection property. +#[test] +fn journey_drives_the_ambient_production_entry_point() { + let src = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("journey_acceptance.rs"), + ) + .expect("read this suite's own source"); + // Every needle is assembled rather than written whole: a literal + // spelled out here would appear in this file and match itself, which + // turns the positive check vacuous and the negative ones into + // guaranteed failures. + let ambient_open = concat!("EditorState::open(", "path.to_path_buf())"); + let seam_open = concat!("EditorState::open", "_with_roots("); + let seam_new = concat!("EditorState::new", "_with_roots("); + assert!( + src.contains(ambient_open), + "journey must call the parameterless production `open`" + ); + assert!( + !src.contains(seam_open), + "journey must not take the isolation seam — its isolation comes \ + from the environment its child is launched with" + ); + assert!( + !src.contains(seam_new), + "journey must not take the isolation seam — its isolation comes \ + from the environment its child is launched with" + ); +} diff --git a/tests/kill_ring_acceptance.rs b/tests/kill_ring_acceptance.rs index 1a375ad..11cb3e3 100644 --- a/tests/kill_ring_acceptance.rs +++ b/tests/kill_ring_acceptance.rs @@ -74,7 +74,7 @@ fn status(s: &EditorState) -> String { /// Fresh editor whose scratch buffer holds `text`, cursor at 0. fn editor_with(text: &str) -> EditorState { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, text); exec(&s, "pmacs.editor.goto_byte(0)"); s @@ -855,3 +855,10 @@ fn frontend_detached_drops_per_frontend_state() { ); assert!(gone, "detach dropped B's killring state"); } + +// 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; diff --git a/tests/lean4_server_acceptance.rs b/tests/lean4_server_acceptance.rs index 86be1d5..113f886 100644 --- a/tests/lean4_server_acceptance.rs +++ b/tests/lean4_server_acceptance.rs @@ -90,7 +90,7 @@ impl Fixture { /// resolver. That combination is the point: the root rule under test is /// production code, only the command is a stand-in. fn editor(fx: &Fixture) -> EditorState { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, "pmacs.lsp.config = {}"); exec( &state, @@ -1880,3 +1880,10 @@ fn r6_no_swap_retires_only_the_failed_root() { swap occurred" ); } + +// 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; diff --git a/tests/lean4_stage1_acceptance.rs b/tests/lean4_stage1_acceptance.rs index 9aafcab..6a9c469 100644 --- a/tests/lean4_stage1_acceptance.rs +++ b/tests/lean4_stage1_acceptance.rs @@ -38,7 +38,7 @@ fn fresh_state_dir() -> PathBuf { } fn editor(state_dir: &std::path::Path) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host .lua() @@ -243,7 +243,7 @@ fn acc10b_the_prime_suffix_does_not_pair_in_lean() { /// WRITE-ONLY proxy (the canonical map lives Rust-side), so an /// alias-table read would prove nothing about what the parser does. fn markdown_layer_languages(src: &[u8]) -> Vec { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let buf_id = state .lua_host .registry() @@ -323,7 +323,7 @@ fn acc12_opening_lean_spawns_no_process_without_a_server_config() { // Constructing an editor touches no process, even though the Lean // config now exists and names `lake`. - let pristine = EditorState::new(); + let pristine = EditorState::new_with_roots(&crate::iso::roots()); let at_init: i64 = eval(&pristine, "return #pmacs.process.list()"); assert_eq!( at_init, 0, @@ -352,3 +352,10 @@ fn acc12_opening_lean_spawns_no_process_without_a_server_config() { "with no server configured, opening a Lean buffer spawns nothing" ); } + +// 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; diff --git a/tests/lean_input_acceptance.rs b/tests/lean_input_acceptance.rs index 9b6a3b8..825184d 100644 --- a/tests/lean_input_acceptance.rs +++ b/tests/lean_input_acceptance.rs @@ -72,7 +72,7 @@ fn lean_editor() -> (EditorState, PathBuf) { let dir = fresh_dir(); let f = dir.join("a.lean"); std::fs::write(&f, "").unwrap(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); let fd = f.display().to_string(); exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); @@ -543,7 +543,7 @@ fn no_abbreviation_state_is_opened_outside_a_lean_buffer() { let dir = fresh_dir(); let f = dir.join("a.rs"); std::fs::write(&f, "").unwrap(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); let fd = f.display().to_string(); exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); @@ -811,7 +811,7 @@ fn the_expansion_reaches_the_first_did_change() { let f = dir.join("a.lean"); std::fs::write(&f, "").unwrap(); - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host.lua().set_app_data(StateDir(dir.clone())); exec(&s, "pmacs.lsp.config = {}"); @@ -1018,3 +1018,10 @@ fn detaching_a_frontend_purges_only_its_own_pending_state() { "B's detachment purged B's entries and left A's record valid" ); } + +// 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; diff --git a/tests/listview_acceptance.rs b/tests/listview_acceptance.rs index 8c15629..d546f00 100644 --- a/tests/listview_acceptance.rs +++ b/tests/listview_acceptance.rs @@ -212,7 +212,7 @@ fn probe(s: &EditorState) -> (String, String, i64, Option) { #[test] fn open_seats_cursor_and_ret_visits_the_row() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); let (name, text, line, _) = probe(&s); assert_eq!(name, "*test-panel*"); @@ -227,7 +227,7 @@ fn open_seats_cursor_and_ret_visits_the_row() { #[test] fn header_row_is_not_visitable() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); press(&mut s, KeyCode::Char('p')); // up onto the header press(&mut s, KeyCode::Enter); @@ -237,7 +237,7 @@ fn header_row_is_not_visitable() { #[test] fn q_restores_the_previous_buffer() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); press(&mut s, KeyCode::Char('q')); let (name, _, _, _) = probe(&s); @@ -246,7 +246,7 @@ fn q_restores_the_previous_buffer() { #[test] fn panel_rejects_typing() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); let (_, before, _, _) = probe(&s); press(&mut s, KeyCode::Char('z')); // unbound printable → self-insert → intercept rejects @@ -258,7 +258,7 @@ fn panel_rejects_typing() { fn dispatch_idle_is_false_while_a_panel_is_focused() { // Q#P6: while the panel is the active buffer, semantic frontends // must round-trip every key (RET = visit, not an optimistic \n). - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); assert!(s.dispatch_idle(), "scratch buffer: idle"); open_test_panel(&mut s); assert!(!s.dispatch_idle(), "panel focused: keys must round-trip"); @@ -268,7 +268,7 @@ fn dispatch_idle_is_false_while_a_panel_is_focused() { #[test] fn refresh_reruns_the_source_and_reseats() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); press(&mut s, KeyCode::Char('g')); let (_, text, line, _) = probe(&s); @@ -304,7 +304,7 @@ const PANEL_TEXT: &str = "3 items RET visit q quit\nalpha\nbeta\ngamma"; /// consulting the intercept chain. #[test] fn s1_1_the_undo_chord_cannot_empty_a_listview_panel() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); assert_eq!(active_text(&s), PANEL_TEXT, "precondition: rendered"); @@ -327,7 +327,7 @@ fn s1_1_the_undo_chord_cannot_empty_a_listview_panel() { /// *Bite:* same empty result on the pre-image. #[test] fn s1_2_m_x_buffer_undo_cannot_empty_a_listview_panel() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); m_x(&mut s, "buffer.undo"); @@ -355,7 +355,7 @@ fn s1_2_m_x_buffer_undo_cannot_empty_a_listview_panel() { /// raising. #[test] fn s1_4_the_owners_refresh_still_works_after_the_lock() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); let panel = id_of(&s, "*test-panel*"); assert!( @@ -388,7 +388,7 @@ fn s1_4_the_owners_refresh_still_works_after_the_lock() { /// therefore passes the rope half and fails the lifted half. #[test] fn s1_5_the_rope_lock_and_named_intercept_refuse_in_order() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); let panel = id_of(&s, "*test-panel*"); let before = active_text(&s); @@ -453,7 +453,7 @@ fn s1_5_the_rope_lock_and_named_intercept_refuse_in_order() { /// pinned through `dispatch_idle_for` rather than through `read_only`. #[test] fn s1_6_round_trip_input_survives_the_adoption() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); // (a) the premise. @@ -498,7 +498,7 @@ fn s1_6_round_trip_input_survives_the_adoption() { /// the old line index live and this paint assertion bites. #[test] fn s1_7_a_shrinking_refresh_reaches_the_window() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); let painted = paint_active_window(&s, 6, 24); assert_eq!( @@ -540,7 +540,7 @@ fn s1_7_a_shrinking_refresh_reaches_the_window() { /// builtin/runtime/listview.lua` falsifies it. #[test] fn s1_9_a_foreign_buffer_with_the_panels_name_is_never_adopted() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, "FOREIGN = pmacs.buffer.create('*test-panel*')\n\ @@ -585,7 +585,7 @@ fn s1_9_a_foreign_buffer_with_the_panels_name_is_never_adopted() { /// created. #[test] fn s1_10_the_disambiguation_limit_raises_rather_than_adopting() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, "MINE = pmacs.buffer.create('*test-panel*')\n\ @@ -624,7 +624,7 @@ fn s1_10_the_disambiguation_limit_raises_rather_than_adopting() { /// command produced, never on "it did not raise". #[test] fn s1_11_a_disambiguated_panel_still_answers_ret_g_and_q() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, "FOREIGN = pmacs.buffer.create('*test-panel*')\n\ @@ -678,7 +678,7 @@ fn s1_11_a_disambiguated_panel_still_answers_ret_g_and_q() { /// the disambiguation and `q` lands back in `*test-panel*<2>`. #[test] fn s1_12_the_q_target_capture_is_not_inverted_across_two_panels() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, "FOREIGN = pmacs.buffer.create('*test-panel*')\n\ @@ -755,3 +755,10 @@ fn s1_14_no_bypass_write_or_name_keyed_identity_remains() { "every `panels[` subscript must be an append; found {subscripts:?}" ); } + +// 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; diff --git a/tests/lsp_dispatch_seams_acceptance.rs b/tests/lsp_dispatch_seams_acceptance.rs index f644367..531c4f3 100644 --- a/tests/lsp_dispatch_seams_acceptance.rs +++ b/tests/lsp_dispatch_seams_acceptance.rs @@ -36,7 +36,7 @@ fn fake_lsp_path() -> String { /// A fresh editor with the shipped language configs cleared, so the only /// server any test can spawn is the fake one it configures itself. fn editor() -> EditorState { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, "pmacs.lsp.config = {}"); state } @@ -722,3 +722,10 @@ fn acc34a_canonicalize_declines_a_non_utf8_resolution() { return a U+FFFD-substituted path that exists nowhere" ); } + +// 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; diff --git a/tests/lsp_multi_root_acceptance.rs b/tests/lsp_multi_root_acceptance.rs index 39ac68f..7a4c570 100644 --- a/tests/lsp_multi_root_acceptance.rs +++ b/tests/lsp_multi_root_acceptance.rs @@ -35,7 +35,7 @@ fn fake_lsp_path() -> String { /// A fresh editor with the shipped language configs cleared, so the only /// server any test can spawn is the fake one it configures itself. fn editor() -> EditorState { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, "pmacs.lsp.config = {}"); state } @@ -702,3 +702,10 @@ fn a_resolver_returning_nil_declines_silently() { file_uri(&fx.dir("proj")) ); } + +// 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; diff --git a/tests/lsp_spawn_guidance_acceptance.rs b/tests/lsp_spawn_guidance_acceptance.rs index fd48ace..6ac5b3c 100644 --- a/tests/lsp_spawn_guidance_acceptance.rs +++ b/tests/lsp_spawn_guidance_acceptance.rs @@ -89,7 +89,7 @@ fn open(state: &EditorState, path: &Path) { } fn editor_for(dir: &Path) -> EditorState { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); // Clamp detection so a stray marker above the tempdir cannot leak in. exec( &state, @@ -565,3 +565,10 @@ fn j1b2_preservation_a_spawnable_server_still_attaches() { ); assert_eq!(failure_count(&state), 0); } + +// 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; diff --git a/tests/m11_5_semantic_acceptance.rs b/tests/m11_5_semantic_acceptance.rs index 09a4e7e..761a0ba 100644 --- a/tests/m11_5_semantic_acceptance.rs +++ b/tests/m11_5_semantic_acceptance.rs @@ -129,7 +129,7 @@ fn assert_disjoint_within(ranges: &[ByteRange], vp: ByteRange) { #[test] fn incremental_reconstruction_equals_fresh_full_projection() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let buffer_id = active_buffer(&state); let vp1 = ByteRange { start: 0, end: 64 }; @@ -327,3 +327,11 @@ fn daemon_routes_semantic_family_to_semantic_session_only() { "grid session must NOT receive the semantic family" ); } + +// 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. Re-exported rather than re-declared +// with `#[path]` — this file already pulls in `common`, and loading one +// source file as two modules is `clippy::duplicate_mod`. +use common::iso; diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 64323e0..32511ff 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -258,7 +258,7 @@ fn m4_1_parse_tree_introspectable_via_lua() { use pmacs::editor::EditorState; use pmacs::lua_bindings::BufferIdLua; - let editor = EditorState::new(); + let editor = EditorState::new_with_roots(&crate::iso::roots()); // Insert a buffer with known shape; bind its handle into Lua as // `BUF` so the script can reference it by name. let buf_id = editor @@ -359,7 +359,8 @@ fn m4_2_opening_a_rust_file_produces_a_parse_tree() { let path = dir.path().join("hello.rs"); std::fs::write(&path, b"fn main() { let x = 1 + 2; }\n").expect("write"); - let mut state = pmacs::editor::EditorState::open(path).expect("open .rs"); + let mut state = + pmacs::editor::EditorState::open_with_roots(path, &crate::iso::roots()).expect("open .rs"); pump_async(&mut state, |s| current_tree_language(s).is_some()); assert_eq!(current_tree_language(&state).as_deref(), Some("rust")); @@ -389,7 +390,8 @@ fn m4_2_opening_a_lua_file_produces_a_parse_tree() { let path = dir.path().join("hello.lua"); std::fs::write(&path, b"local x = 1\nreturn x + 2\n").expect("write"); - let mut state = pmacs::editor::EditorState::open(path).expect("open .lua"); + let mut state = + pmacs::editor::EditorState::open_with_roots(path, &crate::iso::roots()).expect("open .lua"); pump_async(&mut state, |s| current_tree_language(s).is_some()); assert_eq!(current_tree_language(&state).as_deref(), Some("lua")); @@ -420,7 +422,8 @@ fn m4_2_register_extension_attaches_for_a_runtime_added_extension() { let path = dir.path().join("hello.myrust"); std::fs::write(&path, b"fn main() {}\n").expect("write"); - let mut state = pmacs::editor::EditorState::open(path).expect("open .myrust"); + let mut state = pmacs::editor::EditorState::open_with_roots(path, &crate::iso::roots()) + .expect("open .myrust"); // The auto-attach hook fires *during* open, but at that point our // custom extension isn't registered yet --- so the first hook // invocation is a no-op. Register the extension and trigger the @@ -516,7 +519,8 @@ fn render_active_window_to_grid( /// either the parse settles (so highlights can attach) or the /// timeout deadline hits. fn open_and_wait_for_parse(path: std::path::PathBuf) -> pmacs::editor::EditorState { - let mut state = pmacs::editor::EditorState::open(path).expect("open file"); + let mut state = + pmacs::editor::EditorState::open_with_roots(path, &crate::iso::roots()).expect("open file"); pump_async(&mut state, |s| current_tree_language(s).is_some()); state } @@ -1155,7 +1159,7 @@ fn m4_4_pty_mode_child_observes_a_tty() { /// to Lua" spec invariant for the process case. #[test] fn m4_4_lua_surface_drives_lifecycle() { - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let id_raw: i64 = state .lua_host .lua() @@ -1611,7 +1615,7 @@ fn m4_5_protocol_violation_surfaces_as_structured_error() { fn m4_5_lua_surface_drives_lsp_lifecycle() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); let lua = state.lua_host.lua(); let sid_raw: u64 = lua @@ -1846,7 +1850,7 @@ fn m4_6_diagnostic_source_field_is_preserved() { fn m4_6_lua_surface_reads_diagnostics() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); let lua = state.lua_host.lua(); let sid_raw: u64 = lua @@ -1958,7 +1962,7 @@ fn m4_6_lua_surface_reads_diagnostics() { fn m4_6_diag_navigate_commands_and_bindings_are_registered() { use pmacs::editor::EditorState; - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let commands: Vec = lua @@ -2028,7 +2032,7 @@ fn m4_6_diag_navigate_commands_and_bindings_are_registered() { fn m4_6_diag_attach_view_pushes_diagnostic_overlay() { use pmacs::editor::EditorState; - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let attached: bool = state .lua_host .lua() @@ -2260,7 +2264,7 @@ fn m4_7_signature_help_during_function_call() { fn m4_7_lua_surface_drives_completion_hover_signature() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); let lua = state.lua_host.lua(); let sid_raw: u64 = lua @@ -2581,7 +2585,7 @@ fn m4_8_status_buffer_text_reflects_state_and_capabilities() { fn m4_8_lua_surface_exposes_status() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); let lua = state.lua_host.lua(); let sid_raw: u64 = lua @@ -2761,7 +2765,7 @@ fn m4_9_project_switch_is_first_class() { touch_file(&d1.path().join("Cargo.toml")); touch_file(&d2.path().join(".luarc.json")); - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let d1_path = d1.path().display().to_string(); let d2_path = d2.path().display().to_string(); @@ -2817,7 +2821,7 @@ fn m4_9_lsp_runs_per_project_not_per_buffer() { touch_file(&d1.path().join("Cargo.toml")); touch_file(&d2.path().join("Cargo.toml")); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let d1_path = d1.path().display().to_string(); let d2_path = d2.path().display().to_string(); @@ -2929,7 +2933,7 @@ fn m4_10_index_persists_across_sessions() { // Session A: index a synthetic source file, save to disk. { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let saved_path: String = lua .load(format!( @@ -2957,7 +2961,7 @@ fn m4_10_index_persists_across_sessions() { // searches still find what session A indexed --- without ever // calling upsert_file again. let started = std::time::Instant::now(); - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let (file_count, symbol_count, hit_count, hit_name): (u64, u64, u64, String) = lua .load(format!( @@ -3063,7 +3067,7 @@ fn m4_10_lua_surface_drives_index() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path().display().to_string(); - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let (files_before, syms_before, hit_count, after_invalidate, generation_after): ( @@ -3111,7 +3115,7 @@ fn m4_10_lua_surface_ingests_lsp_workspace_symbol() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path().display().to_string(); - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); // The URI we feed has to be under the project root so the // resulting absolute path is deterministic. @@ -3160,7 +3164,7 @@ fn m4_10_lua_surface_ingests_lsp_workspace_symbol() { fn m4_11_multiple_sources_combine_without_duplicates() { use pmacs::editor::EditorState; - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let (parse_count, parse_source, total_count): (u64, String, u64) = lua @@ -3235,7 +3239,7 @@ fn m4_11_multiple_sources_combine_without_duplicates() { fn m4_11_source_priority_configurable() { use pmacs::editor::EditorState; - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let (winner_first, winner_second): (String, String) = lua @@ -3289,7 +3293,7 @@ fn m4_11_source_priority_configurable() { fn m4_11_custom_sources_from_lua() { use pmacs::editor::EditorState; - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let (custom_count_active, custom_label, custom_source, count_disabled, count_unregistered): ( @@ -3373,7 +3377,7 @@ fn m4_11_custom_sources_from_lua() { fn m4_11_snippets_surface_through_completion() { use pmacs::editor::EditorState; - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let (label, kind, insert_text, source): (String, String, String, String) = lua .load( @@ -3529,7 +3533,7 @@ fn m4_12_formatting_response_lands_in_store() { fn m4_12_lua_surface_drives_definition_and_formatting() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); // Spawn a server through the Lua surface. @@ -3667,7 +3671,7 @@ fn m4_12_cross_file_go_to_definition_and_jump_back() { let b_disp = b_path.display().to_string(); let b_uri = format!("file://{b_disp}"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); // Point the default `rust` server at the fake, in `defenv` mode, @@ -3787,7 +3791,7 @@ fn m4_13_rename_applies_cross_file_workspace_edit() { let b_disp = b_path.display().to_string(); let b_uri = format!("file://{b_disp}"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state @@ -3915,7 +3919,7 @@ fn m4_14_code_action_command_drives_apply_edit() { std::fs::write(&a_path, b"abcfooxyz\n___zzz\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state @@ -4028,7 +4032,7 @@ fn m4_15_workspace_edit_resource_ops_apply_in_order() { let created = dir.path().join("created.rs"); let b2 = dir.path().join("b2.rs"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state @@ -4131,7 +4135,7 @@ fn m4_15_workspace_edit_resource_ops_apply_in_order() { /// kinds, and `paddingRight`. #[test] fn m4_16_lua_surface_drives_inlay_hints() { - let mut s = pmacs::editor::EditorState::new(); + let mut s = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut s, Some("inlaybounds")); let uri = "file:///tmp/m4_16_inlay.rs"; @@ -4190,7 +4194,7 @@ fn m4_16_lua_surface_drives_inlay_hints() { /// the `token_type` index resolves to a name. #[test] fn m4_17_lua_surface_drives_semantic_tokens() { - let mut s = pmacs::editor::EditorState::new(); + let mut s = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut s, None); let uri = "file:///tmp/m4_17_sem.rs"; @@ -4261,7 +4265,7 @@ fn m4_18_inlay_hint_refresh_repulls_via_server_request() { std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4314,7 +4318,7 @@ fn m4_18b_inlay_hints_auto_pull_after_initialize() { std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4365,7 +4369,7 @@ fn m4_19_semantic_tokens_refresh_repulls_via_server_request() { std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4422,7 +4426,7 @@ fn arc1c_semantic_tokens_auto_pull_on_attach() { std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4466,7 +4470,7 @@ fn arc1c_semantic_tokens_repull_after_edit_flush() { std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4529,7 +4533,7 @@ fn arc1d_signature_help_auto_triggers_on_trigger_char() { std::fs::write(&a_path, b"\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4589,7 +4593,7 @@ fn arc1d_signature_help_does_not_trigger_on_ordinary_typing() { std::fs::write(&a_path, b"\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4648,7 +4652,7 @@ fn arc1c_range_only_server_is_served_range_requests() { std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4703,7 +4707,7 @@ fn arc1c_range_only_utf16_server_gets_converted_bounds() { std::fs::write(&a_path, "fn a() {}\nlet x = \u{e9}\u{e9};".as_bytes()).expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4752,7 +4756,7 @@ fn arc1d_signature_help_triggers_on_non_ascii_trigger_char() { std::fs::write(&a_path, b"\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4810,7 +4814,7 @@ fn arc1d_signature_help_ignores_non_typed_edits() { std::fs::write(&a_path, b"\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4881,7 +4885,7 @@ fn arc1c_full_only_server_repulls_via_full_not_delta() { std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4954,7 +4958,7 @@ fn arc1c_full_only_server_repulls_via_full_not_delta() { /// fake returns one token. #[test] fn m4_20_semantic_tokens_range() { - let mut s = pmacs::editor::EditorState::new(); + let mut s = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut s, None); let uri = "file:///tmp/m4_20_sem.rs"; @@ -4998,7 +5002,7 @@ fn m4_20_semantic_tokens_range() { /// `resultId` "rid-2". #[test] fn m4_21_semantic_tokens_full_then_delta() { - let mut s = pmacs::editor::EditorState::new(); + let mut s = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut s, None); let uri = "file:///tmp/m4_21_sem.rs"; @@ -5078,7 +5082,7 @@ fn m4_22_rename_prepare_gates_and_prefills() { std::fs::write(&a_path, b"abcfooxyz\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -5179,7 +5183,7 @@ fn m4_23_rename_prepare_refusal_aborts() { std::fs::write(&a_path, b"abcfooxyz\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -5272,7 +5276,7 @@ fn m4_24_workspace_did_change_watched_files() { let received = base.join(".received"); let foo_uri = format!("file://{}", base.join("foo.txt").display()); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -5351,7 +5355,7 @@ fn m4_24_workspace_did_change_watched_files() { fn m4_25_tier1_language_server_configs_and_filetypes() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let probe: mlua::Table = s .lua_host .lua() @@ -5455,7 +5459,7 @@ fn m4_26_auto_attach_roots_server_at_opened_files_project() { let go_file_disp = go_file.display().to_string(); let fake = fake_lsp_path(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); // Clamp the marker walk to the tempdir so a stray ancestor marker // (a developer's /tmp/.git, say) can't masquerade as the root. // Point the default `go` server at the fake in `rooturi` mode. @@ -5567,7 +5571,7 @@ fn m4_27_real_gopls_analyzes_module_via_auto_attach() { let go_file_disp = go_file.display().to_string(); let uri = format!("file://{go_file_disp}"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); // gopls handshake + workspace load is slow on a cold cache (30s). real_server_open_and_init(&mut state, "go", &gopls, &root_disp, &go_file_disp); @@ -5664,7 +5668,7 @@ fn m4_28_real_clangd_diagnostics_and_semantic_tokens_via_auto_attach() { let cpp_disp = cpp.display().to_string(); let uri = format!("file://{cpp_disp}"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); real_server_open_and_init(&mut state, "cpp", &clangd, &root_disp, &cpp_disp); // Fire a semantic-tokens request; diagnostics flow unsolicited @@ -5767,7 +5771,7 @@ fn m4_29_real_rust_analyzer_inlay_hints_via_auto_attach() { let file_disp = file.display().to_string(); let uri = format!("file://{file_disp}"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); real_server_open_and_init(&mut state, "rust", &rust_analyzer, &root_disp, &file_disp); // rust-analyzer only answers `textDocument/inlayHint` after it has @@ -5815,7 +5819,7 @@ fn m4_29_real_rust_analyzer_inlay_hints_via_auto_attach() { fn m4_12_default_bundle_wires_commands_and_keymaps() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let probe: mlua::Table = s .lua_host .lua() @@ -5870,7 +5874,7 @@ fn m4_12_default_bundle_wires_commands_and_keymaps() { fn m4_12_default_bundle_wires_cuda() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let probe: mlua::Table = s .lua_host .lua() @@ -5926,7 +5930,7 @@ fn m4_12_default_bundle_wires_cuda() { fn m4_12_default_bundle_wires_bash() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let probe: mlua::Table = s .lua_host .lua() @@ -5970,7 +5974,7 @@ fn m4_12_default_bundle_wires_bash() { #[test] fn m4_shebang_resolver_maps_interpreters() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let resolve = |first_line: &str| -> Option { s.lua_host .lua() @@ -6030,7 +6034,7 @@ fn m4_shebang_resolver_maps_interpreters() { #[test] fn m4_shebang_extensionless_script_resolves_bash() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let hook = dir.path().join("pre-commit"); // no extension std::fs::write(&hook, b"#!/bin/sh\nset -e\necho building\n").expect("write"); @@ -6071,7 +6075,7 @@ fn m4_shebang_extensionless_script_resolves_bash() { #[test] fn m4_shebang_does_not_override_extension() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let f = dir.path().join("tool.py"); std::fs::write(&f, b"#!/bin/sh\nprint('hi')\n").expect("write"); @@ -6116,7 +6120,7 @@ fn m4_shebang_does_not_override_extension() { #[test] fn m4_shebang_extensionless_grammarless_language_is_silent() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let f = dir.path().join("generate"); // no extension // `ruby` is deliberately grammarless (and serverless) — a language the @@ -6166,7 +6170,7 @@ fn m4_shebang_extensionless_grammarless_language_is_silent() { #[test] fn m4_shebang_edit_keeps_pinned_grammar() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let hook = dir.path().join("deploy"); // no extension std::fs::write(&hook, b"#!/bin/sh\necho one\n").expect("write"); @@ -6261,7 +6265,7 @@ fn m4_shebang_edit_keeps_pinned_grammar() { #[test] fn m4_modeline_overrides_extension_end_to_end() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let file = dir.path().join("misleading.py"); std::fs::write(&file, b"-- -*- mode: lua -*-\nprint('ok')\n").expect("write"); @@ -6299,7 +6303,7 @@ fn m4_modeline_overrides_extension_end_to_end() { #[test] fn m4_modeline_parser_matches_supported_emacs_and_vim_forms() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let resolve = |text: &str| -> Option { s.lua_host .lua() @@ -6344,7 +6348,7 @@ fn m4_modeline_parser_matches_supported_emacs_and_vim_forms() { #[test] fn m4_modeline_parser_enforces_boundaries_aliases_and_conflicts() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let resolve = |text: &str| -> Option { s.lua_host .lua() @@ -6428,7 +6432,7 @@ fn m4_modeline_parser_enforces_boundaries_aliases_and_conflicts() { #[test] fn m4_modeline_unknown_mode_is_quiet_and_parser_free() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let file = dir.path().join("notes.txt"); std::fs::write(&file, b"# vim:ft=prose:\nhello\n").expect("write"); @@ -6464,7 +6468,7 @@ fn m4_modeline_unknown_mode_is_quiet_and_parser_free() { #[test] fn m4_modeline_language_is_pinned_until_reopen() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let file = dir.path().join("mutable.txt"); let other = dir.path().join("other.txt"); @@ -6558,7 +6562,7 @@ fn m4_modeline_language_is_pinned_until_reopen() { #[test] fn m4_modeline_shared_resolver_preserves_pathless_lsp_guard() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let (syntax, lsp): (Option, Option) = s .lua_host .lua() @@ -6580,7 +6584,7 @@ fn m4_modeline_shared_resolver_preserves_pathless_lsp_guard() { #[test] fn m4_filename_map_resolves_special_files() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let probe: mlua::Table = s .lua_host .lua() @@ -6650,7 +6654,7 @@ fn m4_filename_map_resolves_special_files() { #[test] fn m4_filename_extensionless_dockerfile_highlights() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let f = dir.path().join("Dockerfile"); // no extension std::fs::write(&f, b"FROM alpine:3\nRUN apk add curl\n").expect("write"); @@ -6690,7 +6694,7 @@ fn m4_filename_extensionless_dockerfile_highlights() { #[test] fn m4_gap_grammars_align_with_lsp_configs() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); for (path, id) in [ ("app.py", "python"), ("srv.go", "go"), @@ -6733,7 +6737,7 @@ fn m4_gap_grammars_align_with_lsp_configs() { #[test] fn m4_json_yaml_lsp_configs_pin_command_and_sections() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let lua = s.lua_host.lua(); // json: the `@t1ckbase/vscode-langservers-extracted@2.0.2` binary @@ -6809,7 +6813,7 @@ fn m4_json_yaml_lsp_configs_pin_command_and_sections() { #[test] fn m4_5_initial_config_pushed_via_did_change_configuration() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); let dir = tempfile::tempdir().expect("tempdir"); let sink = dir.path().join("config.jsonl"); @@ -6895,7 +6899,7 @@ fn m4_real_json_provider_receives_config_and_reports_diagnostics() { let file_disp = file.display().to_string(); let uri = format!("file://{file_disp}"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state .lua_host .lua() @@ -6964,7 +6968,7 @@ fn m4_real_yaml_provider_pulls_config_and_reports_diagnostics() { let file_disp = file.display().to_string(); let uri = format!("file://{file_disp}"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state .lua_host .lua() @@ -7043,7 +7047,7 @@ fn m4_real_yaml_provider_pulls_config_and_reports_diagnostics() { fn m4_lua_bundle_debounces_did_change_per_keystroke() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); let dir = tempfile::TempDir::new().unwrap(); let file = dir.path().join("debounce.rs"); @@ -7144,12 +7148,12 @@ fn m4_lua_bundle_debounces_did_change_per_keystroke() { fn m4_12_default_bundle_after_load_robust_to_missing_server() { use pmacs::editor::EditorState; let _dir = tempfile::TempDir::new().unwrap(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); // Plain after-load with no buffer path attached — the hook should // run, find no language for path=nil, and exit silently. If it // throws, the *errors* buffer would record it; verify it doesn't. s.lua_host.hooks(); - let s2 = EditorState::new(); + let s2 = EditorState::new_with_roots(&crate::iso::roots()); drop(s); let saw_error: bool = s2 .lua_host @@ -7199,7 +7203,7 @@ fn project_set_search_boundary_clamps_lua_detect_call() { let f = workspace.join("src/main.rs"); std::fs::write(&f, b"").expect("touch file"); - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let workspace_str = workspace.display().to_string(); let f_str = f.display().to_string(); @@ -7234,7 +7238,7 @@ fn project_search_boundary_round_trips_via_lua() { let dir = tempfile::tempdir().expect("dir"); let dir_str = dir.path().display().to_string(); - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let (initial, after_set, after_clear): (Option, Option, Option) = lua @@ -7420,7 +7424,7 @@ fn spawn_lsp_and_init(state: &mut pmacs::editor::EditorState, mode: Option<&str> #[test] fn m4_5_await_completion_returns_result_and_populates_store() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, None); state .lua_host @@ -7456,7 +7460,7 @@ fn m4_5_await_completion_returns_result_and_populates_store() { #[test] fn m4_5_await_server_error_raises_failed() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, Some("error")); state .lua_host @@ -7496,7 +7500,7 @@ fn m4_5_await_server_error_raises_failed() { #[test] fn m4_5_await_cancelled_when_server_stops_mid_request() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, Some("silent")); state .lua_host @@ -7534,7 +7538,7 @@ fn m4_5_await_cancelled_when_server_stops_mid_request() { #[test] fn m4_5_await_times_out_against_silent_server() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, Some("silent")); state .lua_host @@ -7576,7 +7580,7 @@ fn m4_5_await_times_out_against_silent_server() { #[test] fn m4_5_await_superseded_request_is_cancelled() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, Some("silent")); state .lua_host @@ -7622,7 +7626,7 @@ fn m4_5_await_superseded_request_is_cancelled() { #[test] fn m4_5_await_resolves_same_frame_as_response_absorbed() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, None); state .lua_host @@ -7710,7 +7714,7 @@ fn m4_5_await_resolves_same_frame_as_response_absorbed() { #[test] fn m4_5_position_encoding_utf16_round_trips_non_ascii() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, Some("posecho")); state .lua_host @@ -7766,7 +7770,7 @@ fn m4_5_position_encoding_utf16_round_trips_non_ascii() { fn m4_5_utf16_rename_and_prepare_rename_convert_positions() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, Some("posecho")); let uri = "file:///tmp/m4_5_rename_utf16.rs"; state @@ -7806,7 +7810,7 @@ fn m4_5_utf16_rename_and_prepare_rename_convert_positions() { #[test] fn m4_5_workspace_configuration_answered_from_settings() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -7863,7 +7867,7 @@ fn m4_5_workspace_configuration_answered_from_settings() { #[test] fn m4_5_location_nav_requests_route_by_kind() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, None); state .lua_host @@ -7913,7 +7917,7 @@ fn m4_5_location_nav_requests_route_by_kind() { #[test] fn m4_5_symbols_and_highlight_round_trip() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, None); state .lua_host @@ -7977,7 +7981,7 @@ fn m4_5_symbols_and_highlight_round_trip() { /// Open `path` against the fake server and wait for initialization /// (shared bootstrap for the panel tests). fn open_against_fake(path: &std::path::Path) -> pmacs::editor::EditorState { - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -8223,7 +8227,7 @@ fn rd1_delete_refuses_when_a_bound_buffer_is_modified() { let f = dir.path().join("a.rs"); std::fs::write(&f, b"original\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &f); state .lua_host @@ -8265,7 +8269,7 @@ fn rd2_delete_still_succeeds_for_a_clean_open_buffer() { let f = dir.path().join("clean.rs"); std::fs::write(&f, b"untouched\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &f); let (ok, err) = rd_delete(&mut state, &f, ""); @@ -8310,7 +8314,7 @@ fn rd3_filesystem_failure_leaves_the_clean_buffer_intact() { let target = dir.path().join("target"); std::fs::write(&target, b"was a file\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &target); // The path changes type behind pmacs's back — the same class of @@ -8354,7 +8358,7 @@ fn rd4_on_removed_observes_the_path_already_gone() { std::fs::write(&f, b"bye\n").expect("write"); let p = f.display().to_string(); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &f); state .lua_host @@ -8399,7 +8403,7 @@ fn rd5_delete_from_inside_the_targets_own_intercept_refuses_before_disk() { std::fs::write(&f, b"busy\n").expect("write"); let p = f.display().to_string(); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &f); state .lua_host @@ -8455,7 +8459,7 @@ fn rd6_a_clean_first_match_cannot_hide_a_modified_duplicate() { std::fs::write(&f, b"shared\n").expect("write"); let p = f.display().to_string(); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); state .lua_host .lua() @@ -8493,7 +8497,7 @@ fn rd7_a_sibling_directory_sharing_a_name_prefix_does_not_block() { let outside = sibling.join("out.rs"); std::fs::write(&outside, b"out\n").expect("write out"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &outside); state .lua_host @@ -8527,7 +8531,7 @@ fn rd8_recursive_delete_refuses_for_a_modified_descendant() { let inner = nested.join("deep.rs"); std::fs::write(&inner, b"deep\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &inner); state .lua_host @@ -8575,7 +8579,7 @@ fn rd9_clean_recursive_delete_reconciles_descendants_through_both_phases() { let inner = tree.join("kept.rs"); std::fs::write(&inner, b"kept\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &inner); // Display it, so the phase-1 window redirect has something to do. state @@ -8629,7 +8633,7 @@ fn rd10_absent_plus_ignore_does_not_destroy_a_modified_buffer() { let f = dir.path().join("vanished.rs"); std::fs::write(&f, b"content\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &f); state .lua_host @@ -8684,7 +8688,7 @@ fn rd14_clean_duplicates_all_reconcile() { std::fs::write(&f, b"twin\n").expect("write"); let p = f.display().to_string(); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); state .lua_host .lua() @@ -8940,7 +8944,7 @@ fn rd11_absent_plus_ignore_succeeds_through_the_server_pump() { let victim = dir.path().join("victim.rs"); std::fs::write(&victim, b"content\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "delete", "uri": rd_uri(&victim), @@ -8991,7 +8995,7 @@ fn rd11a_present_plus_ignore_with_a_modified_buffer_is_refused() { let victim = dir.path().join("victim.rs"); std::fs::write(&victim, b"content\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "delete", "uri": rd_uri(&victim), @@ -9049,7 +9053,7 @@ fn rd11b_a_dangling_symlink_counts_as_present() { std::fs::write(&real, b"content\n").expect("write"); std::os::unix::fs::symlink(&real, &link).expect("symlink"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "delete", "uri": rd_uri(&link), @@ -9113,7 +9117,7 @@ fn rd11c_absent_without_ignore_refuses_before_the_earlier_op_runs() { let missing = dir.path().join("never-existed.rs"); std::fs::write(&a, b"abcfooxyz\n").expect("write a"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ rd_edit_op(&a, 0, 3, 6, "EDITED"), @@ -9167,7 +9171,7 @@ fn rd11d_an_unanswerable_stat_fails_closed_in_the_plan() { std::fs::write(¬_a_dir, b"I am a file\n").expect("write the would-be parent"); let target = not_a_dir.join("child.rs"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ rd_edit_op(&a, 0, 3, 6, "EDITED"), @@ -9223,7 +9227,7 @@ fn rd12a_edit_then_delete_answers_the_server_and_reports_partial_work() { let a = dir.path().join("a.rs"); std::fs::write(&a, b"abcfooxyz\n").expect("write a"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ rd_edit_op(&a, 0, 3, 6, "EDITED"), @@ -9276,7 +9280,7 @@ fn rd12b_rename_into_delete_answers_the_server_and_reports_partial_work() { let inside = tree.join("m.rs"); std::fs::write(&outside, b"content\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "rename", "oldUri": rd_uri(&outside), "newUri": rd_uri(&inside) }, @@ -9341,7 +9345,7 @@ fn rd13_the_refusal_answers_the_server_and_leaves_a_durable_trace() { let victim = dir.path().join("victim.rs"); std::fs::write(&victim, b"content\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "delete", "uri": rd_uri(&victim) } ] }); @@ -9411,7 +9415,7 @@ fn rd15_defensive_a_parse_failure_still_attempts_a_response() { let a = dir.path().join("a.rs"); std::fs::write(&a, b"abcfooxyz\n").expect("write a"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); // A plan that would otherwise succeed, so a response saying // `applied = false` can only have come from the parse stub. let plan = serde_json::json!({ @@ -9476,7 +9480,7 @@ fn rd18_non_recursive_delete_is_not_blocked_by_an_orphan_beneath_it() { let gone = tree.join("gone.rs"); std::fs::write(&gone, b"content\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &gone); state .lua_host @@ -9521,7 +9525,7 @@ fn rd19a_create_then_delete_is_not_refused_by_the_plan_time_preflight() { let transient = dir.path().join("transient.rs"); let witness = dir.path().join("witness.rs"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "create", "uri": rd_uri(&transient) }, @@ -9563,7 +9567,7 @@ fn rd19b_rename_then_delete_is_not_refused_by_the_plan_time_preflight() { let destination = dir.path().join("destination.rs"); std::fs::write(&source, b"moving\n").expect("write source"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "rename", "oldUri": rd_uri(&source), "newUri": rd_uri(&destination) }, @@ -9607,7 +9611,7 @@ fn rd19c_deferring_the_check_does_not_skip_it() { std::fs::write(&a, b"abcfooxyz\n").expect("write a"); let fresh = dir.path().join("fresh.rs"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "create", "uri": rd_uri(&fresh) }, @@ -9671,7 +9675,7 @@ fn rd20_the_user_facing_message_reports_partial_application() { let a = dir.path().join("a.rs"); std::fs::write(&a, b"abcfooxyz\n").expect("write a"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ rd_edit_op(&a, 0, 3, 6, "EDITED"), @@ -9753,7 +9757,7 @@ fn rd21_equivalent_dot_path_create_then_delete_is_not_preflight_refused() { "fixture: the URI spellings must differ" ); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "create", "uri": dot_uri }, @@ -9792,7 +9796,7 @@ fn rd22a_partial_edits_inside_one_item_are_reported_conservatively() { let target = dir.path().join("target.rs"); std::fs::write(&target, b"abcdef\n").expect("write target"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [{ "textDocument": { "uri": rd_uri(&target), "version": 1 }, @@ -9877,7 +9881,7 @@ fn rd22b_a_failing_resource_item_can_leave_filesystem_state() { "fixture: destination parent must start absent" ); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [{ "kind": "rename", @@ -9907,3 +9911,10 @@ fn rd22b_a_failing_resource_item_can_leave_filesystem_state() { "the response must not deny the directory left on disk: {reason:?}" ); } + +// 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; diff --git a/tests/m5_5_acceptance.rs b/tests/m5_5_acceptance.rs index 031e86b..47adb6b 100644 --- a/tests/m5_5_acceptance.rs +++ b/tests/m5_5_acceptance.rs @@ -253,6 +253,10 @@ fn second_daemon_same_socket_fails_clearly() { .arg(daemon_a.socket_path()) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdout(Stdio::null()) .stderr(Stdio::piped()) .output() diff --git a/tests/m5_7_acceptance.rs b/tests/m5_7_acceptance.rs index ce85591..24829c3 100644 --- a/tests/m5_7_acceptance.rs +++ b/tests/m5_7_acceptance.rs @@ -139,6 +139,10 @@ fn spawn_pmacs_daemon(socket_path: &Path) -> Child { .arg(socket_path) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() @@ -270,6 +274,10 @@ fn cli_attach_invokes_test_ssh_with_expected_argv() { .env(PMACS_TEST_SSH_BIN, &fake_ssh) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -323,6 +331,10 @@ fn cli_attach_with_user_and_instance_name_passes_through_dash_l_and_dash_dash_so .env(PMACS_TEST_SSH_BIN, &fake_ssh) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -361,6 +373,10 @@ fn spawn_bridge(socket: &Path, isolated_home: &Path) -> (Child, ChildStdin, Chil .arg(socket) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -466,6 +482,10 @@ fn cli_attach_with_missing_ssh_binary_surfaces_spawn_failure() { .env(PMACS_TEST_SSH_BIN, &missing_ssh) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) diff --git a/tests/m5_8_acceptance.rs b/tests/m5_8_acceptance.rs index f58c609..8bf2a43 100644 --- a/tests/m5_8_acceptance.rs +++ b/tests/m5_8_acceptance.rs @@ -151,6 +151,10 @@ fn handshake_retry_cap_fires_after_three_failed_handshakes() { .env(PMACS_TEST_BACKOFF_SCALE_MS, "1") .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -198,6 +202,10 @@ fn backoff_scaling_observable_in_wall_clock_runtime() { .env(PMACS_TEST_BACKOFF_SCALE_MS, scale_ms) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -278,6 +286,10 @@ fn ssh_stderr_from_handshake_attempts_reaches_user() { .env(PMACS_TEST_BACKOFF_SCALE_MS, "1") .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) diff --git a/tests/m5_perf_acceptance.rs b/tests/m5_perf_acceptance.rs index 12139b3..6d261d7 100644 --- a/tests/m5_perf_acceptance.rs +++ b/tests/m5_perf_acceptance.rs @@ -98,6 +98,10 @@ impl TestDaemon { .arg(&socket_path) .env("HOME", tempdir.path()) .env("XDG_CONFIG_HOME", tempdir.path()) + .env("XDG_DATA_HOME", tempdir.path()) + .env("XDG_STATE_HOME", tempdir.path()) + .env("PMACS_STATE_HOME", tempdir.path()) + .env("XDG_CACHE_HOME", tempdir.path()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() diff --git a/tests/m6_4_repl_acceptance.rs b/tests/m6_4_repl_acceptance.rs index 200dea0..9dbfce0 100644 --- a/tests/m6_4_repl_acceptance.rs +++ b/tests/m6_4_repl_acceptance.rs @@ -43,7 +43,7 @@ use pmacs::lua_bindings::BufferIdLua; /// translate them into typed Rust errors because the chunk's /// assertions are the test contract. fn run(chunk: &str) { - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); editor .lua_host .eval(Some("@m6_4_test"), chunk) @@ -53,7 +53,7 @@ fn run(chunk: &str) { /// Construct a fresh editor and return a captured value (for tests /// that want to do final assertions on the Rust side). fn run_returning(chunk: &str) -> T { - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); editor .lua_host .eval(Some("@m6_4_test"), chunk) @@ -438,7 +438,7 @@ fn m6_4_buffer_id_is_a_real_buffer_handle() { // The handle's `buffer_id()` returns a real BufferIdLua that // resolves through the registry. This locks in that the package // is built atop genuinely-public surface; no shadow APIs. - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); let returned: mlua::Value = editor .lua_host .eval( @@ -457,3 +457,10 @@ fn m6_4_buffer_id_is_a_real_buffer_handle() { let buf = r.get(id_lua.id()).expect("registered"); assert_eq!(buf.name(), "*handle*"); } + +// 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; diff --git a/tests/m6_5_repl_acceptance.rs b/tests/m6_5_repl_acceptance.rs index 44d6b95..99135b3 100644 --- a/tests/m6_5_repl_acceptance.rs +++ b/tests/m6_5_repl_acceptance.rs @@ -80,7 +80,7 @@ fn locate_shell(name: &str) -> Option { /// Construct a fresh editor and run the given Lua chunk against it. fn run(chunk: &str) { let _guard = pump_test_guard(); - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); editor .lua_host .eval(Some("@m6_5_test"), chunk) @@ -95,7 +95,7 @@ fn run(chunk: &str) { /// after-tick contract is exercised end-to-end. fn run_with_pump(setup_chunk: &str, predicate_chunk: &str, timeout_ms: u64) { let _guard = pump_test_guard(); - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); editor .lua_host .eval(Some("@m6_5_setup"), setup_chunk) @@ -528,3 +528,10 @@ fn m6_5_close_terminates_child_and_unregisters() { pmacs.hook.run("process.after-tick") "#); } + +// 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; diff --git a/tests/m6_7_scrollback_acceptance.rs b/tests/m6_7_scrollback_acceptance.rs index 2cc4dec..7c97f66 100644 --- a/tests/m6_7_scrollback_acceptance.rs +++ b/tests/m6_7_scrollback_acceptance.rs @@ -31,7 +31,7 @@ use pmacs::editor::EditorState; // --------------------------------------------------------------------------- fn run(chunk: &str) { - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); editor .lua_host .eval(Some("@m6_7_test"), chunk) @@ -341,3 +341,10 @@ fn m6_7_50_truncation_events_preserve_block_boundaries() { pmacs.repl.config.scrollback_bytes = 16 * 1024 * 1024 "#); } + +// 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; diff --git a/tests/m6_8_multi_repl_acceptance.rs b/tests/m6_8_multi_repl_acceptance.rs index 4bc482f..fbc9afc 100644 --- a/tests/m6_8_multi_repl_acceptance.rs +++ b/tests/m6_8_multi_repl_acceptance.rs @@ -180,7 +180,7 @@ fn m6_8_three_repls_render_independently() { let Some(lua) = locate_lua() else { return; }; - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); spawn_three_and_wait_running(&mut editor, &lua); // Write a unique marker through each REPL: `io.write("MARK_\n")` @@ -246,7 +246,7 @@ fn m6_8_three_repls_respond_independently() { let Some(lua) = locate_lua() else { return; }; - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); spawn_three_and_wait_running(&mut editor, &lua); // Type into h1, switch to h2, type into h2, etc. Each typed marker @@ -308,7 +308,7 @@ fn m6_8_close_one_does_not_affect_others() { let Some(lua) = locate_lua() else { return; }; - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); spawn_three_and_wait_running(&mut editor, &lua); // Capture h2's proc_id before close; Handle:close clears the @@ -375,7 +375,7 @@ fn m6_8_supervisor_reaps_all_children_across_cycles() { let Some(lua) = locate_lua() else { return; }; - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); // Baseline: list size before any spawning. The post-cycle list // size must equal this — no REPL processes left behind. @@ -480,7 +480,7 @@ fn m6_8_supervisor_reaps_all_children_across_cycles() { /// `pmacs.repl.create` (which the spawn path also calls). #[test] fn m6_8_repls_have_independent_parser_state() { - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); editor .lua_host .eval( @@ -505,7 +505,7 @@ fn m6_8_repls_have_independent_parser_state() { /// accidentally shared. #[test] fn m6_8_repls_have_independent_scrollback_state() { - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); editor .lua_host .eval( @@ -535,7 +535,7 @@ fn m6_8_buffer_scoped_bindings_route_to_active_buffer() { let Some(lua) = locate_lua() else { return; }; - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); let setup = format!( r#" _G.h1 = pmacs.repl.spawn {{ argv = {{ "{lua}", "-i" }} }} @@ -618,7 +618,7 @@ fn m6_8_after_tick_hook_drains_all_handles_per_tick() { let Some(lua) = locate_lua() else { return; }; - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); spawn_three_and_wait_running(&mut editor, &lua); // Capture pre-tick history_end on each handle. @@ -692,3 +692,10 @@ fn m6_8_after_tick_hook_drains_all_handles_per_tick() { .exec() .expect("teardown"); } + +// 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; diff --git a/tests/m6_perf_acceptance.rs b/tests/m6_perf_acceptance.rs index 5ca857b..094808f 100644 --- a/tests/m6_perf_acceptance.rs +++ b/tests/m6_perf_acceptance.rs @@ -278,7 +278,7 @@ fn m6_6_sustained_ingest_rate_meets_100mbps_gate() { ) .max(1); - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); // 100 chars + newline per line. The exact value is unimportant; // what matters is that `yes` blasts at a higher rate than pmacs's // ingest path, so pmacs is the bottleneck under measurement. @@ -389,7 +389,7 @@ fn m6_6_buffer_memory_stays_under_200mb_during_run() { baseline_rss as f64 / (1024.0 * 1024.0) ); - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); // 100-char line; the line content is unimportant for the ceiling // gate. Total target = 150 MB of bytes-into-history. let line = "a".repeat(100); @@ -526,7 +526,7 @@ fn m6_6_cancel_response_p99_under_100ms() { let delay_ms = MIN_DELAY_MS + (r % (max_delay_ms - MIN_DELAY_MS + 1)); let delay = Duration::from_millis(delay_ms); - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); let _ = spawn_repl(&mut editor, &["yes"]); wait_until_running(&mut editor); @@ -670,7 +670,7 @@ fn m6_7_scrollback_navigation_p99_under_16ms() { const TRIALS: usize = 1000; const P99_THRESHOLD: Duration = Duration::from_millis(16); - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); populate_scrollback(&mut editor, LINES); seek_cursor_to_middle(&mut editor, LINES); @@ -749,7 +749,7 @@ fn m6_7_scrollback_search_p99_under_100ms() { const TRIALS: usize = 1000; const P99_THRESHOLD: Duration = Duration::from_millis(100); - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); populate_scrollback(&mut editor, LINES); // Warmup: 100 searches at varied positions. Same trace-compilation @@ -815,3 +815,10 @@ fn m6_7_scrollback_search_p99_under_100ms() { p50={p50:?}, p90={p90:?}, max={max:?}" ); } + +// 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; diff --git a/tests/m7_11_acceptance.rs b/tests/m7_11_acceptance.rs index 5b574ba..03c904c 100644 --- a/tests/m7_11_acceptance.rs +++ b/tests/m7_11_acceptance.rs @@ -89,14 +89,14 @@ fn repl_manifest_declares_exports_and_pmacs_required() { // Bullet 2b: bootstrap loads through the package system // --------------------------------------------------------------------------- // -// EditorState::new() runs the M7.11 bootstrap that materializes +// `EditorState::new()` runs the M7.11 bootstrap that materializes // the bundled REPL and pushes its InstalledPackage record into // the roster. After that, `require("repl")` from Lua resolves // through the M7.7 searcher (not the legacy direct eval). #[test] fn editor_init_makes_repl_loadable_via_require() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let result: bool = state .lua_host .lua() @@ -202,3 +202,10 @@ fn materialize_all_produces_one_record_per_bundled_package() { assert!(repl.entry_path().exists()); let _ = std::fs::remove_dir_all(&tmp); } + +// 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; diff --git a/tests/m8_10_acceptance.rs b/tests/m8_10_acceptance.rs index 74cbc20..24f106d 100644 --- a/tests/m8_10_acceptance.rs +++ b/tests/m8_10_acceptance.rs @@ -45,7 +45,7 @@ fn outline_package_path() -> PathBuf { fn editor_with_outline() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1262,3 +1262,10 @@ fn outline_aggregate_empty_sources_rejected() { "error must mention non-empty; got: {msg}" ); } + +// 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; diff --git a/tests/m8_1_acceptance.rs b/tests/m8_1_acceptance.rs index 5e5b899..3d33f20 100644 --- a/tests/m8_1_acceptance.rs +++ b/tests/m8_1_acceptance.rs @@ -36,7 +36,7 @@ fn pump_until bool>(state: &mut EditorState, predicate: F /// 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() + EditorState::new_with_roots(&crate::iso::roots()) } /// Run a Lua chunk to completion (no `:await`); returns the chunk's @@ -609,3 +609,10 @@ fn fs_watch_reports_file_change_and_can_cancel() { .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; diff --git a/tests/m8_2_acceptance.rs b/tests/m8_2_acceptance.rs index c61abdb..a7e7e63 100644 --- a/tests/m8_2_acceptance.rs +++ b/tests/m8_2_acceptance.rs @@ -80,7 +80,7 @@ fn pump_until bool>(state: &mut EditorState, predicate: F 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(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1251,3 +1251,10 @@ fn dired_source_size_under_audit_ceiling() { "M8.2 spec: dired source under 1500 lines; got {lines}" ); } + +// 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; diff --git a/tests/m8_3_acceptance.rs b/tests/m8_3_acceptance.rs index 7706efd..742cec1 100644 --- a/tests/m8_3_acceptance.rs +++ b/tests/m8_3_acceptance.rs @@ -44,7 +44,7 @@ fn pump_until bool>(state: &mut EditorState, predicate: F 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(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -2101,3 +2101,10 @@ fn dired_active_handle_is_cleared_when_active_buffer_removed() { "active_handle must not return a stale handle after its buffer is removed" ); } + +// 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; diff --git a/tests/m8_5_acceptance.rs b/tests/m8_5_acceptance.rs index ecba842..2fac268 100644 --- a/tests/m8_5_acceptance.rs +++ b/tests/m8_5_acceptance.rs @@ -52,7 +52,7 @@ fn pump_until bool>(state: &mut EditorState, predicate: F fn editor_with_magit() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -913,3 +913,10 @@ fn magit_cursor_on_hidden_descendant_reseats_to_visible_ancestor() { "cursor on hidden A1 must reseat to A's header (line 0)" ); } + +// 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; diff --git a/tests/m8_6_acceptance.rs b/tests/m8_6_acceptance.rs index 4ddcffe..e9d8baf 100644 --- a/tests/m8_6_acceptance.rs +++ b/tests/m8_6_acceptance.rs @@ -64,7 +64,7 @@ fn pump_until_with_deadline bool>( fn editor_with_magit() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -653,3 +653,10 @@ fn magit_build_spec_section_ids_are_stable_canonical_set() { .expect("build_spec ids"); assert_eq!(ids, "working,staged,log,branches,stashes"); } + +// 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; diff --git a/tests/m8_7_acceptance.rs b/tests/m8_7_acceptance.rs index fb3cd88..6990557 100644 --- a/tests/m8_7_acceptance.rs +++ b/tests/m8_7_acceptance.rs @@ -62,7 +62,7 @@ fn pump_until_with_deadline bool>( fn editor_with_magit() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1011,3 +1011,10 @@ fn magit_b_c_keybinding_dispatches_to_branch_create_prompt() { .eval(Some("cancel"), "pmacs.minibuffer.cancel()") .expect("cancel"); } + +// 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; diff --git a/tests/m8_9_acceptance.rs b/tests/m8_9_acceptance.rs index 11834a6..2877b23 100644 --- a/tests/m8_9_acceptance.rs +++ b/tests/m8_9_acceptance.rs @@ -45,7 +45,7 @@ fn outline_package_path() -> PathBuf { fn editor_with_outline() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1090,3 +1090,10 @@ fn outline_parser_unit_parses_org_subset() { .expect("tags"); assert_eq!(n_tags, 2); } + +// 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; diff --git a/tests/m9_1_acceptance.rs b/tests/m9_1_acceptance.rs index 47c8a59..a7eb45d 100644 --- a/tests/m9_1_acceptance.rs +++ b/tests/m9_1_acceptance.rs @@ -725,7 +725,7 @@ fn m9_1_oncrash_policy_does_not_restart_on_clean_exit() { fn m9_1_lua_surface_drives_mcp_lifecycle() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_mcp_path(); let lua = state.lua_host.lua(); let sid_raw: u64 = lua @@ -815,7 +815,7 @@ fn m9_1_lua_surface_drives_mcp_lifecycle() { fn m9_1_lua_send_request_returns_awaitable_handle() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_mcp_path(); let lua = state.lua_host.lua(); @@ -924,3 +924,10 @@ fn m9_1_lua_send_request_returns_awaitable_handle() { .load("pmacs.mcp.stop(_G._mcp_test_server)") .exec(); } + +// 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; diff --git a/tests/m9_2_acceptance.rs b/tests/m9_2_acceptance.rs index 06338c9..e0f5437 100644 --- a/tests/m9_2_acceptance.rs +++ b/tests/m9_2_acceptance.rs @@ -587,7 +587,7 @@ fn m9_2_cancelled_sibling_wins_over_queued_response() { fn m9_2_lua_read_resource_returns_awaitable_handle() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_mcp_path(); state @@ -738,3 +738,10 @@ fn m9_2_lua_read_resource_returns_awaitable_handle() { .load("pmacs.mcp.stop(_G._mcp_test_server)") .exec(); } + +// 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; diff --git a/tests/m9_3_acceptance.rs b/tests/m9_3_acceptance.rs index aadf606..cf020ec 100644 --- a/tests/m9_3_acceptance.rs +++ b/tests/m9_3_acceptance.rs @@ -332,7 +332,7 @@ fn m9_3_cancellation_reaches_server() { fn m9_3_lua_invoke_tool_returns_awaitable_handle() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_mcp_path(); state @@ -496,3 +496,10 @@ fn m9_3_lua_invoke_tool_returns_awaitable_handle() { .load("pmacs.mcp.stop(_G._mcp_test_server)") .exec(); } + +// 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; diff --git a/tests/m9_4_acceptance.rs b/tests/m9_4_acceptance.rs index 8474db1..43e2de3 100644 --- a/tests/m9_4_acceptance.rs +++ b/tests/m9_4_acceptance.rs @@ -317,7 +317,7 @@ fn m9_4_no_args_wire_shape_is_empty_object() { fn m9_4_lua_get_prompt_returns_awaitable_handle() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_mcp_path(); state @@ -494,3 +494,10 @@ fn m9_4_lua_get_prompt_returns_awaitable_handle() { .load("pmacs.mcp.stop(_G._mcp_test_server)") .exec(); } + +// 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; diff --git a/tests/m9_5_acceptance.rs b/tests/m9_5_acceptance.rs index 8831935..c0e44cc 100644 --- a/tests/m9_5_acceptance.rs +++ b/tests/m9_5_acceptance.rs @@ -51,7 +51,7 @@ fn resources_package_path() -> PathBuf { fn editor_with_resources() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1275,3 +1275,10 @@ fn m9_5_stale_buffer_recovers_on_reopen_after_restart() { "after re-open, the buffer must have fresh content; got {reopen_body:?}" ); } + +// 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; diff --git a/tests/m9_6_acceptance.rs b/tests/m9_6_acceptance.rs index 4b5166d..ee3eac9 100644 --- a/tests/m9_6_acceptance.rs +++ b/tests/m9_6_acceptance.rs @@ -48,7 +48,7 @@ fn tools_package_path() -> PathBuf { fn editor_with_tools() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1532,3 +1532,10 @@ fn m9_6_editor_describe_command_unknown_name_status_only() { "*help* must not be created for an unknown-name describe-command call" ); } + +// 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; diff --git a/tests/m9_7_acceptance.rs b/tests/m9_7_acceptance.rs index 456ad97..63d88eb 100644 --- a/tests/m9_7_acceptance.rs +++ b/tests/m9_7_acceptance.rs @@ -74,7 +74,7 @@ fn prompts_package_path() -> PathBuf { fn editor_with_prompts() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1386,3 +1386,10 @@ fn m9_7_prompt_hash_includes_required_argument_order() { "reordering required args must change the prompt hash so reconcile re-registers" ); } + +// 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; diff --git a/tests/m9_8_acceptance.rs b/tests/m9_8_acceptance.rs index 06ed3a3..0b418e8 100644 --- a/tests/m9_8_acceptance.rs +++ b/tests/m9_8_acceptance.rs @@ -56,7 +56,7 @@ fn prompts_package_path() -> PathBuf { fn editor_with_ai() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1122,3 +1122,10 @@ fn m9_8_configured_prompt_missing_on_server_surfaces_error() { state.core.borrow().status ); } + +// 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; diff --git a/tests/overlay_reattach_acceptance.rs b/tests/overlay_reattach_acceptance.rs index 48eafbb..efd57f8 100644 --- a/tests/overlay_reattach_acceptance.rs +++ b/tests/overlay_reattach_acceptance.rs @@ -41,7 +41,7 @@ fn count_of(kinds: &[String], kind: &str) -> usize { #[test] fn switch_away_and_back_reattaches_syntax_overlay_exactly_once() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let path = open_probe_file(&s); s.lua_host .lua() @@ -86,7 +86,7 @@ fn switch_away_and_back_reattaches_syntax_overlay_exactly_once() { #[test] fn panel_quit_restores_overlays_on_the_source_buffer() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let path = open_probe_file(&s); s.lua_host .lua() @@ -121,3 +121,10 @@ fn panel_quit_restores_overlays_on_the_source_buffer() { "leaving a panel restores the source buffer's styling (got {after:?})" ); } + +// 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; diff --git a/tests/persistence_acceptance.rs b/tests/persistence_acceptance.rs index c3a6c9d..3045709 100644 --- a/tests/persistence_acceptance.rs +++ b/tests/persistence_acceptance.rs @@ -26,7 +26,7 @@ fn editor_with_state_dir() -> (EditorState, PathBuf) { SEQ.fetch_add(1, Ordering::Relaxed) )); std::fs::create_dir_all(&dir).expect("mk state tempdir"); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); // Override whatever startup configured with our tempdir. s.lua_host.lua().remove_app_data::(); s.lua_host.lua().set_app_data(StateDir(dir.clone())); @@ -73,12 +73,12 @@ fn state_round_trips_and_rejects_escapes() { #[test] fn state_is_inert_when_unconfigured() { - // A plain EditorState::new() must NOT configure a state dir — that + // A plain `EditorState::new()` must NOT configure a state dir — that // is what keeps the whole integration-test suite (which links the // lib without cfg(test)) from writing to a developer's real // ~/.local/state/pmacs. Only the real entry points call // install_state_dirs(); tests construct EditorState directly. - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); assert!( s.lua_host.lua().app_data_ref::().is_none(), "new() must leave the state dir unconfigured" @@ -182,3 +182,10 @@ fn saveplace_can_be_disabled() { assert_eq!(cursor, 0, "disabled saveplace leaves the cursor at the top"); std::fs::remove_dir_all(&dir).ok(); } + +// Isolated bootstrap storage roots (see the module docs): an +// integration test is compiled without `cfg(test)`, so a raw +// `EditorState::new()` would read the developer's real `init.lua` and +// write into their real data root. +#[path = "common/iso.rs"] +mod iso; diff --git a/tests/query_replace_acceptance.rs b/tests/query_replace_acceptance.rs index 4fa69b3..a36ba56 100644 --- a/tests/query_replace_acceptance.rs +++ b/tests/query_replace_acceptance.rs @@ -85,7 +85,7 @@ fn start_query_replace(s: &mut EditorState, from: &str, to: &str, regex: bool) { #[test] fn replace_skip_and_quit_is_selective() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "x x x x"); // Cursor to buffer start so all four are ahead of point. goto_start(&s); @@ -107,7 +107,7 @@ fn replace_skip_and_quit_is_selective() { #[test] fn bang_replaces_all_remaining() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a a a a"); goto_start(&s); start_query_replace(&mut s, "a", "b", false); @@ -119,7 +119,7 @@ fn bang_replaces_all_remaining() { #[test] fn dot_replaces_current_then_quits() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a a a"); goto_start(&s); start_query_replace(&mut s, "a", "z", false); @@ -133,7 +133,7 @@ fn dot_replaces_current_then_quits() { fn growing_replacement_does_not_loop() { // a → aa must not re-match the inserted text (offset-shift + the // search-forward-past-replacement rule). - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a a a"); goto_start(&s); start_query_replace(&mut s, "a", "aa", false); @@ -144,7 +144,7 @@ fn growing_replacement_does_not_loop() { #[test] fn empty_to_deletes() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a-b-c"); goto_start(&s); start_query_replace(&mut s, "-", "", false); @@ -155,7 +155,7 @@ fn empty_to_deletes() { #[test] fn regex_query_replace_via_binding() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a1 b2 c3"); goto_start(&s); start_query_replace(&mut s, "[0-9]", "#", true); @@ -167,7 +167,7 @@ fn regex_query_replace_via_binding() { #[test] fn m_percent_binding_starts_query_replace() { // The literal chord: M-% (Alt + Shift+5 → Char('%') with ALT). - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "cat cat"); goto_start(&s); s.dispatch_key( @@ -196,7 +196,7 @@ fn m_percent_binding_starts_query_replace() { fn c_m_percent_binding_starts_regexp_query_replace() { // Control-meta-shifted punctuation — the chord most likely to parse // differently across key paths (the C-c H lesson). - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "x1 x2"); goto_start(&s); s.dispatch_key( @@ -224,7 +224,7 @@ fn c_m_percent_binding_starts_regexp_query_replace() { #[test] fn nothing_matched_leaves_buffer_untouched() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello"); start_query_replace(&mut s, "zzz", "q", false); let (text, active) = probe(&s); @@ -236,7 +236,7 @@ fn nothing_matched_leaves_buffer_untouched() { fn query_replace_flips_dispatch_idle_so_gpu_round_trips() { // While the interactive phase runs, dispatch_idle must be false so a // semantic frontend round-trips y/n/etc. instead of self-inserting. - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a a"); goto_start(&s); start_query_replace(&mut s, "a", "b", false); @@ -251,7 +251,7 @@ fn query_replace_flips_dispatch_idle_so_gpu_round_trips() { #[test] fn replace_fires_after_edit_hook() { // The Q#QR1 hook: an LSP/syntax observer must see replaced text. - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host .lua() .load( @@ -289,7 +289,7 @@ fn bang_fires_after_edit_hook_once_for_the_batch() { // Q#QR1: `!` applies many replacements under one keypress, but the // debounced didChange wants a single after-edit — the shadow // compares revision once across the whole handler. - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host .lua() .load( @@ -322,7 +322,7 @@ fn bang_fires_after_edit_hook_once_for_the_batch() { fn quit_via_ret_and_esc_keeps_replacements() { // Q#QR10: RET and Esc both quit (keeping replacements), not just q. for quit in [KeyCode::Enter, KeyCode::Esc] { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a a a"); goto_start(&s); start_query_replace(&mut s, "a", "b", false); @@ -337,7 +337,7 @@ fn quit_via_ret_and_esc_keeps_replacements() { #[test] fn ctrl_g_quits_keeping_replacements() { // Q#QR10: C-g exits and KEEPS replacements (unlike isearch's C-g). - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a a a"); goto_start(&s); start_query_replace(&mut s, "a", "b", false); @@ -353,7 +353,7 @@ fn ctrl_g_quits_keeping_replacements() { #[test] fn del_key_skips_like_n() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a a a"); goto_start(&s); start_query_replace(&mut s, "a", "b", false); @@ -373,7 +373,7 @@ fn focus_drift_mid_session_aborts_without_touching_either_buffer() { // (simulated by switch_buffer, which the pointer path also uses) // while query-replace is active. The next y must abort, not apply // the origin-buffer match to the now-active unrelated buffer. - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "foo foo"); goto_start(&s); start_query_replace(&mut s, "foo", "bar", false); @@ -416,3 +416,10 @@ fn focus_drift_mid_session_aborts_without_touching_either_buffer() { "origin buffer untouched by the aborted replace" ); } + +// 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; diff --git a/tests/resource_reconciliation_acceptance.rs b/tests/resource_reconciliation_acceptance.rs index c9beba4..6a00681 100644 --- a/tests/resource_reconciliation_acceptance.rs +++ b/tests/resource_reconciliation_acceptance.rs @@ -113,7 +113,7 @@ impl Fixture { } fn editor() -> EditorState { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); // No language server may spawn from these fixtures. The LSP rows // that DO want one configure it explicitly. exec(&state, "pmacs.lsp.config = {}"); @@ -2043,3 +2043,10 @@ fn a_subscriber_reconciliation_failure_is_reported_and_the_rest_still_reconcile( unreconciled — the raise has to come after the loop, not inside it" ); } + +// 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; diff --git a/tests/save_clobber_guard_acceptance.rs b/tests/save_clobber_guard_acceptance.rs index f1700c7..da177b7 100644 --- a/tests/save_clobber_guard_acceptance.rs +++ b/tests/save_clobber_guard_acceptance.rs @@ -57,7 +57,7 @@ fn save_refuses_to_clobber_a_file_changed_on_disk() { write(&f, "original\n"); let fs = f.display().to_string(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); open_and_dirty(&s, &fs); // Another writer lands between our read and our save. @@ -88,7 +88,7 @@ fn the_save_command_does_not_fire_after_save_when_it_refuses() { write(&f, "original\n"); let fs = f.display().to_string(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); open_and_dirty(&s, &fs); exec( &s, @@ -110,7 +110,7 @@ fn save_anyway_overwrites_deliberately_and_resyncs_meta() { write(&f, "original\n"); let fs = f.display().to_string(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); open_and_dirty(&s, &fs); write(&f, "theirs\n"); assert!(!eval::(&s, "return pmacs.editor.save()")); @@ -137,7 +137,7 @@ fn an_unchanged_file_saves_normally_and_repeatedly() { write(&f, "original\n"); let fs = f.display().to_string(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); open_and_dirty(&s, &fs); assert!(eval::(&s, "return pmacs.editor.save()")); assert_eq!(read(&f), "mine original\n"); @@ -157,7 +157,7 @@ fn a_deleted_file_is_recreated_not_refused() { write(&f, "original\n"); let fs = f.display().to_string(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); open_and_dirty(&s, &fs); // Nothing on disk to clobber, so recreating it is not data loss. std::fs::remove_file(&f).unwrap(); @@ -174,7 +174,7 @@ fn a_new_file_buffer_refuses_once_someone_else_creates_the_file() { let dir = tempdir(); let missing = dir.join("draft.txt"); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); // The argv `[new file]` shape: a path with nothing on disk, no meta. exec( &s, @@ -200,3 +200,10 @@ fn a_new_file_buffer_refuses_once_someone_else_creates_the_file() { assert_eq!(read(&missing), "my draft"); std::fs::remove_dir_all(&dir).ok(); } + +// Isolated bootstrap storage roots (see the module docs): an +// integration test is compiled without `cfg(test)`, so a raw +// `EditorState::new()` would read the developer's real `init.lua` and +// write into their real data root. +#[path = "common/iso.rs"] +mod iso; diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index 2f5cfd9..2df0161 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -29,7 +29,8 @@ use pmacs::statusline::{ StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline, }; -#[cfg(feature = "crdt")] +// Ungated: `common::iso` (isolated bootstrap roots) is needed in every +// build, not only the CRDT one. mod common; fn exec(state: &EditorState, source: &str) { @@ -41,7 +42,7 @@ fn eval(state: &EditorState, source: &str) -> T { } fn editor() -> EditorState { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, "pmacs.lsp.config = {}"); state } @@ -689,7 +690,7 @@ fn a09_11_full_tui_frame_composes_unicode_clips_and_preserves_echo() { // map and polls the real tracker without a buffer edit. #[test] fn a12_builtin_lsp_provider_tracks_real_attachment_and_unknown_label() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = env!("CARGO_BIN_EXE_pmacs_fake_lsp"); let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("statusline.rs"); @@ -1048,3 +1049,11 @@ fn a16_26_real_daemon_v17_gate_v18_first_frame_and_late_join() { "late join sees established state" ); } + +// 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. Re-exported rather than re-declared +// with `#[path]` — this file already pulls in `common`, and loading one +// source file as two modules is `clippy::duplicate_mod`. +use common::iso; diff --git a/tests/terminal_config_acceptance.rs b/tests/terminal_config_acceptance.rs index ceeb8fe..9a52bc8 100644 --- a/tests/terminal_config_acceptance.rs +++ b/tests/terminal_config_acceptance.rs @@ -187,7 +187,7 @@ fn escape_was_armed(state: &mut EditorState, buffer: pmacs::buffer::BufferId, pr /// Acceptance 1: a profile spec is strict, and rejects before anything spawns. #[test] fn acc1_profile_specs_are_strict_and_reject_before_spawning() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let before = state.core.borrow().registry.borrow().ids().len(); exec( @@ -218,7 +218,7 @@ fn acc1_profile_specs_are_strict_and_reject_before_spawning() { /// Acceptance 2: an unknown profile names the known ones and creates nothing. #[test] fn acc2_unknown_profile_lists_known_names_and_creates_nothing() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, CAT_PROFILE); exec( &state, @@ -262,7 +262,7 @@ fn acc2_unknown_profile_lists_known_names_and_creates_nothing() { /// unknown-profile path, replacing the exact error being asked for. #[test] fn acc2_malformed_profile_keys_do_not_mask_the_unknown_profile_error() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, CAT_PROFILE); exec( &state, @@ -297,7 +297,7 @@ fn acc2_malformed_profile_keys_do_not_mask_the_unknown_profile_error() { /// `env` MERGES rather than replacing. #[test] fn acc3_field_resolution_order_and_env_merge() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec( &state, r#" @@ -323,7 +323,7 @@ fn acc3_field_resolution_order_and_env_merge() { /// Acceptance 3 (explicit command wins) and 4 (`""` means no profile). #[test] fn acc3_acc4_explicit_command_wins_and_empty_default_means_no_profile() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, CAT_PROFILE); exec( &state, @@ -373,7 +373,7 @@ pmacs.terminal.profiles.fill = { /// fallback) deleted outright. #[test] fn acc5_scrollback_setting_reaches_retained_history() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, FILL_PROFILE); // Arm 1: `0` is legal, and means the early rows are GONE. @@ -423,7 +423,7 @@ fn acc5_scrollback_setting_reaches_retained_history() { /// values, and `0` is inside it rather than a disabled sentinel. #[test] fn acc5_scrollback_bounds() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, r#"pmacs.config.set("terminal.scrollback-rows", 0)"#); assert_eq!( state @@ -458,7 +458,7 @@ fn acc5_scrollback_bounds() { /// THAT chord to the child, and an ordinary `C-c` still reaches the child. #[test] fn acc6_acc9_configured_escape_chord_and_literal_repeat() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, CAT_PROFILE); let buffer = open_cat_terminal(&state, r#"profile = "echo""#); assert!(tick_until(&mut state, "READY", buffer)); @@ -499,7 +499,7 @@ fn acc6_acc9_configured_escape_chord_and_literal_repeat() { /// count that does not grow, and a cache that dies with its terminal. #[test] fn acc7_acc8_acc8a_per_terminal_escape_cache_identity_and_lifecycle() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, CAT_PROFILE); let a = open_cat_terminal(&state, r#"profile = "echo""#); exec(&state, "TERM_A = TERM_BUF"); @@ -633,7 +633,7 @@ fn acc7_acc8_acc8a_per_terminal_escape_cache_identity_and_lifecycle() { /// the status line, and reports once per terminal per effective bad value. #[test] fn acc10_acc10a_invalid_escape_falls_back_and_reports_once() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, CAT_PROFILE); let buffer = open_cat_terminal(&state, r#"profile = "echo""#); assert!(tick_until(&mut state, "READY", buffer)); @@ -699,7 +699,7 @@ fn acc10_acc10a_invalid_escape_falls_back_and_reports_once() { /// proves the second half). #[test] fn acc11_terminal_opening_binding_is_bound_and_shadowed_nothing() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let command: Option = state .lua_host .lua() @@ -717,7 +717,7 @@ fn acc11_terminal_opening_binding_is_bound_and_shadowed_nothing() { /// defaults reproduce the pre-arc behavior. #[test] fn acc12_defaults_reproduce_prior_behavior() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); assert_eq!( lua.load(r#"return pmacs.config.get("terminal.default-profile")"#) @@ -744,3 +744,10 @@ fn acc12_defaults_reproduce_prior_behavior() { "no profiles are registered by default" ); } + +// 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; diff --git a/tests/terminal_copy_mode_acceptance.rs b/tests/terminal_copy_mode_acceptance.rs index aeba1ce..ecb6e69 100644 --- a/tests/terminal_copy_mode_acceptance.rs +++ b/tests/terminal_copy_mode_acceptance.rs @@ -223,7 +223,7 @@ fn buffer_count(state: &EditorState) -> usize { /// `copy_selection_bytes` itself. #[test] fn acc13_snapshot_is_the_whole_retained_range_through_the_shared_serializer() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); @@ -250,7 +250,7 @@ fn acc13_snapshot_is_the_whole_retained_range_through_the_shared_serializer() { /// buffer-shaped consumer work and what removes the transport arm. #[test] fn acc14_the_snapshot_is_an_ordinary_non_terminal_buffer() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); @@ -272,7 +272,7 @@ fn acc14_the_snapshot_is_an_ordinary_non_terminal_buffer() { /// with no change to `src/search.rs` (B1). #[test] fn acc15_isearch_finds_content_only_in_scrollback() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); @@ -319,7 +319,7 @@ fn acc15_isearch_finds_content_only_in_scrollback() { /// agreement; what stops the mutation is this. #[test] fn acc16_dispatch_idle_is_false_while_the_snapshot_is_focused() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); @@ -344,7 +344,7 @@ fn acc16_dispatch_idle_is_false_while_the_snapshot_is_focused() { /// protection does not depend on which key or command was used. #[test] fn acc16b_the_snapshot_is_immutable_at_the_rope_not_merely_intercepted() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); @@ -390,7 +390,7 @@ fn acc16b_the_snapshot_is_immutable_at_the_rope_not_merely_intercepted() { /// leaves the buffer emptiable. Only rope-level `read_only` closes both. #[test] fn acc16c_undo_cannot_empty_the_snapshot_by_chord_or_by_command() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); @@ -523,7 +523,7 @@ fn grid_row(cells: &[pmacs::cell::Cell], row: u32, cols: u32) -> String { /// any other owner that adopts the primitive later. #[test] fn acc16d_a_generated_write_notifies_the_window_that_displays_it() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec( &state, r" @@ -567,7 +567,7 @@ fn acc16d_a_generated_write_notifies_the_window_that_displays_it() { #[cfg(feature = "crdt")] #[test] fn acc16e_a_refresh_queues_the_owners_write_for_replica_mirrors() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); @@ -606,7 +606,7 @@ fn acc16e_a_refresh_queues_the_owners_write_for_replica_mirrors() { /// both directions. #[test] fn acc18_reinvoke_refreshes_in_place_and_lifecycle_runs_both_ways() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); @@ -681,7 +681,7 @@ fn acc18_reinvoke_refreshes_in_place_and_lifecycle_runs_both_ways() { /// `q` returns to the source terminal. #[test] fn acc19_escape_c_t_enters_copy_mode_and_g_and_q_work() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); let terminal_name = active_buffer_name(&state); @@ -749,7 +749,7 @@ fn acc19_escape_c_t_enters_copy_mode_and_g_and_q_work() { /// tail. #[test] fn acc20_live_terminal_keys_are_unchanged_while_a_snapshot_exists() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); let key = focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); @@ -806,7 +806,7 @@ fn acc20_live_terminal_keys_are_unchanged_while_a_snapshot_exists() { /// (or nothing) while the keys behaved differently. #[test] fn acc21_describe_key_reports_the_truth_for_the_snapshot_bindings() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); @@ -847,7 +847,7 @@ fn acc21_describe_key_reports_the_truth_for_the_snapshot_bindings() { /// (dired's F7 rule); a taken name gets a `<2>` variant instead. #[test] fn acc18a_a_foreign_same_named_buffer_is_never_adopted_or_clobbered() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); @@ -898,7 +898,7 @@ fn acc18a_a_foreign_same_named_buffer_is_never_adopted_or_clobbered() { /// terminal, and killing either one removes the shared snapshot. #[test] fn acc18b_two_same_named_terminals_get_two_independent_snapshots() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, FILL_PROFILE); let before = terminal_buffers(&state); @@ -985,7 +985,7 @@ fn acc18b_two_same_named_terminals_get_two_independent_snapshots() { /// snapshot of nothing. #[test] fn copy_mode_refuses_a_non_terminal_buffer() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let err = eval_err(&state, "return pmacs.terminal.copy_mode()"); assert!( err.contains("not a terminal"), @@ -1010,7 +1010,7 @@ fn copy_mode_refuses_a_non_terminal_buffer() { /// state. Falsify by deleting the `win.cursor > len` clamp. #[test] fn acc16f_a_shrinking_generated_write_clamps_the_window_cursor() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec( &state, r" @@ -1066,7 +1066,7 @@ fn acc16f_a_shrinking_generated_write_clamps_the_window_cursor() { /// the measurement. #[test] fn acc16g_a_line_collapsing_generated_write_clamps_view_top() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec( &state, r" @@ -1129,7 +1129,7 @@ fn acc16g_a_line_collapsing_generated_write_clamps_view_top() { /// `notify_buffer_edit`; the first copy reaches the stale-anchor panic. #[test] fn acc16h_a_shrinking_generated_write_clamps_or_clears_the_selection() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec( &state, r" @@ -1224,7 +1224,7 @@ fn acc16h_a_shrinking_generated_write_clamps_or_clears_the_selection() { /// `acc16h` stays green. #[test] fn acc16i_a_shrinking_view_rebuild_clamps_or_clears_the_selection() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); // 286 bytes, then 154: a real shrink through the help renderer. exec( &state, @@ -1300,3 +1300,10 @@ fn acc16i_a_shrinking_view_rebuild_clamps_or_clears_the_selection() { "the collapsed region is not retained as active-but-empty" ); } + +// 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; diff --git a/tests/theme_faces_acceptance.rs b/tests/theme_faces_acceptance.rs index c81ace7..c9e5fb2 100644 --- a/tests/theme_faces_acceptance.rs +++ b/tests/theme_faces_acceptance.rs @@ -20,7 +20,8 @@ use pmacs::protocol::{ByteRange, FrontendId, InstanceMessage, ThemeFace}; use pmacs::semantic_render::SemanticRenderState; use std::time::{Duration, Instant}; -#[cfg(feature = "crdt")] +// Ungated: `common::iso` (isolated bootstrap roots) is needed in every +// build, not only the CRDT one. mod common; // --------------------------------------------------------------------------- @@ -78,7 +79,7 @@ fn eval(s: &EditorState, src: &str) -> T { /// Fresh editor with LSP spawning disabled. fn editor() -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); s } @@ -184,7 +185,7 @@ fn current_tree_language(state: &EditorState) -> Option { /// Open `path` and pump until its parse settles (highlights attached). fn open_and_wait_for_parse(path: std::path::PathBuf) -> EditorState { - let mut state = EditorState::open(path).expect("open file"); + let mut state = EditorState::open_with_roots(path, &crate::iso::roots()).expect("open file"); exec(&state, "pmacs.lsp.config = {}"); pump_async(&mut state, |s| current_tree_language(s).is_some()); state @@ -1511,3 +1512,11 @@ fn minibuffer_and_candidate_faces_apply_through_m_x() { panic!("no candidate suffix rendered: {text:?}"); } } + +// 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. Re-exported rather than re-declared +// with `#[path]` — this file already pulls in `common`, and loading one +// source file as two modules is `clippy::duplicate_mod`. +use common::iso; diff --git a/tests/typed_edit_chain_acceptance.rs b/tests/typed_edit_chain_acceptance.rs index 8ff9c13..2a65cb7 100644 --- a/tests/typed_edit_chain_acceptance.rs +++ b/tests/typed_edit_chain_acceptance.rs @@ -78,7 +78,7 @@ fn status(s: &EditorState) -> String { /// Fresh scratch-buffer editor, cursor at 0. Scratch pairing uses the /// `default` set, so `(` pairs — which is what 46c reads. fn editor_with(body: &str) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); if !body.is_empty() { exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})")); } @@ -656,7 +656,7 @@ fn a_chain_consumers_edit_reaches_the_first_did_change() { let sink_disp = sink.display().to_string(); let fake = fake_lsp_path(); - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host.lua().set_app_data(StateDir(dir.clone())); exec(&s, "pmacs.lsp.config = {}"); @@ -733,3 +733,10 @@ fn a_chain_consumers_edit_reaches_the_first_did_change() { before lsp.lua's synchronous flush (Q#AP7)" ); } + +// 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; diff --git a/tests/vterm_stage1_acceptance.rs b/tests/vterm_stage1_acceptance.rs index d1a4a63..489ca9a 100644 --- a/tests/vterm_stage1_acceptance.rs +++ b/tests/vterm_stage1_acceptance.rs @@ -60,7 +60,7 @@ fn terminal_cells_reject_child_control_characters() { #[test] fn spawn_failure_is_transactional() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let buffers_before = state.core.borrow().registry.borrow().len(); let processes_before = state.process_supervisor.borrow().ids().count(); state.process_supervisor.borrow_mut().shutdown(); @@ -77,7 +77,7 @@ fn spawn_failure_is_transactional() { #[test] fn strict_owned_spec_rejects_before_spawn_and_is_mutation_independent() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let buffers_before = state.core.borrow().registry.borrow().len(); let mut invalid = TerminalSpec::new("/bin/sh"); invalid.rows = 0; @@ -193,7 +193,7 @@ fn read_only_empty_crdt_bootstrap_is_immutable_against_remote_content() { #[test] fn final_output_precedes_exact_nonzero_annotation_and_buffer_is_retained() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let mut spec = TerminalSpec::new("/bin/sh"); spec.args = vec![ "-c".into(), @@ -306,7 +306,7 @@ fn normal_and_signal_annotations_use_exact_pid_and_outcome() { "exited abnormally with signal SIGTERM", ), ] { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let mut spec = TerminalSpec::new("/bin/sh"); spec.args = vec!["-c".into(), script.into()]; spec.rows = 6; @@ -334,7 +334,7 @@ fn normal_and_signal_annotations_use_exact_pid_and_outcome() { #[test] fn killing_terminal_buffer_prunes_session_and_reaps_owned_process() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let mut spec = TerminalSpec::new("/bin/sh"); spec.args = vec!["-c".into(), "sleep 30".into()]; let buffer_id = state.open_terminal(spec).expect("open terminal"); @@ -364,7 +364,7 @@ fn killing_terminal_buffer_prunes_session_and_reaps_owned_process() { #[test] fn editor_shutdown_kills_term_ignoring_terminal_child() { let pid = { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state .process_supervisor .borrow_mut() @@ -397,7 +397,7 @@ fn editor_shutdown_kills_term_ignoring_terminal_child() { #[test] fn terminal_tick_does_not_take_non_terminal_process_events() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let mut process = pmacs::process::ProcessSpec::new("ordinary", "/bin/sh"); process.args = vec!["-c".into(), "printf ordinary".into()]; let ordinary_id = state @@ -424,3 +424,10 @@ fn terminal_tick_does_not_take_non_terminal_process_events() { "TerminalManager must not steal ordinary process output" ); } + +// 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; diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs index 852ed1f..d5b86e3 100644 --- a/tests/vterm_stage2_acceptance.rs +++ b/tests/vterm_stage2_acceptance.rs @@ -60,7 +60,7 @@ fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String { reason = "cross-surface Lua transaction scenario" )] fn lua_surface_is_strict_fresh_transactional_and_context_safe() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let command_lua = lua_string("/bin/sh"); let baseline_buffer_id = state.core.borrow().active_buffer_id(); @@ -364,7 +364,7 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() { #[test] #[allow(clippy::too_many_lines, reason = "shared view and controller scenario")] fn shared_screen_keeps_view_scroll_selection_and_controller_independent() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let mut spec = TerminalSpec::new("/bin/sh"); spec.args = vec![ "-c".into(), @@ -508,7 +508,7 @@ fn terminal_escape_gates_local_bindings_and_double_escape_sends_interrupt() { ready_path.to_str().expect("UTF-8 ready path"), input_path.to_str().expect("UTF-8 input path") ); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state .lua_host .lua() @@ -986,3 +986,11 @@ fn longest_rendered_prefix_separates_absent_child_text_from_a_split_render() { needle.len() ); } + +// 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. Re-exported rather than re-declared +// with `#[path]` — this file already pulls in `common`, and loading one +// source file as two modules is `clippy::duplicate_mod`. +use common::iso; diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index e4eb3c1..c415abf 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -156,7 +156,7 @@ fn one_frame(messages: &[InstanceMessage]) -> TerminalFrame { reason = "one producer-baseline lifecycle scenario" )] fn a30_first_frame_is_authoritative_then_only_real_changes_emit() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let frontend_id = FrontendId(31); let terminal_buffer = open_terminal( &mut state, @@ -314,7 +314,7 @@ fn a30_first_frame_is_authoritative_then_only_real_changes_emit() { reason = "one shared-session two-frontend scenario" )] fn a31_two_semantic_frontends_share_one_session_with_independent_views() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let first_id = FrontendId(41); let second_id = FrontendId(42); let terminal_buffer = open_terminal( @@ -459,7 +459,7 @@ fn a31_two_semantic_frontends_share_one_session_with_independent_views() { reason = "one case per rejected identity or bound" )] fn a32_forged_stale_and_out_of_bounds_terminal_events_change_nothing() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let owner = FrontendId(51); let attacker = FrontendId(52); let terminal_buffer = open_terminal(&mut state, "sleep 30", 6, 20); @@ -978,7 +978,7 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { /// gesture still claims; only motion does not. #[test] fn hover_does_not_steal_terminal_control_from_the_active_frontend() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let owner = FrontendId(71); let bystander = FrontendId(72); let terminal_buffer = open_terminal(&mut state, "sleep 30", 6, 20); @@ -1075,7 +1075,7 @@ fn hover_does_not_steal_terminal_control_from_the_active_frontend() { /// the empty identity buffer. #[test] fn a28_a30_a_v18_semantic_peer_has_no_terminal_surface() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let frontend_id = FrontendId(61); let terminal_buffer = open_terminal(&mut state, "sleep 30", 4, 20); tick_until(&mut state, Duration::from_secs(5), |state| { @@ -1341,3 +1341,11 @@ fn gpu_terminal_input_reaches_the_child_and_returns_in_a_frame() { report() ); } + +// 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. Re-exported rather than re-declared +// with `#[path]` — this file already pulls in `common`, and loading one +// source file as two modules is `clippy::duplicate_mod`. +use common::iso; diff --git a/tests/worker_shutdown_acceptance.rs b/tests/worker_shutdown_acceptance.rs index 3893a20..a9cf831 100644 --- a/tests/worker_shutdown_acceptance.rs +++ b/tests/worker_shutdown_acceptance.rs @@ -30,7 +30,7 @@ fn live_threads() -> usize { fn editor_state_drop_releases_workers_and_shutdown_is_idempotent() { let baseline = live_threads(); for _ in 0..3 { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); drop(s); } // Signal-only shutdown: parked workers exit within their 100ms @@ -45,7 +45,7 @@ fn editor_state_drop_releases_workers_and_shutdown_is_idempotent() { // Idempotence: explicit shutdown twice, then drop runs it a // third time --- none may hang or panic. - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.async_runtime.shutdown_workers(); s.async_runtime.shutdown_workers(); drop(s); @@ -55,8 +55,15 @@ fn editor_state_drop_releases_workers_and_shutdown_is_idempotent() { #[cfg(not(target_os = "linux"))] #[test] fn explicit_shutdown_is_idempotent() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.async_runtime.shutdown_workers(); s.async_runtime.shutdown_workers(); // second call must not hang or panic drop(s); // drop runs shutdown a third time } + +// 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; From 22925964d9cc1257e5c499276110d6755f34288a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 18:49:00 -0400 Subject: [PATCH 4/6] docs: record the ambient-isolation lane and its five-variable rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/active-work.md`: the lane moves from FRAMING OPEN to IMPLEMENTATION OPEN, with the implementation branch and worktree and a recovery command that names them. The framing-only worktree is spent — its doc is on `main` (#201). `docs/agent-handoff.md` §3: a local full-suite run needs all FIVE storage variables controlled, not four. `PMACS_STATE_HOME` outranks `XDG_STATE_HOME`, so naming only the XDG four leaves a higher-precedence state override live; and a run isolating only `XDG_CONFIG_HOME` stops the `init.lua` reads while still writing through the real data root — every local gate run in this repo before today had that hole. `HOME` is deliberately excluded: it is the fallback the XDG roots already cover once set, and it separately drives `~`-expansion, which `find_file_acceptance` pins on purpose. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- docs/active-work.md | 30 +++++++++++++++++++++--------- docs/agent-handoff.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 147f568..625509f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -414,13 +414,25 @@ compatible. - **DAP waits for Stage 2, not Stage 1** — that dependency is now satisfied. -## Test ambient-root isolation — FRAMING OPEN, revision 4 +## Test ambient-root isolation — IMPLEMENTATION OPEN -- **Branch `test-ambient-config-isolation`**, worktree - `../pmacs-test-isolation`, based on `githubsucks/main` @ `4cd4a7b`. - **Framing only; no code, no PR yet.** - `docs/test-ambient-config-isolation-framing.md` revision 4, three review - rounds closed (eight blocking, six major, all accepted). +- **Framing MERGED (#201)**; + `docs/test-ambient-config-isolation-framing.md` revision 4, three + review rounds closed (eight blocking, six major, all accepted). +- **Implementation branch `test-ambient-isolation-impl`**, worktree + `../pmacs-test-isolation-impl`, based on `githubsucks/main` @ + `54a092e`. The framing-only worktree `../pmacs-test-isolation` + (branch `test-ambient-config-isolation`) is spent — its doc is on + `main`. +- **What landed on the branch.** `pmacs::bootstrap::BootstrapRoots` + (config/data/state/cache, `ambient()` for production), reachable from + `EditorState::new_with_roots` **and** `open_with_roots` and consulted + again by `install_state_dirs`; all 342 in-process construction sites + in 65 files migrated; `journey_acceptance` keeps the ambient `open` + and re-execs itself per test with controlled roots instead; the shared + daemon and PTY spawners now set all five storage variables; + `tests/ambient_isolation_acceptance.rs` carries the hostile-environment + proof and the adoption ratchet. - **What it is.** Integration tests use the developer's real ambient roots. `#[cfg(not(test))]` guards config loading against the crate's own unit tests only, so the **65** files in `tests/` that construct an @@ -455,9 +467,9 @@ compatible. ```sh git fetch githubsucks - git worktree add ../pmacs-test-isolation \ - -b test-ambient-config-isolation \ - githubsucks/test-ambient-config-isolation + git worktree add ../pmacs-test-isolation-impl \ + -b test-ambient-isolation-impl \ + githubsucks/test-ambient-isolation-impl ``` ## Reap-ledger silent failures — MERGED (#202); kept for its parked follow-ons diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index d4542f4..ad61320 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1639,6 +1639,35 @@ git diff --check Machine-specific caveats — re-verify on a machine you haven't used before trusting them: +- **Ambient storage roots: control all FIVE, not four.** Until the + ambient-root isolation lane lands + (`docs/test-ambient-config-isolation-framing.md`), the ~96 integration + suites read the developer's real `~/.config/pmacs/init.lua` and + **write** bundled packages into the real data root: + `#[cfg(not(test))]` guards the crate's own unit tests only, and + `EditorState::new` materializes packages outside every `cfg` guard. + A local full-suite run therefore needs, all pointed at a fresh + directory: + + ``` + XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME XDG_CACHE_HOME + PMACS_STATE_HOME + ``` + + **The fifth is not redundant.** `PMACS_STATE_HOME` outranks + `XDG_STATE_HOME` (`src/state.rs`), so redirecting the four XDG + variables on a machine that exports it leaves the real state root + live. `HOME` is deliberately left alone: it is the fallback for the + XDG roots (already covered once they are set) and separately drives + `~`-expansion, which `find_file_acceptance` pins on purpose. + A run isolating only `XDG_CONFIG_HOME` stops the `init.lua` reads and + still writes through the real data root — every local gate run in this + repo before 2026-07-31 had that hole. + + **After the lane lands** the in-process population isolates itself + through `EditorState::new_with_roots(&crate::iso::roots())` and the + five variables are belt-and-braces rather than required. + - **basedpyright**: the desktop binary was **never broken** — this was a real code defect, diagnosed and fixed 2026-07-29 (see §5, "A `Drop` body runs before its fields"). `RuntimeHandles::drop` joined its reader From 9ea522f3cce203b115ec56db8e2346d34c88ce90 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 19:46:18 -0400 Subject: [PATCH 5/6] fix(isolation): the isolation suite must not itself be ambient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1: `isolated_construction_is_init_complete` asserted its paired half — that the *ambient* constructor is unchanged — with an ambient `EditorState::new()` in an ordinary parent test. That reads the developer's real `init.lua` and materializes packages into their real data root: the exposure this suite exists to remove, committed by the suite itself. The claim is worth keeping, so it moves rather than dies. It now lives in the re-exec'd positive control, which runs only as a child under a hostile-by-construction environment. That is the one place an ambient constructor is safe, and so it is where every ambient claim this suite makes belongs. **The ratchet did not catch this, and that is the more important half.** `ambient_isolation_acceptance.rs` was on the allowlist for its positive control, and a bare file-level exemption licenses the named file to grow new ambient sites forever — which is exactly what happened. So every exemption now carries its **exact permitted site count**, and a file with more sites than it was reviewed with fails even while allowlisted. A count that drops fails too, so the allowlist stays a census rather than drifting into a ceiling nobody rechecks. The count immediately earned itself: it rejected the number written from memory for `journey_acceptance` (47) and reported the real one (26 — 19 `new()` + 7 `open(`, after the scanner drops two assertion-message mentions and the assembled `concat!` needle). Verified in both directions: restoring the removed ambient site fails the ratchet with `2 site(s), allowlist says 1`; and with the ambient half gone, both init-complete pins still fail under the `if roots.is_ambient()` mutation, so neither has become a test that passes for the wrong reason. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- tests/ambient_isolation_acceptance.rs | 85 +++++++++++++++++++++------ 1 file changed, 67 insertions(+), 18 deletions(-) diff --git a/tests/ambient_isolation_acceptance.rs b/tests/ambient_isolation_acceptance.rs index 58e12a6..fee892d 100644 --- a/tests/ambient_isolation_acceptance.rs +++ b/tests/ambient_isolation_acceptance.rs @@ -91,9 +91,14 @@ fn isolated_construction_is_init_complete() { "isolated construction must still leave the init phase, or every \ suite that reopens it (m8_2) breaks" ); - // The paired half: the ambient constructor is unchanged. - let ambient = EditorState::new(); - assert!(ambient.lua_host.is_init_complete()); + // The paired half — that the *ambient* constructor is unchanged — + // deliberately does NOT live here. Asserting it needs an ambient + // `EditorState::new()`, and an ambient construction in an ordinary + // parent test reads the developer's real `init.lua` and materializes + // packages into their real data root: the exact exposure this suite + // exists to remove, committed by the suite itself. It lives in the + // re-exec'd positive control below, where the roots are controlled + // by construction. } /// **N** — the init phase is genuinely closed, not merely reported @@ -288,14 +293,30 @@ fn run_child(test_name: &str, env: Vec<(&'static str, PathBuf)>) -> (bool, Strin /// `init.lua` that never loads under any circumstances would satisfy /// "the isolated editor did not load it" while proving nothing. /// -/// Runs only as a re-exec'd child (marker set), so an ordinary suite run -/// does not construct an ambient editor. +/// **This is the suite's only ambient construction, and it runs only as +/// a re-exec'd child** (the marker gates it), where the roots are +/// controlled by construction. An ambient `EditorState::new()` in an +/// ordinary parent test would read the developer's real `init.lua` and +/// write their real data root — so the one place that legitimately needs +/// the ambient constructor is also the one place where the environment +/// has already been redirected. Every ambient claim this suite makes +/// belongs here for that reason. #[test] fn ambient_construction_under_a_hostile_environment_is_captured_by_it() { if std::env::var_os(AMBIENT_CONTROL_CHILD).is_none() { return; } let state = EditorState::new(); + // Relocated from `isolated_construction_is_init_complete`: the + // ambient constructor is unchanged by this lane and still finishes + // initialization. Asserting it needs an ambient construction, which + // is only safe here. + assert!( + state.lua_host.is_init_complete(), + "the ambient constructor must still leave the init phase — the \ + roots parameter changes which directory is read, never whether \ + the block runs" + ); let ran: bool = state .lua_host .lua() @@ -409,7 +430,7 @@ fn a_hostile_ambient_environment_is_neither_read_nor_written() { // --------------------------------------------------------------------------- /// Files permitted to construct an editor through the **ambient** entry -/// points. +/// points, **with the exact number of sites each is permitted**. /// /// `journey_acceptance` is ambient on purpose: it is the golden-journey /// ratchet, and its whole claim is that the production entry point @@ -417,7 +438,24 @@ fn a_hostile_ambient_environment_is_neither_read_nor_written() { /// with controlled roots instead (framing §1.10). This file is ambient /// only inside the positive control above, which never runs except as a /// deliberately re-exec'd child. -const AMBIENT_ALLOWLIST: &[&str] = &["journey_acceptance.rs", "ambient_isolation_acceptance.rs"]; +/// +/// **The count is the point, not decoration.** A bare file-level +/// exemption is the weakest form of this ratchet: it licenses the named +/// file to grow *new* ambient sites forever. That is not hypothetical — +/// review round 1 of this PR found an ambient `EditorState::new()` in an +/// ordinary parent test of **this very file**, and the file-level +/// exemption is precisely what let it through a green ratchet. Every +/// exemption is now a census entry, so an added site fails even inside +/// an allowlisted file, and a removed one has to be recorded. +const AMBIENT_ALLOWLIST: &[(&str, usize)] = &[ + // 19 `new()` + 7 `open(` — the golden journey, every one of them + // reached only from inside a re-exec'd child. (29 textual + // occurrences; the scanner drops 2 assertion-message mentions and + // the assembled `concat!` needle in the self-source check.) + ("journey_acceptance.rs", 26), + // Exactly one: the re-exec'd positive control. + ("ambient_isolation_acceptance.rs", 1), +]; /// Strip comments and string-literal *contents* from Rust source, so a /// scan counts calls rather than mentions. @@ -559,6 +597,7 @@ fn no_test_outside_the_allowlist_constructs_an_ambient_editor() { concat!("EditorState::", "open("), ]; let mut offenders: Vec = Vec::new(); + let mut miscounted: Vec = Vec::new(); let mut seen_allowlisted: Vec<&str> = Vec::new(); for (name, src) in &sources { let code = strip_comments_and_strings(src); @@ -566,18 +605,20 @@ fn no_test_outside_the_allowlist_constructs_an_ambient_editor() { if hits == 0 { continue; } - if AMBIENT_ALLOWLIST.contains(&name.as_str()) { - seen_allowlisted.push( - AMBIENT_ALLOWLIST - .iter() - .find(|a| **a == name.as_str()) - .expect("just matched"), - ); - } else { - offenders.push(format!("{name} ({hits} site(s))")); + match AMBIENT_ALLOWLIST.iter().find(|(f, _)| *f == name.as_str()) { + Some((file, allowed)) => { + seen_allowlisted.push(file); + // An allowlisted file is exempted for the sites it was + // reviewed with, not for any it grows later. + if hits != *allowed { + miscounted.push(format!("{name}: {hits} site(s), allowlist says {allowed}")); + } + } + None => offenders.push(format!("{name} ({hits} site(s))")), } } offenders.sort(); + miscounted.sort(); assert!( offenders.is_empty(), "these suites construct an editor through the ambient entry \ @@ -587,11 +628,19 @@ fn no_test_outside_the_allowlist_constructs_an_ambient_editor() { tests/common/iso.rs), or add the file to AMBIENT_ALLOWLIST with \ a reason.", ); + assert!( + miscounted.is_empty(), + "allowlisted files whose ambient site count moved: {miscounted:?}\n\ + MORE than allowed means a new ambient construction slipped into \ + an exempted file — the failure mode a bare file-level exemption \ + cannot see. FEWER means the census is stale; update the count.", + ); // Dead allowlist entries are how a ratchet rots: an entry that no // longer needs to be there silently licenses a future regression. - let mut missing: Vec<&&str> = AMBIENT_ALLOWLIST + let mut missing: Vec<&str> = AMBIENT_ALLOWLIST .iter() - .filter(|a| !seen_allowlisted.contains(&**a)) + .map(|(f, _)| *f) + .filter(|f| !seen_allowlisted.contains(f)) .collect(); missing.sort_unstable(); assert!( From b3131dbf959bef84d29ab0c154b398a7b56d836f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 19:46:33 -0400 Subject: [PATCH 6/6] =?UTF-8?q?docs(framing):=20record=20the=20deliberate?= =?UTF-8?q?=20departure=20from=20the=20=C2=A77=20branch=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §7 said the classification comes first and alone, "its answer belongs in review before any mechanical edit rides on it." The classification came back at 342 sites across 66 of 97 files, and the whole-corpus migration rode this PR anyway. That was a decision, not an oversight, and revision 5 records it as one so a later reader does not have to reconstruct it from the diff. The reasoning, in short: splitting would either leave 65 suites still writing the developer's real data root while the seam sat unused, or ship acceptance 12's ratchet with a ~65-file allowlist — and a ratchet exempting most of the corpus records rather than ratchets. §7's ORDERING is honoured (the census is the first commit); its implied SCOPING is not. Revision 5 also records what review round 1 established about the shape acceptance 12 needs: "a narrow, named allowlist" is not sufficient by itself, because narrowness constrains which files are exempt and says nothing about how far each exemption stretches. Exemptions carry counts. `docs/active-work.md` picks up the same two facts and the PR number. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- docs/active-work.md | 12 +++- docs/test-ambient-config-isolation-framing.md | 55 +++++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 625509f..75eed40 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -417,8 +417,16 @@ compatible. ## Test ambient-root isolation — IMPLEMENTATION OPEN - **Framing MERGED (#201)**; - `docs/test-ambient-config-isolation-framing.md` revision 4, three - review rounds closed (eight blocking, six major, all accepted). + `docs/test-ambient-config-isolation-framing.md` **revision 5** + (revision 4 approved after three review rounds — eight blocking, six + major, all accepted; revision 5 rides the implementation PR and records + findings, not a new design round). +- **PR #206 OPEN**, one review round closed. **§7's branch plan was + consciously exceeded**: the whole-corpus migration rides this PR rather + than a follow-up lane, because splitting would either leave 65 suites + writing the real data root or ship acceptance 12's ratchet with a + ~65-file allowlist that does not ratchet. Recorded in framing revision + 5; accepted in review. - **Implementation branch `test-ambient-isolation-impl`**, worktree `../pmacs-test-isolation-impl`, based on `githubsucks/main` @ `54a092e`. The framing-only worktree `../pmacs-test-isolation` diff --git a/docs/test-ambient-config-isolation-framing.md b/docs/test-ambient-config-isolation-framing.md index d9971ba..b8aa64e 100644 --- a/docs/test-ambient-config-isolation-framing.md +++ b/docs/test-ambient-config-isolation-framing.md @@ -1,9 +1,11 @@ # Framing — integration tests use the developer's real ambient roots -**Revision 4.** Status: awaiting review round 4. Proposed lane: -`test-ambient-config-isolation`, worktree `../pmacs-test-isolation`, -based on `githubsucks/main` @ `4cd4a7b` (a reading; re-measure at branch -time). +**Revision 5.** Status: implemented on branch +`test-ambient-isolation-impl` (worktree `../pmacs-test-isolation-impl`, +based on `githubsucks/main` @ `54a092e`), PR #206. Revision 4 was +approved after three review rounds and merged as #201; revision 5 records +implementation findings and one **deliberate departure from §7's branch +plan**, not a new design round. **The suite is green in CI and red on a developer machine that has a real `~/.config/pmacs/init.lua`.** Not flaky — deterministic, and @@ -16,6 +18,51 @@ directory**. The lane is therefore about *ambient roots*, not about ## Revision history +**Revision 4 → 5**, at implementation. No design changed; two things are +recorded that a later reader would otherwise have to reconstruct. + +### The §7 branch plan was consciously exceeded + +**§7 said: classification first and alone, "its answer belongs in review +before any mechanical edit rides on it."** The classification came back +at **342 in-process construction sites across 66 of 97 files** — large +enough that §7's shape would naturally suggest splitting the mechanical +migration into its own lane. **It was not split, and that is deliberate.** + +The alternatives were both worse, and both worse in the way this lane +exists to prevent: + +- **Split, migrating nothing now.** The seam would land while 65 files + kept reading the developer's real `init.lua` and writing their real + data root. The lane's own defect would survive its own PR. +- **Split, with a broad temporary allowlist.** Acceptance 12's ratchet + would ship exempting ~65 files. A ratchet whose allowlist is most of + the corpus does not ratchet; it records. And the exemption would have + to be removed later by the same reviewer who granted it, with nothing + failing in the meantime to remind anyone. + +So the whole-corpus migration is the atomic remediation and rides this +PR. §7's *ordering* is honoured — the classification is the first commit, +before any mechanical edit — while its implied *scoping* is not. The +qualification this leaves on acceptance 12 (the allowlist is narrow +because everything else moved, not because the change was small) was +raised in the PR and accepted in review round 1. + +### The exemption shape acceptance 12 needs + +Review round 1 found an ambient `EditorState::new()` in an ordinary +parent test of the isolation suite itself — the exposure, committed by +the suite written to remove it. **A file-level allowlist did not catch +it**, because the file was already exempted for its re-exec'd positive +control, and a bare exemption licenses a named file to grow new ambient +sites indefinitely. + +The fix is that **every exemption carries its exact site count**, so an +added site fails even inside an exempted file. That is the shape +acceptance 12 needs; "a narrow, named allowlist" is not sufficient on its +own, because narrowness constrains which files are exempt and says +nothing about how far each exemption stretches. + **Revision 3 → 4**, after review round 3 (one blocking, two major). All three accepted.