// builtin_packages.rs --- Bundled packages materialized at editor startup. //! Bundled packages (T M7.11). //! //! pmacs ships a small set of first-party packages alongside the //! editor binary. Through M7.10, those bundled packages were loaded //! directly via `lua_host.eval(include_str!(...))`, bypassing the //! manifest / exports / per-package `_ENV` machinery the M7 package //! system gives third-party packages. T M7.11 closes the gap: the //! bundled REPL gets a real `pmacs.toml` manifest and is loaded //! through the same path a `pmacs.packages.install` user package //! would take. //! //! ## How a bundled package is delivered //! //! Each [`BundledPackage`] carries its manifest TOML and entry source //! as `&'static str` references --- compiled into the binary via //! [`include_str!`]. At startup, the editor calls //! [`materialize_all`] to write each package to a per-version //! directory under the user's XDG data dir (see //! [`bundled_runtime_dir`] for the resolution policy), then //! constructs an //! [`InstalledPackage`] record and pushes it onto the //! [`InstalledPackages`](crate::lua_bindings::InstalledPackages) //! roster. From that point the M7.7 searcher resolves //! `require("repl")` (etc.) the same way it resolves any //! user-installed package. //! //! ## Why not load embedded source directly //! //! The M7.7 loader reads chunk bytes from `install_path` via //! [`std::fs::read`]. Routing the bundled chunk through that same //! disk-read keeps the load path uniform: one mechanism, one //! per-package `_ENV` setup, one exports check. Adding a parallel //! "embedded source" code path would split that uniformity and //! mean every loader-side change would need two implementations. //! //! ## Where the files land //! //! [`bundled_runtime_dir`] resolves to //! `$XDG_DATA_HOME/pmacs/builtin-packages/v/`, //! falling back to `$HOME/.local/share/pmacs/builtin-packages/v/` //! and then to a per-user, per-process subdir of the OS temp dir if //! neither HOME nor `XDG_DATA_HOME` is set. The data-dir choice is //! deliberate: the previous version used `$TMPDIR` directly, which //! on a multi-user host is world-writable and exposed bundled-package //! materialization to a shared-tempdir race. Sitting under the user's //! own data-dir path puts the bundled tree behind the same ownership //! and mode boundary as user-installed packages. //! //! The version-stamped subdir means two side-by-side pmacs versions //! don't collide and a content-gated write skips redundant updates. //! We do not delete the dir on editor shutdown: leaving the //! materialized tree in place lets a later editor invocation reuse //! the files without re-writing. use std::fs; use std::io; use std::path::{Path, PathBuf}; use semver::{Version, VersionReq}; use crate::packages::{InstallPin, InstallScope, InstalledPackage, ManifestError, PackageManifest}; /// One bundled package's compile-time content. #[derive(Debug, Clone, Copy)] pub struct BundledPackage { /// Package basename. Must match `manifest.name` after parsing. pub name: &'static str, /// Embedded `pmacs.toml` source. pub manifest_toml: &'static str, /// Embedded `(relative-path, file-bytes)` table. The first /// entry's relative path must match the parsed manifest's /// `entry` field; subsequent entries are exported submodules. pub files: &'static [(&'static str, &'static str)], } /// All bundled packages shipped with the editor. Order is /// presentation-only --- the loader looks them up by name. pub const BUNDLED_PACKAGES: &[BundledPackage] = &[BundledPackage { name: "repl", manifest_toml: include_str!("../builtin/packages/repl/pmacs.toml"), files: &[( "init.lua", include_str!("../builtin/packages/repl/init.lua"), )], }]; /// Errors raised during bundled-package materialization. None of /// these are recoverable at runtime; the editor cannot start /// without its bundled packages. #[derive(Debug, thiserror::Error)] pub enum BundledError { /// I/O failure writing or reading a bundled-package file. #[error("bundled-package I/O at {path}: {source}")] Io { /// Path the operation was attempted against. path: PathBuf, /// Underlying error. #[source] source: io::Error, }, /// A bundled package's embedded manifest failed to parse. /// Compile-time content invariant: this should never fire in /// production, but the path is exercised so a malformed /// `pmacs.toml` in `builtin/packages//` fails loud. #[error("bundled manifest for `{name}` failed to parse: {source}")] Manifest { /// Package basename. name: String, /// Underlying parse error. #[source] source: ManifestError, }, /// A bundled package's manifest's `name` field did not match /// the registry-table entry's `name` field. Indicates a typo in /// either side; surfaces at startup to fail fast. #[error( "bundled package name mismatch: registry says `{registry}`, manifest says `{manifest}`" )] NameMismatch { /// Registry entry's name. registry: String, /// Manifest's `name` field. manifest: String, }, } /// Resolve the directory under which bundled-package trees are /// materialized. Tries, in order: /// /// 1. `$XDG_DATA_HOME/pmacs/builtin-packages/v/` /// 2. `$HOME/.local/share/pmacs/builtin-packages/v/` /// 3. A user-private fallback under `$TMPDIR`: /// `$TMPDIR/pmacs-builtin-uid-v/` (only used /// when neither `XDG_DATA_HOME` nor `HOME` is set; the UID /// suffix narrows the path to the calling user so a multi-user /// box doesn't share the same world-writable directory). #[must_use] pub fn bundled_runtime_dir() -> PathBuf { let suffix = format!("v{}", env!("CARGO_PKG_VERSION")); if let Some(d) = std::env::var_os("XDG_DATA_HOME") { let p: PathBuf = d.into(); if !p.as_os_str().is_empty() { return p.join("pmacs").join("builtin-packages").join(suffix); } } if let Some(home) = std::env::var_os("HOME") { let p: PathBuf = home.into(); if !p.as_os_str().is_empty() { return p .join(".local") .join("share") .join("pmacs") .join("builtin-packages") .join(suffix); } } // Headless / sandboxed environment with neither HOME nor // XDG_DATA_HOME (CI containers, build daemons). Scope the temp // path to the calling user to avoid world-writable shared paths. let uid = nix::unistd::Uid::current().as_raw(); std::env::temp_dir().join(format!("pmacs-builtin-uid{uid}-{suffix}")) } /// Materialize every entry in [`BUNDLED_PACKAGES`] under `parent`, /// returning one [`InstalledPackage`] per package in registry /// order. Idempotent: per-file content gate skips writes when the /// existing bytes already match. Each package lands at /// `parent//`. /// /// `parent` must already exist (or be createable); this function /// creates it on demand. pub fn materialize_all(parent: &Path) -> Result, BundledError> { fs::create_dir_all(parent).map_err(|source| BundledError::Io { path: parent.to_path_buf(), source, })?; let mut out = Vec::with_capacity(BUNDLED_PACKAGES.len()); for bp in BUNDLED_PACKAGES { out.push(materialize_one(parent, bp)?); } Ok(out) } fn materialize_one(parent: &Path, bp: &BundledPackage) -> Result { let install_path = parent.join(bp.name); fs::create_dir_all(&install_path).map_err(|source| BundledError::Io { path: install_path.clone(), source, })?; // Manifest first --- write it to disk in addition to parsing it, // so a third-party tool inspecting the bundled tree on disk sees // a fully-formed package directory. let manifest_path = install_path.join("pmacs.toml"); write_if_changed(&manifest_path, bp.manifest_toml.as_bytes())?; let manifest = PackageManifest::from_toml(bp.manifest_toml).map_err(|source| BundledError::Manifest { name: bp.name.to_string(), source, })?; if manifest.name.as_str() != bp.name { return Err(BundledError::NameMismatch { registry: bp.name.to_string(), manifest: manifest.name.as_str().to_string(), }); } // Then every other file the package declared. Subdirs are // created on demand so an export at `submod/init.lua` works. for (rel, body) in bp.files { let p = install_path.join(rel); if let Some(d) = p.parent() { fs::create_dir_all(d).map_err(|source| BundledError::Io { path: d.to_path_buf(), source, })?; } write_if_changed(&p, body.as_bytes())?; } // Build the synthetic InstalledPackage record. The version / // pin / tag fields use the manifest's declared version verbatim // --- bundled packages don't have a Git history to draw a tag // and commit from, so we synthesize stable values that the rest // of the system treats consistently: // // * `commit` --- 40-char placeholder; the bundled package's // "identity" is its source bytes, which the // on-disk path captures. // * `tag` --- "bundled" (descriptive label visible to the // Lua introspection surface). // * `pin` --- exact-version pin matching the manifest. // * `scope` --- `User`, since the bundled tree shares the // same lookup precedence as user-installed // packages. (Adding a `Bundled` scope variant // would propagate through several enums; the // `tag = "bundled"` field already lets Lua // callers tell the two apart.) let version: Version = manifest.version.clone(); let pin = InstallPin::Version(VersionReq::parse(&format!("={version}")).unwrap_or( // exact-version syntax should always parse; fall back to // any-match if a future semver-crate version regresses. VersionReq::STAR, )); Ok(InstalledPackage { manifest, install_path, commit: "0".repeat(40), tag: "bundled".to_string(), version, scope: InstallScope::User, pin, }) } fn write_if_changed(path: &Path, bytes: &[u8]) -> Result<(), BundledError> { match fs::read(path) { Ok(existing) if existing == bytes => return Ok(()), Ok(_) | Err(_) => {} } fs::write(path, bytes).map_err(|source| BundledError::Io { path: path.to_path_buf(), source, }) } #[cfg(test)] mod tests { use super::*; #[test] fn embedded_repl_manifest_parses() { let m = BUNDLED_PACKAGES .iter() .find(|p| p.name == "repl") .expect("repl bundled"); let parsed = PackageManifest::from_toml(m.manifest_toml).expect("repl manifest parses"); assert_eq!(parsed.name.as_str(), "repl"); assert_eq!(parsed.entry.to_str().unwrap(), "init.lua"); assert!(parsed.exports.iter().any(|e| e == "repl")); } #[test] fn materialize_all_writes_files_and_returns_installed_packages() { let tmp = std::env::temp_dir().join(format!( "pmacs-builtin-test-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_nanos()) )); let pkgs = materialize_all(&tmp).expect("materialize"); assert_eq!(pkgs.len(), BUNDLED_PACKAGES.len()); let repl = pkgs .iter() .find(|p| p.manifest.name.as_str() == "repl") .expect("repl present"); assert!(repl.entry_path().exists(), "repl entry on disk"); assert!( repl.install_path.join("pmacs.toml").exists(), "repl manifest on disk" ); assert_eq!(repl.tag, "bundled"); // Idempotent re-materialization. let pkgs2 = materialize_all(&tmp).expect("re-materialize"); assert_eq!(pkgs2.len(), pkgs.len()); // Best-effort cleanup; failure is non-fatal. let _ = fs::remove_dir_all(&tmp); } #[test] fn write_if_changed_skips_when_bytes_match() { let tmp = std::env::temp_dir().join(format!( "pmacs-builtin-test-write-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_nanos()) )); fs::create_dir_all(&tmp).unwrap(); let p = tmp.join("x"); fs::write(&p, b"v1").unwrap(); let mtime1 = fs::metadata(&p).unwrap().modified().ok(); // small sleep to ensure the mtime would advance if we did write again std::thread::sleep(std::time::Duration::from_millis(15)); write_if_changed(&p, b"v1").unwrap(); let mtime2 = fs::metadata(&p).unwrap().modified().ok(); assert_eq!(mtime1, mtime2, "matching bytes should not retrigger write"); write_if_changed(&p, b"v2").unwrap(); assert_eq!(fs::read(&p).unwrap(), b"v2"); let _ = fs::remove_dir_all(&tmp); } }