392 lines
14 KiB
Rust
392 lines
14 KiB
Rust
// 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_<X>_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<PathBuf>,
|
|
data: Option<PathBuf>,
|
|
state: Option<PathBuf>,
|
|
cache: Option<PathBuf>,
|
|
}
|
|
|
|
/// 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 --- `<config>/pmacs`, the
|
|
/// same shape [`crate::config::user_config_dir`] builds.
|
|
#[must_use]
|
|
pub fn config_dir(&self) -> Option<PathBuf> {
|
|
self.config.as_ref().map(|p| p.join("pmacs"))
|
|
}
|
|
|
|
/// Where bundled packages are materialized ---
|
|
/// `<data>/pmacs/builtin-packages/v<crate-version>`, the same shape
|
|
/// [`crate::builtin_packages::bundled_runtime_dir`] builds.
|
|
#[must_use]
|
|
pub fn bundled_runtime_dir(&self) -> Option<PathBuf> {
|
|
self.data.as_ref().map(|p| {
|
|
p.join("pmacs")
|
|
.join("builtin-packages")
|
|
.join(bundled_leaf())
|
|
})
|
|
}
|
|
|
|
/// The user-scope package install root --- `<data>/pmacs/packages`.
|
|
#[must_use]
|
|
pub fn package_install_root(&self) -> Option<PathBuf> {
|
|
self.data.as_ref().map(|p| p.join("pmacs").join("packages"))
|
|
}
|
|
|
|
/// The package fetcher's bare-mirror cache --- `<cache>/pmacs/git`.
|
|
#[must_use]
|
|
pub fn package_cache_dir(&self) -> Option<PathBuf> {
|
|
self.cache.as_ref().map(|p| p.join("pmacs").join("git"))
|
|
}
|
|
|
|
/// The editor state directory --- `<state>/pmacs`, the same shape
|
|
/// [`crate::state::user_state_dir`] builds.
|
|
#[must_use]
|
|
pub fn state_dir(&self) -> Option<PathBuf> {
|
|
self.state.as_ref().map(|p| p.join("pmacs"))
|
|
}
|
|
|
|
/// The minibuffer history directory --- `<state>/pmacs/history`.
|
|
#[must_use]
|
|
pub fn history_dir(&self) -> Option<PathBuf> {
|
|
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::<Vec<_>>()
|
|
};
|
|
assert_eq!(tail(&isolated), tail(&ambient));
|
|
}
|
|
}
|