1799 lines
70 KiB
Rust
1799 lines
70 KiB
Rust
// packages/installer.rs --- Synchronous package installer.
|
|
|
|
//! Package installer (T M7.3, spec §sec:packages-future).
|
|
//!
|
|
//! Given an [`InstallSpec`] (address + version constraint) and an
|
|
//! install root, performs:
|
|
//!
|
|
//! 1. **Fetch** via [`Fetcher::fetch`] (mirror clone or update).
|
|
//! 2. **Tag enumeration** via [`Fetcher::list_tags`] and best-match
|
|
//! selection against the user's [`VersionReq`].
|
|
//! 3. **Resolve** the chosen tag to a 40-char commit hash.
|
|
//! 4. **Manifest read** via [`Fetcher::show_blob`] so we can derive the
|
|
//! install directory name from `manifest.name`'s basename without
|
|
//! materializing the whole tree first.
|
|
//! 5. **Archive + extract** via [`Fetcher::archive_commit`] piped into
|
|
//! `tar -x`. The install dir is self-contained; no `.git` clutter,
|
|
//! no link back to the bare cache.
|
|
//!
|
|
//! v0.1 design notes:
|
|
//!
|
|
//! - **Synchronous**: errors surface back at the call site (typically
|
|
//! a Lua traceback inside `pmacs.packages.install{...}`). The
|
|
//! deferred-dispatcher pattern used for `pmacs.attach` does not
|
|
//! apply --- attach is a transport handoff, install is just I/O,
|
|
//! and a synchronous failure pins the offending `init.lua` line.
|
|
//! - **Standalone vs resolver-driven**. The installer has two
|
|
//! entry points. [`Installer::install`] is standalone: it
|
|
//! independently picks a tag for [`InstallPin::Version`] via
|
|
//! [`best_match`] and refuses to overwrite an existing install
|
|
//! at a different commit. [`Installer::install_at_commit`] /
|
|
//! [`Installer::replace_at_commit`] are the resolver-driven
|
|
//! paths: they trust the supplied commit and (for replace)
|
|
//! stage-and-swap an existing install. The Lua surface
|
|
//! (`pmacs.packages.install` / `update`) goes through the
|
|
//! resolver-driven paths so the resolver's revision choice is
|
|
//! authoritative; transitive resolution and lockfile writes
|
|
//! live in the surrounding orchestration code.
|
|
//! - **All three pin kinds supported**. [`InstallPin::Version`]
|
|
//! maps to `best_match` over upstream tags;
|
|
//! [`InstallPin::Branch`] resolves to the named branch's HEAD;
|
|
//! [`InstallPin::Commit`] resolves to a specific revision. The
|
|
//! user surface accepts all three via the table form (`{ ...,
|
|
//! branch = ... }` / `{ ..., commit = ... }`).
|
|
//! - **Install dir naming**: `<install_root>/<basename>/`, where
|
|
//! `basename` is the package name's last `/`-segment. Two
|
|
//! packages with the same basename collide on disk; for v0.1 we
|
|
//! accept the collision (the resolver / caller can
|
|
//! `pmacs.packages.installed()` to spot conflicts before they
|
|
//! bite).
|
|
|
|
use std::fs;
|
|
use std::io::{self, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
|
|
use semver::{Version, VersionReq};
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
use super::address::{Address, AddressError};
|
|
use super::fetcher::{FetchError, Fetcher, RefSpec};
|
|
use super::manifest::{ManifestError, PackageManifest};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// InstallScope
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Where a package should be installed.
|
|
///
|
|
/// `User` resolves to `$XDG_DATA_HOME/pmacs/packages/` (or
|
|
/// `$HOME/.local/share/pmacs/packages/` if `XDG_DATA_HOME` is unset).
|
|
/// `Project` resolves to `<project_root>/.pmacs/packages/`.
|
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
|
pub enum InstallScope {
|
|
/// User-config install (per-user, persistent across projects).
|
|
User,
|
|
/// Project-scoped install. The project root is conventionally the
|
|
/// editor's working directory at startup.
|
|
Project {
|
|
/// Project root directory; the install dir is `<root>/.pmacs/packages/`.
|
|
project_root: PathBuf,
|
|
},
|
|
}
|
|
|
|
impl InstallScope {
|
|
/// Resolve to the install root directory. Does not create it.
|
|
pub fn resolve_root(&self) -> Result<PathBuf, InstallError> {
|
|
match self {
|
|
Self::User => xdg_data_root(),
|
|
Self::Project { project_root } => Ok(project_root.join(".pmacs").join("packages")),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn xdg_data_root() -> Result<PathBuf, InstallError> {
|
|
if let Some(dir) = std::env::var_os("XDG_DATA_HOME") {
|
|
let p: PathBuf = dir.into();
|
|
if !p.as_os_str().is_empty() {
|
|
return Ok(p.join("pmacs").join("packages"));
|
|
}
|
|
}
|
|
if let Some(home) = std::env::var_os("HOME") {
|
|
let p: PathBuf = home.into();
|
|
if !p.as_os_str().is_empty() {
|
|
return Ok(p
|
|
.join(".local")
|
|
.join("share")
|
|
.join("pmacs")
|
|
.join("packages"));
|
|
}
|
|
}
|
|
Err(InstallError::NoDataHome)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// InstallSpec
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// What the user pinned the install to. Mutually exclusive on the
|
|
/// Lua side: a spec table may carry exactly one of `version`,
|
|
/// `branch`, or `commit`.
|
|
///
|
|
/// # Why three kinds
|
|
///
|
|
/// - [`Self::Version`] is the default, recommended path: the
|
|
/// installer picks the highest semver tag matching the constraint
|
|
/// and validates the manifest's declared version against the same
|
|
/// constraint. Lockfile reproduction (M7.6) records the resolved
|
|
/// commit so a later install at the same constraint yields the
|
|
/// same revision.
|
|
/// - [`Self::Branch`] follows a moving target. Each install
|
|
/// re-resolves the branch's HEAD; the install is *not*
|
|
/// reproducible across time. Useful for development against an
|
|
/// upstream's `main` or for a private package whose semver
|
|
/// discipline is not yet established.
|
|
/// - [`Self::Commit`] freezes the install at a specific revision.
|
|
/// Useful for pinning to a known-good state before the upstream
|
|
/// has tagged a release, or for reproducing a colleague's
|
|
/// environment exactly without semver drift.
|
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
|
pub enum InstallPin {
|
|
/// Highest semver tag satisfying the constraint.
|
|
Version(VersionReq),
|
|
/// HEAD of the named branch at install time.
|
|
Branch(String),
|
|
/// Specific commit (full or partial SHA; the fetcher accepts
|
|
/// either via `git rev-parse`).
|
|
Commit(String),
|
|
/// Working-tree symlink installed via
|
|
/// [`pmacs.packages.install_local`] (T M8.1c). Carries the
|
|
/// source path so the Lua-visible roster entry can show where
|
|
/// the live tree lives. Local-pinned packages are *ephemeral*:
|
|
/// they never enter the lockfile and aren't reproducible across
|
|
/// machines --- they exist for the M8 dev loop ("edit a
|
|
/// package's source on disk and reload without restarting") and
|
|
/// nothing else.
|
|
Local {
|
|
/// Source path the install dir symlinks to.
|
|
source_path: PathBuf,
|
|
},
|
|
}
|
|
|
|
impl InstallPin {
|
|
/// Stable string discriminator used at the Lua boundary
|
|
/// (`installed_package_to_lua`) and in error messages.
|
|
#[must_use]
|
|
pub fn kind(&self) -> &'static str {
|
|
match self {
|
|
Self::Version(_) => "version",
|
|
Self::Branch(_) => "branch",
|
|
Self::Commit(_) => "commit",
|
|
Self::Local { .. } => "local",
|
|
}
|
|
}
|
|
|
|
/// User-supplied value as a string: the constraint for
|
|
/// [`Self::Version`], the branch name for [`Self::Branch`], the
|
|
/// SHA for [`Self::Commit`], the source path for [`Self::Local`].
|
|
#[must_use]
|
|
pub fn value(&self) -> String {
|
|
match self {
|
|
Self::Version(req) => req.to_string(),
|
|
Self::Branch(b) => b.clone(),
|
|
Self::Commit(c) => c.clone(),
|
|
Self::Local { source_path } => source_path.display().to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Result of [`Installer::plan_local`]: everything needed to
|
|
/// commit a working-tree symlink install, with no disk changes
|
|
/// yet performed (T M8.1c).
|
|
///
|
|
/// The plan/commit split lets the Lua binding layer interleave
|
|
/// `on_unload`-hook execution between validation and the disk
|
|
/// swap. If a hook fails, the caller can drop the plan and the
|
|
/// disk is unchanged.
|
|
#[derive(Debug, Clone)]
|
|
pub struct LocalInstallPlan {
|
|
/// Parsed manifest from `<source_path>/pmacs.toml`.
|
|
pub manifest: PackageManifest,
|
|
/// Where the symlink will be placed:
|
|
/// `<install_root>/<basename>`.
|
|
pub install_path: PathBuf,
|
|
/// Canonicalized source path the symlink will point at. Holds
|
|
/// an absolute path so the symlink resolves regardless of
|
|
/// where the editor's CWD ends up.
|
|
pub canonical_source: PathBuf,
|
|
/// Install basename (the last `/`-segment of `manifest.name`).
|
|
/// Useful to the binding layer for keying registry slots
|
|
/// (`PackageUnloadHooks`, etc.) without re-deriving from the
|
|
/// manifest.
|
|
pub basename: String,
|
|
/// Scope (user / project) this install will land in.
|
|
pub scope: InstallScope,
|
|
}
|
|
|
|
/// A local install whose new symlink has already been created at a
|
|
/// sibling staging path, but has not yet been published over
|
|
/// [`LocalInstallPlan::install_path`].
|
|
///
|
|
/// The Lua binding uses this to front-load the fallible symlink
|
|
/// creation before it runs the prior package's `on_unload` hooks.
|
|
/// After hooks complete, publishing is a same-directory `rename(2)`.
|
|
#[derive(Debug, Clone)]
|
|
pub struct StagedLocalInstall {
|
|
plan: LocalInstallPlan,
|
|
staging_path: PathBuf,
|
|
}
|
|
|
|
/// A normalized install request: where to fetch and how to pin the
|
|
/// revision.
|
|
#[derive(Debug, Clone)]
|
|
pub struct InstallSpec {
|
|
/// Address (resolved via `Address::parse`).
|
|
pub address: Address,
|
|
/// What the user pinned the install to. See [`InstallPin`].
|
|
pub pin: InstallPin,
|
|
}
|
|
|
|
impl InstallSpec {
|
|
/// Parse the `<address>@<constraint>` shorthand. Examples:
|
|
///
|
|
/// - `github:user/repo@^1.0.0`
|
|
/// - `git:https://x.y/z.git@=2.3.4`
|
|
/// - `https://x.y/z.git@~0.5`
|
|
///
|
|
/// The `@` separator is always the **last** `@` in the string so
|
|
/// addresses containing an `@` (SSH shorthand: `git@host:path`) work.
|
|
/// Returns [`InstallError::ShorthandMissingVersion`] when the input
|
|
/// has no `@` separator and [`InstallError::Address`] /
|
|
/// [`InstallError::InvalidVersionReq`] for the underlying parse
|
|
/// failures.
|
|
///
|
|
/// The shorthand string form is **version-pin only**. Branch and
|
|
/// commit pins must use the Lua table form
|
|
/// (`{ "addr", branch = "..." }` / `{ "addr", commit = "..." }`)
|
|
/// because there is no concise sigil that disambiguates a
|
|
/// branch/commit value from a semver constraint without
|
|
/// surprising users (`@main` could be a branch named "main" or
|
|
/// a malformed semver --- ambiguous).
|
|
pub fn parse_shorthand(s: &str) -> Result<Self, InstallError> {
|
|
let (addr, ver) =
|
|
s.rsplit_once('@')
|
|
.ok_or_else(|| InstallError::ShorthandMissingVersion {
|
|
input: s.to_string(),
|
|
})?;
|
|
if addr.is_empty() || ver.is_empty() {
|
|
return Err(InstallError::ShorthandMissingVersion {
|
|
input: s.to_string(),
|
|
});
|
|
}
|
|
let address = Address::parse(addr).map_err(InstallError::Address)?;
|
|
let version = VersionReq::parse(ver).map_err(|e| InstallError::InvalidVersionReq {
|
|
value: ver.to_string(),
|
|
cause: e.to_string(),
|
|
})?;
|
|
Ok(Self {
|
|
address,
|
|
pin: InstallPin::Version(version),
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// InstalledPackage
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Result of a successful install.
|
|
#[derive(Debug, Clone)]
|
|
pub struct InstalledPackage {
|
|
/// Parsed `pmacs.toml` from the installed snapshot.
|
|
pub manifest: PackageManifest,
|
|
/// On-disk directory holding the installed snapshot.
|
|
pub install_path: PathBuf,
|
|
/// 40-char commit hash of the installed snapshot.
|
|
pub commit: String,
|
|
/// A descriptor of what was installed:
|
|
/// - For [`InstallPin::Version`]: the matched tag, e.g. `"v1.0.0"`.
|
|
/// - For [`InstallPin::Branch`]: `"branch:<name>"`.
|
|
/// - For [`InstallPin::Commit`]: `"commit:<short-sha>"`.
|
|
///
|
|
/// Always non-empty so Lua callers can use it as a stable
|
|
/// "what got installed" label without nil-checking.
|
|
pub tag: String,
|
|
/// Semver version of the installed snapshot. For
|
|
/// [`InstallPin::Version`] this is the version parsed from the
|
|
/// matched tag; for [`InstallPin::Branch`] / [`InstallPin::Commit`]
|
|
/// it falls back to `manifest.version` (the package's declared
|
|
/// version at the resolved revision).
|
|
pub version: Version,
|
|
/// The install scope this package was installed under.
|
|
pub scope: InstallScope,
|
|
/// What the user originally pinned this install to. Useful for
|
|
/// lockfile generation (M7.6) and for surfacing to the Lua
|
|
/// `installed()` snapshot.
|
|
pub pin: InstallPin,
|
|
}
|
|
|
|
impl InstalledPackage {
|
|
/// The basename used for the on-disk install dir and for the
|
|
/// Lua `require` name. Equal to `manifest.name`'s segment after
|
|
/// the optional `/` namespace prefix.
|
|
#[must_use]
|
|
pub fn install_basename(&self) -> &str {
|
|
package_basename(self.manifest.name.as_str())
|
|
}
|
|
|
|
/// Absolute path of the entry Lua module (`install_path` joined
|
|
/// with `manifest.entry`).
|
|
#[must_use]
|
|
pub fn entry_path(&self) -> PathBuf {
|
|
self.install_path.join(&self.manifest.entry)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Installer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Internal options passed through [`Installer::install_with`].
|
|
/// Public callers use [`Installer::install`] /
|
|
/// [`Installer::install_at_commit`] /
|
|
/// [`Installer::replace_at_commit`] which set these flags
|
|
/// appropriately.
|
|
#[derive(Debug, Default)]
|
|
struct InstallOptions {
|
|
/// If `Some`, treat this commit as the resolver's choice and
|
|
/// skip the installer's own tag/branch/commit lookup. The
|
|
/// manifest is read at this commit.
|
|
resolved_commit: Option<String>,
|
|
/// If `true`, an existing install at the same path with a
|
|
/// different commit is replaced rather than rejected with
|
|
/// [`InstallError::AlreadyInstalled`]. The replacement is
|
|
/// staged at `<install_path>.new` and only swapped in after
|
|
/// successful extraction.
|
|
replace_existing: bool,
|
|
}
|
|
|
|
/// Installer: pairs a [`Fetcher`] with an [`InstallScope`].
|
|
///
|
|
/// One `Installer` per scope; `LuaHost` constructs two (user-scoped and
|
|
/// project-scoped) at startup.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Installer {
|
|
fetcher: Fetcher,
|
|
scope: InstallScope,
|
|
/// Test override for the install root. Production callers leave
|
|
/// this `None`; the test harness sets it (the project forbids
|
|
/// `unsafe_code`, so mutating `XDG_DATA_HOME` directly is not an
|
|
/// option).
|
|
root_override: Option<PathBuf>,
|
|
}
|
|
|
|
impl Installer {
|
|
/// Construct an installer with an explicit fetcher and scope.
|
|
#[must_use]
|
|
pub fn new(fetcher: Fetcher, scope: InstallScope) -> Self {
|
|
Self {
|
|
fetcher,
|
|
scope,
|
|
root_override: None,
|
|
}
|
|
}
|
|
|
|
/// Builder: override the on-disk install root (test-facing).
|
|
/// Production code does not call this; the resolved
|
|
/// [`InstallScope`] determines the path.
|
|
#[must_use]
|
|
pub fn with_install_root_override(mut self, root: PathBuf) -> Self {
|
|
self.root_override = Some(root);
|
|
self
|
|
}
|
|
|
|
/// The scope this installer targets.
|
|
#[must_use]
|
|
pub fn scope(&self) -> &InstallScope {
|
|
&self.scope
|
|
}
|
|
|
|
/// Resolve the install root (creating it on demand).
|
|
pub fn install_root(&self) -> Result<PathBuf, InstallError> {
|
|
let root = match &self.root_override {
|
|
Some(p) => p.clone(),
|
|
None => self.scope.resolve_root()?,
|
|
};
|
|
fs::create_dir_all(&root).map_err(|source| InstallError::Io {
|
|
path: root.clone(),
|
|
source,
|
|
})?;
|
|
Ok(root)
|
|
}
|
|
|
|
/// Install one package. See module docs for the step-by-step flow.
|
|
///
|
|
/// `install()` is the standalone entry point: the installer
|
|
/// independently picks the tag for `InstallPin::Version` via
|
|
/// [`best_match`]. Resolver-driven flows
|
|
/// (`pmacs.packages.install` / `pmacs.packages.update`) instead
|
|
/// call [`Self::install_at_commit`] / [`Self::replace_at_commit`]
|
|
/// so the installer honors the resolver's revision choice rather
|
|
/// than re-deriving it.
|
|
pub fn install(&self, spec: &InstallSpec) -> Result<InstalledPackage, InstallError> {
|
|
self.install_with(spec, &InstallOptions::default())
|
|
}
|
|
|
|
/// Install at a commit pre-chosen by the resolver. The displayed
|
|
/// `pin` field on the returned [`InstalledPackage`] still
|
|
/// reflects `spec.pin` (so a `Version(^1.0.0)` request shows up
|
|
/// as a version pin), but the installer skips its own tag
|
|
/// enumeration and checks out the supplied commit directly.
|
|
/// The displayed `tag` is synthesized from `manifest.version` for
|
|
/// Version pins, or kept as `branch:<name>`/`commit:<short>` for
|
|
/// the other variants.
|
|
///
|
|
/// Refuses to overwrite an existing install at a different
|
|
/// commit; for that path see [`Self::replace_at_commit`].
|
|
pub fn install_at_commit(
|
|
&self,
|
|
spec: &InstallSpec,
|
|
commit: &str,
|
|
) -> Result<InstalledPackage, InstallError> {
|
|
self.install_with(
|
|
spec,
|
|
&InstallOptions {
|
|
resolved_commit: Some(commit.to_string()),
|
|
replace_existing: false,
|
|
},
|
|
)
|
|
}
|
|
|
|
/// Install or replace at a commit pre-chosen by the resolver.
|
|
/// Differs from [`Self::install_at_commit`] only in that an
|
|
/// existing install at the same path with a different commit is
|
|
/// replaced rather than erroring. The replacement is staged at
|
|
/// `<install_path>.new` and only swapped in after extraction
|
|
/// succeeds, so a failing update leaves the prior install intact.
|
|
///
|
|
/// Used by `pmacs.packages.update`, which by definition expects
|
|
/// to overwrite a prior install when upstream has moved.
|
|
pub fn replace_at_commit(
|
|
&self,
|
|
spec: &InstallSpec,
|
|
commit: &str,
|
|
) -> Result<InstalledPackage, InstallError> {
|
|
self.install_with(
|
|
spec,
|
|
&InstallOptions {
|
|
resolved_commit: Some(commit.to_string()),
|
|
replace_existing: true,
|
|
},
|
|
)
|
|
}
|
|
|
|
/// Install from a local working-tree path by symlinking it
|
|
/// into the install root (T M8.1c). The dev-loop counterpart
|
|
/// to [`Self::install`]: edits to files under `source_path`
|
|
/// become live in the editor without re-running the package
|
|
/// pipeline; `pmacs.packages.reload(name)` (M8.1d) picks them
|
|
/// up without restarting the session.
|
|
///
|
|
/// Semantics:
|
|
///
|
|
/// - `source_path` must contain a readable `pmacs.toml`.
|
|
/// Anything else fails with [`InstallError::LocalManifestMissing`].
|
|
/// - The install dir is `<install_root>/<basename>`. If a
|
|
/// symlink already lives there, it is replaced by staging a
|
|
/// sibling symlink and atomically renaming it into place.
|
|
/// - If a *real* directory lives at the install path,
|
|
/// [`InstallError::LocalRealInstallInWay`] surfaces. The user
|
|
/// removes that install first (manually or via a future
|
|
/// uninstall API).
|
|
/// - The returned [`InstalledPackage`] carries
|
|
/// [`InstallPin::Local`] so the Lua-visible roster entry
|
|
/// names the source. No lockfile work is done; `install_local`
|
|
/// is explicitly ephemeral.
|
|
pub fn install_local(&self, source_path: &Path) -> Result<InstalledPackage, InstallError> {
|
|
let plan = self.plan_local(source_path)?;
|
|
self.commit_local(plan)
|
|
}
|
|
|
|
/// Validate `source_path` and compute where its symlink should
|
|
/// land, **without making any disk changes**. The returned
|
|
/// [`LocalInstallPlan`] is consumed by [`Self::commit_local`],
|
|
/// which performs the symlink swap.
|
|
///
|
|
/// The plan/commit split exists so the Lua binding layer can
|
|
/// run prior-install `on_unload` hooks between the two steps:
|
|
/// if any hook fails, the disk symlink hasn't moved, so disk
|
|
/// and runtime state remain in sync. Without the split, a
|
|
/// failing hook leaves the symlink at the new source while the
|
|
/// roster / `package.loaded` / per-package env still track the
|
|
/// old one --- a desync the user can only resolve by
|
|
/// restarting.
|
|
pub fn plan_local(&self, source_path: &Path) -> Result<LocalInstallPlan, InstallError> {
|
|
// Manifest must exist and parse. A friendly error here
|
|
// beats a surprising error later when the searcher tries
|
|
// to load a non-existent entry.
|
|
let manifest_path = source_path.join("pmacs.toml");
|
|
let manifest_str = std::fs::read_to_string(&manifest_path).map_err(|e| {
|
|
InstallError::LocalManifestMissing {
|
|
source_path: source_path.to_path_buf(),
|
|
cause: e.to_string(),
|
|
}
|
|
})?;
|
|
let manifest = PackageManifest::from_toml(&manifest_str).map_err(|e| {
|
|
InstallError::LocalManifestMissing {
|
|
source_path: source_path.to_path_buf(),
|
|
cause: e.to_string(),
|
|
}
|
|
})?;
|
|
|
|
// pmacs_required check, identical to the fetched-install
|
|
// path. install_local doesn't bypass any compatibility
|
|
// gate; the dev-loop story doesn't extend to "ignore the
|
|
// version constraint."
|
|
let running_pmacs = running_pmacs_version();
|
|
if !manifest.pmacs_required.matches(&running_pmacs) {
|
|
return Err(InstallError::PmacsVersionIncompatible {
|
|
address: source_path.display().to_string(),
|
|
tag: format!("local:{}", source_path.display()),
|
|
required: manifest.pmacs_required.to_string(),
|
|
running: running_pmacs.to_string(),
|
|
});
|
|
}
|
|
|
|
let install_root = self.install_root()?;
|
|
let basename = package_basename(manifest.name.as_str()).to_string();
|
|
let install_path = install_root.join(&basename);
|
|
|
|
// Probe the install path. We don't mutate it here --- the
|
|
// commit step does. We do reject the real-directory case
|
|
// up front so the Lua binding layer can refuse before
|
|
// running any unload hooks (a hook running and then the
|
|
// commit failing because of a real-dir collision would be
|
|
// worse than refusing immediately).
|
|
match std::fs::symlink_metadata(&install_path) {
|
|
Ok(meta) if meta.file_type().is_symlink() => { /* ok, we'll replace */ }
|
|
Ok(_) => {
|
|
return Err(InstallError::LocalRealInstallInWay { install_path });
|
|
}
|
|
Err(e) if e.kind() == io::ErrorKind::NotFound => { /* ok, we'll create */ }
|
|
Err(source) => {
|
|
return Err(InstallError::Io {
|
|
path: install_path.clone(),
|
|
source,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Canonicalize the source so the symlink points at an
|
|
// absolute path. Without this, a relative source resolves
|
|
// against the install dir's parent rather than the user's
|
|
// CWD, and the user's CWD is the contract here.
|
|
let canonical_source =
|
|
std::fs::canonicalize(source_path).map_err(|source| InstallError::Io {
|
|
path: source_path.to_path_buf(),
|
|
source,
|
|
})?;
|
|
|
|
Ok(LocalInstallPlan {
|
|
manifest,
|
|
install_path,
|
|
canonical_source,
|
|
basename,
|
|
scope: self.scope.clone(),
|
|
})
|
|
}
|
|
|
|
/// Commit a [`LocalInstallPlan`] in one step: stage a new symlink
|
|
/// at a sibling temp path, then atomically rename it over
|
|
/// `plan.install_path`. After this returns, the plan's bytes are
|
|
/// live on disk; the caller is responsible for cache invalidation.
|
|
///
|
|
/// Callers that need to interleave package teardown hooks between
|
|
/// staging and publishing should use [`Self::stage_local`] followed
|
|
/// by [`Self::publish_local`].
|
|
///
|
|
/// **Atomicity.** The replacement uses `rename(2)` to swap the
|
|
/// staged symlink over the existing one. On the same
|
|
/// filesystem (which it is by construction --- the staging
|
|
/// path is in the same directory as `install_path`),
|
|
/// `rename(2)` is atomic with respect to other observers: at
|
|
/// any moment, `install_path` either holds the old symlink or
|
|
/// the new one, never neither. This is the upgrade from the
|
|
/// prior remove-then-create shape, where a `symlink(2)` failure
|
|
/// after the `unlink(2)` left the install path missing while
|
|
/// the runtime still tracked the old install.
|
|
///
|
|
/// Re-checks the install path's symlink-vs-real-dir state at
|
|
/// commit time: belt-and-braces against a TOCTOU between plan
|
|
/// and commit (the dev-loop is single-user, so a real
|
|
/// race is unlikely, but a `LocalRealInstallInWay` returned
|
|
/// here keeps the contract symmetric with [`Self::plan_local`]).
|
|
pub fn commit_local(&self, plan: LocalInstallPlan) -> Result<InstalledPackage, InstallError> {
|
|
let staged = self.stage_local(plan)?;
|
|
self.publish_local(staged)
|
|
}
|
|
|
|
/// Stage a [`LocalInstallPlan`] by creating the new symlink at a
|
|
/// hidden sibling path, but do not publish it over the live
|
|
/// install path yet.
|
|
///
|
|
/// This performs the fallible symlink-creation work before the
|
|
/// binding layer runs `on_unload` hooks. If staging fails, the old
|
|
/// package is still live and no teardown hooks have fired.
|
|
pub fn stage_local(&self, plan: LocalInstallPlan) -> Result<StagedLocalInstall, InstallError> {
|
|
// Re-check the install path. A real dir surfacing here
|
|
// would indicate either a TOCTOU race or a bug in the
|
|
// plan/commit caller; either way refuse rather than
|
|
// silently overwrite. Symlinks and missing paths are both
|
|
// valid commit destinations; the atomic rename below
|
|
// handles both shapes uniformly.
|
|
match std::fs::symlink_metadata(&plan.install_path) {
|
|
Ok(meta) if meta.file_type().is_symlink() => { /* ok, atomic swap */ }
|
|
Ok(_) => {
|
|
return Err(InstallError::LocalRealInstallInWay {
|
|
install_path: plan.install_path,
|
|
});
|
|
}
|
|
Err(e) if e.kind() == io::ErrorKind::NotFound => { /* ok, fresh create */ }
|
|
Err(source) => {
|
|
return Err(InstallError::Io {
|
|
path: plan.install_path.clone(),
|
|
source,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Stage the new symlink at a sibling path. Same directory
|
|
// as the install path means rename(2) is atomic. The
|
|
// sentinel-prefix (`.<basename>.swap.tmp`) is hidden in
|
|
// ls(1) output and namespaced so concurrent commits for
|
|
// different basenames don't collide. A leftover from a
|
|
// prior crashed commit would be unlinked here before we
|
|
// re-stage.
|
|
let staging_path = plan
|
|
.install_path
|
|
.with_file_name(format!(".{}.swap.tmp", plan.basename));
|
|
if let Err(e) = std::fs::remove_file(&staging_path)
|
|
&& e.kind() != io::ErrorKind::NotFound
|
|
{
|
|
return Err(InstallError::Io {
|
|
path: staging_path,
|
|
source: e,
|
|
});
|
|
}
|
|
symlink_create(&plan.canonical_source, &staging_path)?;
|
|
|
|
Ok(StagedLocalInstall { plan, staging_path })
|
|
}
|
|
|
|
/// Best-effort cleanup for a staged local install that will not be
|
|
/// published, typically because an `on_unload` hook failed.
|
|
pub fn discard_staged_local(&self, staged: StagedLocalInstall) {
|
|
let _ = std::fs::remove_file(staged.staging_path);
|
|
}
|
|
|
|
/// Publish a staged local install with a same-directory atomic
|
|
/// rename, returning the Lua-visible package record.
|
|
pub fn publish_local(
|
|
&self,
|
|
staged: StagedLocalInstall,
|
|
) -> Result<InstalledPackage, InstallError> {
|
|
let StagedLocalInstall { plan, staging_path } = staged;
|
|
|
|
// Atomic swap. rename(2) replaces install_path
|
|
// (whether or not it currently exists) in a single
|
|
// observable step. On failure the staged symlink is
|
|
// unlinked so we don't leave a dangling .swap.tmp file
|
|
// behind; the original install_path is untouched.
|
|
if let Err(source) = std::fs::rename(&staging_path, &plan.install_path) {
|
|
let _ = std::fs::remove_file(&staging_path);
|
|
return Err(InstallError::Io {
|
|
path: plan.install_path.clone(),
|
|
source,
|
|
});
|
|
}
|
|
|
|
Ok(InstalledPackage {
|
|
version: plan.manifest.version.clone(),
|
|
manifest: plan.manifest,
|
|
install_path: plan.install_path,
|
|
// No commit. The synthetic `local` token marks this as
|
|
// an ephemeral install in the Lua-visible roster
|
|
// (callers compare the `pin.kind` field, not commit).
|
|
commit: "local".to_string(),
|
|
tag: format!("local:{}", plan.canonical_source.display()),
|
|
scope: plan.scope,
|
|
pin: InstallPin::Local {
|
|
source_path: plan.canonical_source,
|
|
},
|
|
})
|
|
}
|
|
|
|
/// Unified install flow used by [`Self::install`],
|
|
/// [`Self::install_at_commit`], and [`Self::replace_at_commit`].
|
|
/// The three differ only in `opts`.
|
|
#[allow(clippy::too_many_lines)]
|
|
fn install_with(
|
|
&self,
|
|
spec: &InstallSpec,
|
|
opts: &InstallOptions,
|
|
) -> Result<InstalledPackage, InstallError> {
|
|
// Reject Local pins early: the fetched-install path needs a
|
|
// clone URL, and Local pins don't have one. install_local()
|
|
// owns the working-tree symlink path. T M8.1c.
|
|
if let InstallPin::Local { source_path } = &spec.pin {
|
|
return Err(InstallError::LocalPinNotSupported {
|
|
source_path: source_path.clone(),
|
|
});
|
|
}
|
|
let url = spec.address.to_git_url();
|
|
let bare = self.fetcher.fetch(&url).map_err(InstallError::Fetch)?;
|
|
|
|
// Resolve the user's pin to a concrete (commit, tag-descriptor)
|
|
// pair. When the resolver has supplied a commit, we use it
|
|
// directly: this keeps the installer aligned with the
|
|
// resolver's choice for InstallPin::Version (where re-running
|
|
// best_match() could otherwise diverge if upstream tagged a
|
|
// newer version that the resolver rejected for compatibility
|
|
// reasons). The descriptor for Version pins is synthesized
|
|
// from manifest.version after we read the manifest.
|
|
let (commit, tag_descriptor) = if let Some(forced) = opts.resolved_commit.as_deref() {
|
|
let commit = self
|
|
.fetcher
|
|
.resolve(&bare, &RefSpec::Commit(forced.to_string()))
|
|
.map_err(InstallError::Fetch)?;
|
|
let descriptor = match &spec.pin {
|
|
// Replaced post-manifest-read below.
|
|
InstallPin::Version(_) => String::new(),
|
|
InstallPin::Branch(name) => format!("branch:{name}"),
|
|
InstallPin::Commit(_) => {
|
|
let short = commit.get(..7).unwrap_or(commit.as_str()).to_string();
|
|
format!("commit:{short}")
|
|
}
|
|
InstallPin::Local { .. } => {
|
|
unreachable!("Local pins refused at install_with entry")
|
|
}
|
|
};
|
|
(commit, descriptor)
|
|
} else {
|
|
match &spec.pin {
|
|
InstallPin::Version(req) => {
|
|
let tags = self.fetcher.list_tags(&bare).map_err(InstallError::Fetch)?;
|
|
let chosen =
|
|
best_match(&tags, req).ok_or_else(|| InstallError::NoMatchingVersion {
|
|
address: url.clone(),
|
|
req: req.to_string(),
|
|
available: tags.clone(),
|
|
})?;
|
|
let commit = self
|
|
.fetcher
|
|
.resolve(&bare, &RefSpec::Tag(chosen.tag.clone()))
|
|
.map_err(InstallError::Fetch)?;
|
|
(commit, chosen.tag)
|
|
}
|
|
InstallPin::Branch(name) => {
|
|
let commit = self
|
|
.fetcher
|
|
.resolve(&bare, &RefSpec::Branch(name.clone()))
|
|
.map_err(InstallError::Fetch)?;
|
|
(commit, format!("branch:{name}"))
|
|
}
|
|
InstallPin::Commit(sha) => {
|
|
let commit = self
|
|
.fetcher
|
|
.resolve(&bare, &RefSpec::Commit(sha.clone()))
|
|
.map_err(InstallError::Fetch)?;
|
|
let short = commit.get(..7).unwrap_or(commit.as_str()).to_string();
|
|
(commit, format!("commit:{short}"))
|
|
}
|
|
InstallPin::Local { .. } => {
|
|
unreachable!("Local pins refused at install_with entry")
|
|
}
|
|
}
|
|
};
|
|
|
|
// Read the manifest at this commit so we know the install dir name.
|
|
let manifest_bytes = self
|
|
.fetcher
|
|
.show_blob(&bare, &commit, "pmacs.toml")
|
|
.map_err(|e| match e {
|
|
FetchError::GitInvocation { stderr, .. } => InstallError::ManifestMissing {
|
|
address: url.clone(),
|
|
tag: tag_descriptor.clone(),
|
|
cause: stderr,
|
|
},
|
|
other => InstallError::Fetch(other),
|
|
})?;
|
|
let manifest_str =
|
|
std::str::from_utf8(&manifest_bytes).map_err(|_| InstallError::ManifestNotUtf8 {
|
|
address: url.clone(),
|
|
tag: tag_descriptor.clone(),
|
|
})?;
|
|
let manifest = PackageManifest::from_toml(manifest_str).map_err(InstallError::Manifest)?;
|
|
|
|
// Synthesize the Version-pin descriptor now that we know the
|
|
// manifest version. Mirrors the conventional `v{X.Y.Z}` form
|
|
// produced by the standalone tag-matching path.
|
|
let tag_descriptor =
|
|
if opts.resolved_commit.is_some() && matches!(spec.pin, InstallPin::Version(_)) {
|
|
format!("v{}", manifest.version)
|
|
} else {
|
|
tag_descriptor
|
|
};
|
|
|
|
// Refuse to install a package whose `pmacs_required` constraint
|
|
// does not match the running pmacs version. Applies to every
|
|
// pin kind: a package's declared API requirements are
|
|
// independent of how the user pinned the revision.
|
|
let running_pmacs = running_pmacs_version();
|
|
if !manifest.pmacs_required.matches(&running_pmacs) {
|
|
return Err(InstallError::PmacsVersionIncompatible {
|
|
address: url.clone(),
|
|
tag: tag_descriptor.clone(),
|
|
required: manifest.pmacs_required.to_string(),
|
|
running: running_pmacs.to_string(),
|
|
});
|
|
}
|
|
|
|
// For version pins only: cross-check that the manifest's
|
|
// declared version satisfies the constraint. The matched tag
|
|
// already satisfies it (we chose it that way); the strict
|
|
// check is on the manifest, which catches packages whose tag
|
|
// and pmacs.toml version disagree. Branch and commit pins
|
|
// skip this check --- the user explicitly asked for that
|
|
// revision regardless of what the manifest says.
|
|
if let InstallPin::Version(req) = &spec.pin
|
|
&& !req.matches(&manifest.version)
|
|
{
|
|
return Err(InstallError::ManifestVersionMismatch {
|
|
address: url.clone(),
|
|
tag: tag_descriptor.clone(),
|
|
manifest_version: manifest.version.to_string(),
|
|
req: req.to_string(),
|
|
});
|
|
}
|
|
|
|
// Archive + extract.
|
|
let install_root = self.install_root()?;
|
|
let basename = package_basename(manifest.name.as_str());
|
|
let install_path = install_root.join(basename);
|
|
|
|
// If the install path already exists with the same commit,
|
|
// treat as idempotent. With a different commit, behavior
|
|
// depends on `opts.replace_existing`: the standalone install
|
|
// path refuses (`pmacs.packages.install` callers reach this
|
|
// when re-running install with a moved upstream and should
|
|
// be told to use `update`); the resolver-driven update path
|
|
// proceeds to a staged replacement.
|
|
let mut needs_replace = false;
|
|
if install_path.exists() {
|
|
let existing = read_install_marker(&install_path).ok();
|
|
match existing {
|
|
Some(prev) if prev == commit => {
|
|
return Ok(InstalledPackage {
|
|
version: manifest.version.clone(),
|
|
manifest,
|
|
install_path,
|
|
commit,
|
|
tag: tag_descriptor,
|
|
scope: self.scope.clone(),
|
|
pin: spec.pin.clone(),
|
|
});
|
|
}
|
|
_ if opts.replace_existing => {
|
|
needs_replace = true;
|
|
}
|
|
_ => {
|
|
return Err(InstallError::AlreadyInstalled {
|
|
path: install_path,
|
|
existing_commit: existing,
|
|
requested_commit: commit,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let archive = self
|
|
.fetcher
|
|
.archive_commit(&bare, &commit)
|
|
.map_err(InstallError::Fetch)?;
|
|
|
|
// Staging path: when replacing, extract to a sibling dir and
|
|
// only rename into place after success, so a failing replace
|
|
// leaves the prior install untouched. Same-filesystem rename
|
|
// makes the swap visible atomically; a crash between removing
|
|
// the old dir and renaming the staged dir leaves the staged
|
|
// dir in place, which is recoverable on next run.
|
|
let extract_target = if needs_replace {
|
|
let staged = install_path.with_extension("new");
|
|
// A leftover staging dir from a prior crash would
|
|
// confuse `create_dir_all` semantics; clear it first.
|
|
if staged.exists() {
|
|
fs::remove_dir_all(&staged).map_err(|source| InstallError::Io {
|
|
path: staged.clone(),
|
|
source,
|
|
})?;
|
|
}
|
|
staged
|
|
} else {
|
|
install_path.clone()
|
|
};
|
|
|
|
fs::create_dir_all(&extract_target).map_err(|source| InstallError::Io {
|
|
path: extract_target.clone(),
|
|
source,
|
|
})?;
|
|
if let Err(e) = extract_tar(&archive, &extract_target) {
|
|
// Roll back partial extraction. For the staging path
|
|
// this leaves the prior install untouched; for the
|
|
// direct path this leaves the install root clean.
|
|
let _ = fs::remove_dir_all(&extract_target);
|
|
return Err(e);
|
|
}
|
|
write_install_marker(&extract_target, &commit)?;
|
|
|
|
if needs_replace {
|
|
// Swap with rollback: rename the old install aside,
|
|
// rename the staged dir into place, then remove the
|
|
// backup. If the second rename fails, restore from the
|
|
// backup so the prior install survives the failed
|
|
// update. The two renames are individually atomic on the
|
|
// same filesystem; the only window where neither dir
|
|
// sits at `install_path` is between them, and a crash in
|
|
// that window leaves both `.old` and `.new` siblings
|
|
// for manual recovery.
|
|
let backup = install_path.with_extension("old");
|
|
// Clear any leftover backup from a prior crash.
|
|
if backup.exists() {
|
|
fs::remove_dir_all(&backup).map_err(|source| InstallError::Io {
|
|
path: backup.clone(),
|
|
source,
|
|
})?;
|
|
}
|
|
fs::rename(&install_path, &backup).map_err(|source| InstallError::Io {
|
|
path: install_path.clone(),
|
|
source,
|
|
})?;
|
|
if let Err(source) = fs::rename(&extract_target, &install_path) {
|
|
// Restore. If even the restore fails, surface the
|
|
// original error --- the operator now needs to
|
|
// manually swap `<path>.old` back into place, but
|
|
// we've at least preserved the bytes.
|
|
let _ = fs::rename(&backup, &install_path);
|
|
return Err(InstallError::Io {
|
|
path: install_path.clone(),
|
|
source,
|
|
});
|
|
}
|
|
// Both renames succeeded --- safe to drop the backup.
|
|
// A failure here leaves `<path>.old` behind (best-
|
|
// effort): the new install is correct on disk, just a
|
|
// disk-space leak.
|
|
let _ = fs::remove_dir_all(&backup);
|
|
}
|
|
|
|
Ok(InstalledPackage {
|
|
version: manifest.version.clone(),
|
|
manifest,
|
|
install_path,
|
|
commit,
|
|
tag: tag_descriptor,
|
|
scope: self.scope.clone(),
|
|
pin: spec.pin.clone(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tag selection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct TagMatch {
|
|
tag: String,
|
|
version: Version,
|
|
}
|
|
|
|
/// Pick the highest-numbered tag satisfying `req`.
|
|
///
|
|
/// Tags are filtered through [`parse_tag_as_semver`]: tags that don't
|
|
/// parse as semver are silently skipped (release notes, internal markers,
|
|
/// etc.). Tags with a leading `v` are accepted (the `v1.2.3` convention
|
|
/// is universal); the returned `tag` field preserves the original form.
|
|
fn best_match(tags: &[String], req: &VersionReq) -> Option<TagMatch> {
|
|
let mut best: Option<TagMatch> = None;
|
|
for tag in tags {
|
|
let Some(version) = parse_tag_as_semver(tag) else {
|
|
continue;
|
|
};
|
|
if !req.matches(&version) {
|
|
continue;
|
|
}
|
|
match &best {
|
|
None => {
|
|
best = Some(TagMatch {
|
|
tag: tag.clone(),
|
|
version,
|
|
});
|
|
}
|
|
Some(current) if version > current.version => {
|
|
best = Some(TagMatch {
|
|
tag: tag.clone(),
|
|
version,
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
best
|
|
}
|
|
|
|
fn parse_tag_as_semver(tag: &str) -> Option<Version> {
|
|
let stripped = tag.strip_prefix('v').unwrap_or(tag);
|
|
Version::parse(stripped).ok()
|
|
}
|
|
|
|
/// Strip a `<owner>/` namespace prefix from a manifest name and
|
|
/// return the trailing segment used for the on-disk install dir
|
|
/// and for `require()` lookup. `"magit"` → `"magit"`,
|
|
/// `"user/magit"` → `"magit"`. The `pub(crate)` exposure lets
|
|
/// `lua_bindings::do_update` derive the basename for a lockfile
|
|
/// entry without re-implementing the rule.
|
|
pub(crate) fn package_basename(name: &str) -> &str {
|
|
match name.rsplit_once('/') {
|
|
Some((_, last)) => last,
|
|
None => name,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tar extraction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Pipe `archive` into `tar -xC <dest>` and wait for completion.
|
|
///
|
|
/// `tar` is invoked with explicit options so it works the same on GNU
|
|
/// tar and BSD tar (macOS): `-x` extracts, `-f -` reads from stdin,
|
|
/// `-C <dest>` changes directory before extracting.
|
|
fn extract_tar(archive: &[u8], dest: &Path) -> Result<(), InstallError> {
|
|
let mut cmd = Command::new("tar");
|
|
cmd.arg("-x").arg("-f").arg("-").arg("-C").arg(dest);
|
|
cmd.stdin(Stdio::piped());
|
|
cmd.stdout(Stdio::piped());
|
|
cmd.stderr(Stdio::piped());
|
|
let mut child = cmd.spawn().map_err(|e| match e.kind() {
|
|
io::ErrorKind::NotFound => InstallError::TarNotFound,
|
|
_ => InstallError::TarSpawn { source: e },
|
|
})?;
|
|
{
|
|
let mut stdin = child
|
|
.stdin
|
|
.take()
|
|
.expect("tar stdin handle present after Stdio::piped");
|
|
stdin
|
|
.write_all(archive)
|
|
.map_err(|source| InstallError::TarSpawn { source })?;
|
|
// stdin drops here, closing the pipe so tar can finish.
|
|
}
|
|
let output = child
|
|
.wait_with_output()
|
|
.map_err(|source| InstallError::TarSpawn { source })?;
|
|
if !output.status.success() {
|
|
return Err(InstallError::TarFailed {
|
|
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Install marker
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MARKER_NAME: &str = ".pmacs-install";
|
|
|
|
/// Create a symlink at `link` pointing at `target`. Unix-only in
|
|
/// v0.1; pmacs doesn't ship Windows builds and `std::os::unix`'s
|
|
/// symlink semantics are what dired/wdired need (the link is the
|
|
/// thing being managed; the target is data).
|
|
fn symlink_create(target: &Path, link: &Path) -> Result<(), InstallError> {
|
|
#[cfg(unix)]
|
|
{
|
|
std::os::unix::fs::symlink(target, link).map_err(|source| InstallError::Io {
|
|
path: link.to_path_buf(),
|
|
source,
|
|
})
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
let _ = (target, link);
|
|
Err(InstallError::Io {
|
|
path: link.to_path_buf(),
|
|
source: io::Error::new(
|
|
io::ErrorKind::Unsupported,
|
|
"install_local requires Unix symlink support",
|
|
),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn write_install_marker(install_path: &Path, commit: &str) -> Result<(), InstallError> {
|
|
let p = install_path.join(MARKER_NAME);
|
|
fs::write(&p, format!("{commit}\n")).map_err(|source| InstallError::Io { path: p, source })
|
|
}
|
|
|
|
fn read_install_marker(install_path: &Path) -> Result<String, io::Error> {
|
|
let p = install_path.join(MARKER_NAME);
|
|
let s = fs::read_to_string(&p)?;
|
|
Ok(s.trim().to_string())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Errors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Errors raised during package install.
|
|
#[derive(Debug, Error)]
|
|
pub enum InstallError {
|
|
/// `$XDG_DATA_HOME` and `$HOME` were both unset.
|
|
#[error("cannot resolve XDG data directory: HOME and XDG_DATA_HOME are both unset")]
|
|
NoDataHome,
|
|
/// [`Installer::install`] / [`Installer::install_at_commit`] /
|
|
/// [`Installer::replace_at_commit`] received an
|
|
/// [`InstallPin::Local`]. Local pins must go through
|
|
/// [`Installer::install_local`] (T M8.1c); routing them to the
|
|
/// fetched-install path would require a clone URL that doesn't
|
|
/// exist for working-tree installs.
|
|
#[error(
|
|
"InstallPin::Local cannot be installed via the fetched-install path; \
|
|
use Installer::install_local for source path `{source_path}`"
|
|
)]
|
|
LocalPinNotSupported {
|
|
/// The source path the Local pin named.
|
|
source_path: PathBuf,
|
|
},
|
|
/// [`Installer::install_local`] was given a path that doesn't
|
|
/// contain a readable `pmacs.toml`. The package layout
|
|
/// requirements are documented in the package author guide; the
|
|
/// user typically forgot to write the manifest or pointed at
|
|
/// the wrong directory.
|
|
#[error("install_local: no readable pmacs.toml at `{source_path}`: {cause}")]
|
|
LocalManifestMissing {
|
|
/// The source path the user passed.
|
|
source_path: PathBuf,
|
|
/// The underlying I/O or parse error message.
|
|
cause: String,
|
|
},
|
|
/// [`Installer::install_local`] was asked to install at a name
|
|
/// that already has a real (non-symlink) install. The user must
|
|
/// uninstall the fetched copy first. We refuse rather than
|
|
/// silently replace because losing a fetched-install tree is a
|
|
/// real risk (it might contain manual edits the user made
|
|
/// before discovering `install_local`).
|
|
#[error(
|
|
"install_local: `{install_path}` is a real install, not a symlink; \
|
|
remove it first, then re-run install_local"
|
|
)]
|
|
LocalRealInstallInWay {
|
|
/// The install dir that's blocking the new symlink.
|
|
install_path: PathBuf,
|
|
},
|
|
/// Underlying fetch/clone/resolve operation failed.
|
|
#[error(transparent)]
|
|
Fetch(#[from] FetchError),
|
|
/// Address parsing failed (typically only via `parse_shorthand`).
|
|
#[error(transparent)]
|
|
Address(AddressError),
|
|
/// Manifest parsing failed.
|
|
#[error(transparent)]
|
|
Manifest(#[from] ManifestError),
|
|
/// Generic I/O error during install.
|
|
#[error("install I/O error at `{path}`: {source}")]
|
|
Io {
|
|
/// The path being acted on.
|
|
path: PathBuf,
|
|
/// The underlying I/O error.
|
|
#[source]
|
|
source: io::Error,
|
|
},
|
|
/// `pmacs.packages.install("addr@constraint")` lacked the `@`
|
|
/// separator.
|
|
#[error(
|
|
"missing `@<version>` in shorthand `{input}`; \
|
|
expected e.g. `github:user/repo@^1.0.0`"
|
|
)]
|
|
ShorthandMissingVersion {
|
|
/// The offending input.
|
|
input: String,
|
|
},
|
|
/// `parse_shorthand` got an `@<value>` that isn't a valid `VersionReq`.
|
|
#[error("invalid version constraint `{value}`: {cause}")]
|
|
InvalidVersionReq {
|
|
/// The offending constraint string.
|
|
value: String,
|
|
/// The semver crate's parse-error message.
|
|
cause: String,
|
|
},
|
|
/// No tag satisfied the user's [`VersionReq`].
|
|
#[error(
|
|
"no tag for `{address}` satisfies `{req}`. \
|
|
Available tags: {available:?}"
|
|
)]
|
|
NoMatchingVersion {
|
|
/// The clone URL the search ran against.
|
|
address: String,
|
|
/// The user's constraint, formatted for humans.
|
|
req: String,
|
|
/// The full tag list (filtered by the caller's eyes, not by
|
|
/// semver-parseability).
|
|
available: Vec<String>,
|
|
},
|
|
/// A repo was fetched and the tag resolved, but no `pmacs.toml`
|
|
/// existed at that commit.
|
|
#[error(
|
|
"no pmacs.toml at tag `{tag}` of `{address}` \
|
|
(this may not be a pmacs package): {cause}"
|
|
)]
|
|
ManifestMissing {
|
|
/// The clone URL the search ran against.
|
|
address: String,
|
|
/// The tag the install would have used.
|
|
tag: String,
|
|
/// The git stderr message.
|
|
cause: String,
|
|
},
|
|
/// `pmacs.toml` at the commit wasn't UTF-8.
|
|
#[error("pmacs.toml at tag `{tag}` of `{address}` is not valid UTF-8")]
|
|
ManifestNotUtf8 {
|
|
/// The clone URL.
|
|
address: String,
|
|
/// The tag that was being installed.
|
|
tag: String,
|
|
},
|
|
/// The manifest's `version` doesn't satisfy the user's constraint
|
|
/// even though the tag did. Indicates a packaging bug upstream
|
|
/// (tag and manifest disagree).
|
|
#[error(
|
|
"package at tag `{tag}` of `{address}` declares version \
|
|
`{manifest_version}`, which does not satisfy `{req}` \
|
|
(the upstream's tag and manifest disagree)"
|
|
)]
|
|
ManifestVersionMismatch {
|
|
/// The clone URL.
|
|
address: String,
|
|
/// The tag that was matched.
|
|
tag: String,
|
|
/// The version declared in `pmacs.toml`.
|
|
manifest_version: String,
|
|
/// The user's constraint.
|
|
req: String,
|
|
},
|
|
/// A different version of this package is already installed at the
|
|
/// target path. Caller must `update` rather than `install`.
|
|
#[error(
|
|
"package already installed at `{path}` \
|
|
(existing commit: {existing_commit:?}, requested: {requested_commit}); \
|
|
use `pmacs.packages.update(\"...\")` to change versions"
|
|
)]
|
|
AlreadyInstalled {
|
|
/// On-disk install dir.
|
|
path: PathBuf,
|
|
/// Commit hash recorded in the install marker, if readable.
|
|
existing_commit: Option<String>,
|
|
/// Commit hash that the new install would have used.
|
|
requested_commit: String,
|
|
},
|
|
/// `tar` is not on PATH.
|
|
#[error(
|
|
"the `tar` binary was not found on PATH. \
|
|
Pmacs's package install uses `git archive | tar -x` to \
|
|
materialize a snapshot; install GNU tar or bsdtar."
|
|
)]
|
|
TarNotFound,
|
|
/// `tar` failed to spawn or its stdin write failed.
|
|
#[error("could not spawn or feed tar: {source}")]
|
|
TarSpawn {
|
|
/// The underlying I/O error.
|
|
#[source]
|
|
source: io::Error,
|
|
},
|
|
/// `tar` exited with a non-zero status.
|
|
#[error("tar extraction failed: {stderr}")]
|
|
TarFailed {
|
|
/// Captured stderr from `tar`.
|
|
stderr: String,
|
|
},
|
|
/// The package's `pmacs_required` constraint does not match the
|
|
/// running pmacs version. The manifest field is a hard contract
|
|
/// (the package may rely on APIs not present in this pmacs), so
|
|
/// we refuse the install rather than letting the failure surface
|
|
/// later as an opaque Lua runtime error.
|
|
#[error(
|
|
"package at tag `{tag}` of `{address}` requires pmacs `{required}`, \
|
|
but this pmacs is `{running}`. \
|
|
Upgrade pmacs, or pin a package version compatible with `{running}`."
|
|
)]
|
|
PmacsVersionIncompatible {
|
|
/// The clone URL.
|
|
address: String,
|
|
/// The tag the install was about to use.
|
|
tag: String,
|
|
/// The constraint declared by the package's `pmacs_required`.
|
|
required: String,
|
|
/// The running pmacs version (`env!("CARGO_PKG_VERSION")`).
|
|
running: String,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Running-pmacs-version probe
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Return the running pmacs version as a [`semver::Version`].
|
|
///
|
|
/// The string comes from Cargo's `CARGO_PKG_VERSION` build-time env
|
|
/// var (so it tracks the workspace's `Cargo.toml` automatically).
|
|
/// `Cargo` enforces semver-shaped versions, so the parse cannot fail
|
|
/// in a release build; an `expect` here is a build-system invariant
|
|
/// guard, not user-reachable.
|
|
#[must_use]
|
|
pub fn running_pmacs_version() -> Version {
|
|
Version::parse(env!("CARGO_PKG_VERSION"))
|
|
.expect("CARGO_PKG_VERSION must be valid semver (Cargo guarantees this)")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::ffi::OsStr;
|
|
use std::process::Command as StdCommand;
|
|
use tempfile::TempDir;
|
|
|
|
// -- helpers ------------------------------------------------------------
|
|
|
|
fn run_git(args: &[&OsStr], cwd: Option<&Path>) {
|
|
let mut cmd = StdCommand::new("git");
|
|
if let Some(d) = cwd {
|
|
cmd.current_dir(d);
|
|
}
|
|
for a in args {
|
|
cmd.arg(a);
|
|
}
|
|
cmd.env("GIT_TERMINAL_PROMPT", "0");
|
|
cmd.env("LC_ALL", "C");
|
|
let out = cmd.output().unwrap_or_else(|e| panic!("git spawn: {e}"));
|
|
assert!(
|
|
out.status.success(),
|
|
"git {args:?} failed: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
}
|
|
|
|
/// Build a sample-package bare repo with two tagged versions and a
|
|
/// `pmacs.toml` at each. Returns `(tempdir, bare_path)`.
|
|
fn make_sample_package() -> (TempDir, PathBuf) {
|
|
let td = tempfile::tempdir().unwrap();
|
|
let work = td.path().join("work");
|
|
let bare = td.path().join("upstream.git");
|
|
|
|
init_git_workdir(&work);
|
|
commit_version(&work, "samplepkg", "1.0.0", "v1.0.0");
|
|
commit_version(&work, "samplepkg", "1.1.0", "v1.1.0");
|
|
clone_bare(&work, &bare);
|
|
|
|
(td, bare)
|
|
}
|
|
|
|
fn init_git_workdir(work: &Path) {
|
|
run_git(
|
|
&[
|
|
OsStr::new("init"),
|
|
OsStr::new("--initial-branch=main"),
|
|
work.as_os_str(),
|
|
],
|
|
None,
|
|
);
|
|
run_git_at(work, &["config", "user.email", "test@example.com"]);
|
|
run_git_at(work, &["config", "user.name", "Tester"]);
|
|
}
|
|
|
|
fn commit_version(work: &Path, name: &str, version: &str, tag: &str) {
|
|
write_manifest(work, name, version);
|
|
std::fs::write(
|
|
work.join("init.lua"),
|
|
format!("return {{ version = '{version}' }}\n"),
|
|
)
|
|
.unwrap();
|
|
run_git_at(work, &["add", "."]);
|
|
run_git_at(work, &["commit", "-m", tag]);
|
|
run_git_at(work, &["tag", tag]);
|
|
}
|
|
|
|
fn clone_bare(work: &Path, bare: &Path) {
|
|
run_git(
|
|
&[
|
|
OsStr::new("clone"),
|
|
OsStr::new("--bare"),
|
|
work.as_os_str(),
|
|
bare.as_os_str(),
|
|
],
|
|
None,
|
|
);
|
|
}
|
|
|
|
/// `git -C <work> <args...>` shorthand: prepends `-C` and the work
|
|
/// path so each call site stays one line.
|
|
fn run_git_at(work: &Path, args: &[&str]) {
|
|
let mut full: Vec<&OsStr> = vec![OsStr::new("-C"), work.as_os_str()];
|
|
full.extend(args.iter().map(OsStr::new));
|
|
run_git(&full, None);
|
|
}
|
|
|
|
fn write_manifest(work: &Path, name: &str, version: &str) {
|
|
let toml = format!(
|
|
r#"name = "{name}"
|
|
version = "{version}"
|
|
summary = "test package"
|
|
pmacs_required = ">= 0.1.0"
|
|
entry = "init.lua"
|
|
exports = ["samplepkg"]
|
|
"#
|
|
);
|
|
std::fs::write(work.join("pmacs.toml"), toml).unwrap();
|
|
}
|
|
|
|
fn file_url(p: &Path) -> String {
|
|
format!("file://{}", p.display())
|
|
}
|
|
|
|
fn make_installer(install_root: &Path) -> (TempDir, Installer) {
|
|
let cache = tempfile::tempdir().unwrap();
|
|
let fetcher = Fetcher::with_cache_dir(cache.path().to_path_buf());
|
|
let scope = InstallScope::Project {
|
|
project_root: install_root.to_path_buf(),
|
|
};
|
|
let installer = Installer::new(fetcher, scope);
|
|
(cache, installer)
|
|
}
|
|
|
|
// -- shorthand ----------------------------------------------------------
|
|
|
|
#[test]
|
|
fn shorthand_parses_github_address_with_caret_constraint() {
|
|
let s = InstallSpec::parse_shorthand("github:user/repo@^1.0.0").unwrap();
|
|
assert!(matches!(s.address, Address::Github { .. }));
|
|
match &s.pin {
|
|
InstallPin::Version(req) => assert_eq!(req.to_string(), "^1.0.0"),
|
|
other => panic!("expected Version pin, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shorthand_handles_address_containing_at_sign() {
|
|
// SSH shorthand: `git:git@host:path`. The `@` in `git@host`
|
|
// must not be confused with the version separator.
|
|
let s = InstallSpec::parse_shorthand("git:git@host:path/repo.git@=1.2.3").unwrap();
|
|
match &s.pin {
|
|
InstallPin::Version(req) => assert_eq!(req.to_string(), "=1.2.3"),
|
|
other => panic!("expected Version pin, got {other:?}"),
|
|
}
|
|
if let Address::Url(u) = s.address {
|
|
assert_eq!(u, "git@host:path/repo.git");
|
|
} else {
|
|
panic!("expected Url, got {:?}", s.address);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shorthand_without_at_separator_errors() {
|
|
let err = InstallSpec::parse_shorthand("github:user/repo").unwrap_err();
|
|
assert!(matches!(err, InstallError::ShorthandMissingVersion { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn shorthand_rejects_empty_version_after_at() {
|
|
let err = InstallSpec::parse_shorthand("github:user/repo@").unwrap_err();
|
|
assert!(matches!(err, InstallError::ShorthandMissingVersion { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn shorthand_rejects_invalid_constraint() {
|
|
let err = InstallSpec::parse_shorthand("github:user/repo@not-a-version").unwrap_err();
|
|
assert!(matches!(err, InstallError::InvalidVersionReq { .. }));
|
|
}
|
|
|
|
// -- best_match ---------------------------------------------------------
|
|
|
|
#[test]
|
|
fn best_match_picks_highest_satisfying_caret() {
|
|
let tags = vec![
|
|
"v0.9.0".into(),
|
|
"v1.0.0".into(),
|
|
"v1.1.0".into(),
|
|
"v2.0.0".into(),
|
|
];
|
|
let req = VersionReq::parse("^1.0").unwrap();
|
|
let m = best_match(&tags, &req).unwrap();
|
|
assert_eq!(m.tag, "v1.1.0");
|
|
}
|
|
|
|
#[test]
|
|
fn best_match_skips_non_semver_tags() {
|
|
let tags = vec![
|
|
"release-notes".into(),
|
|
"v1.0.0".into(),
|
|
"milestone-1".into(),
|
|
];
|
|
let req = VersionReq::parse("*").unwrap();
|
|
let m = best_match(&tags, &req).unwrap();
|
|
assert_eq!(m.tag, "v1.0.0");
|
|
}
|
|
|
|
#[test]
|
|
fn best_match_returns_none_when_no_tag_satisfies() {
|
|
let tags = vec!["v1.0.0".into(), "v1.1.0".into()];
|
|
let req = VersionReq::parse(">=2.0").unwrap();
|
|
assert!(best_match(&tags, &req).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn best_match_accepts_unprefixed_tags() {
|
|
let tags = vec!["1.0.0".into(), "1.1.0".into()];
|
|
let req = VersionReq::parse("^1.0").unwrap();
|
|
let m = best_match(&tags, &req).unwrap();
|
|
assert_eq!(m.tag, "1.1.0");
|
|
}
|
|
|
|
// -- package_basename ---------------------------------------------------
|
|
|
|
#[test]
|
|
fn package_basename_unwraps_namespace() {
|
|
assert_eq!(package_basename("magit"), "magit");
|
|
assert_eq!(package_basename("user/magit"), "magit");
|
|
}
|
|
|
|
// -- end-to-end install -------------------------------------------------
|
|
|
|
#[test]
|
|
fn install_picks_highest_tag_and_extracts_to_install_root() {
|
|
let (_pkg_td, bare) = make_sample_package();
|
|
let install_td = tempfile::tempdir().unwrap();
|
|
let (_cache_td, installer) = make_installer(install_td.path());
|
|
|
|
let spec = InstallSpec {
|
|
address: Address::Url(file_url(&bare)),
|
|
pin: InstallPin::Version(VersionReq::parse("^1.0").unwrap()),
|
|
};
|
|
let installed = installer.install(&spec).unwrap();
|
|
|
|
// Picked the higher tag (1.1.0 over 1.0.0).
|
|
assert_eq!(installed.tag, "v1.1.0");
|
|
assert_eq!(installed.version.to_string(), "1.1.0");
|
|
|
|
// Install dir uses the package basename.
|
|
assert!(
|
|
installed
|
|
.install_path
|
|
.ends_with(".pmacs/packages/samplepkg")
|
|
);
|
|
|
|
// Entry module exists at the expected path.
|
|
let entry = installed.entry_path();
|
|
assert!(
|
|
entry.exists(),
|
|
"entry file `{}` should exist",
|
|
entry.display()
|
|
);
|
|
let entry_text = std::fs::read_to_string(&entry).unwrap();
|
|
assert!(entry_text.contains("1.1.0"));
|
|
|
|
// Manifest snapshot is present (extraction included pmacs.toml).
|
|
assert!(installed.install_path.join("pmacs.toml").exists());
|
|
|
|
// Marker file records the commit.
|
|
let marker = installed.install_path.join(".pmacs-install");
|
|
let recorded = std::fs::read_to_string(&marker).unwrap();
|
|
assert_eq!(recorded.trim(), installed.commit);
|
|
}
|
|
|
|
#[test]
|
|
fn install_resolves_exact_pin_to_lower_version() {
|
|
let (_pkg_td, bare) = make_sample_package();
|
|
let install_td = tempfile::tempdir().unwrap();
|
|
let (_cache_td, installer) = make_installer(install_td.path());
|
|
|
|
let spec = InstallSpec {
|
|
address: Address::Url(file_url(&bare)),
|
|
pin: InstallPin::Version(VersionReq::parse("=1.0.0").unwrap()),
|
|
};
|
|
let installed = installer.install(&spec).unwrap();
|
|
assert_eq!(installed.tag, "v1.0.0");
|
|
assert_eq!(installed.version.to_string(), "1.0.0");
|
|
}
|
|
|
|
#[test]
|
|
fn install_no_matching_tag_errors_with_listing() {
|
|
let (_pkg_td, bare) = make_sample_package();
|
|
let install_td = tempfile::tempdir().unwrap();
|
|
let (_cache_td, installer) = make_installer(install_td.path());
|
|
|
|
let spec = InstallSpec {
|
|
address: Address::Url(file_url(&bare)),
|
|
pin: InstallPin::Version(VersionReq::parse(">=2.0.0").unwrap()),
|
|
};
|
|
let err = installer.install(&spec).unwrap_err();
|
|
match err {
|
|
InstallError::NoMatchingVersion { available, .. } => {
|
|
assert!(available.iter().any(|t| t == "v1.0.0"));
|
|
assert!(available.iter().any(|t| t == "v1.1.0"));
|
|
}
|
|
other => panic!("expected NoMatchingVersion, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn install_idempotent_for_same_commit() {
|
|
let (_pkg_td, bare) = make_sample_package();
|
|
let install_td = tempfile::tempdir().unwrap();
|
|
let (_cache_td, installer) = make_installer(install_td.path());
|
|
|
|
let spec = InstallSpec {
|
|
address: Address::Url(file_url(&bare)),
|
|
pin: InstallPin::Version(VersionReq::parse("=1.0.0").unwrap()),
|
|
};
|
|
let first = installer.install(&spec).unwrap();
|
|
// Drop a sentinel; idempotent re-install should not blow it away.
|
|
let sentinel = first.install_path.join("PMACS_TEST_SENTINEL");
|
|
std::fs::write(&sentinel, b"x").unwrap();
|
|
let second = installer.install(&spec).unwrap();
|
|
assert_eq!(first.commit, second.commit);
|
|
assert!(
|
|
sentinel.exists(),
|
|
"idempotent re-install must not re-extract"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_rejects_overwrite_when_commit_differs() {
|
|
let (_pkg_td, bare) = make_sample_package();
|
|
let install_td = tempfile::tempdir().unwrap();
|
|
let (_cache_td, installer) = make_installer(install_td.path());
|
|
|
|
// First install at 1.0.0.
|
|
let spec_v1 = InstallSpec {
|
|
address: Address::Url(file_url(&bare)),
|
|
pin: InstallPin::Version(VersionReq::parse("=1.0.0").unwrap()),
|
|
};
|
|
installer.install(&spec_v1).unwrap();
|
|
|
|
// Second install at 1.1.0 to the same install path: refuse.
|
|
let spec_v2 = InstallSpec {
|
|
address: Address::Url(file_url(&bare)),
|
|
pin: InstallPin::Version(VersionReq::parse("=1.1.0").unwrap()),
|
|
};
|
|
let err = installer.install(&spec_v2).unwrap_err();
|
|
assert!(matches!(err, InstallError::AlreadyInstalled { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn project_and_user_scopes_have_distinct_install_roots() {
|
|
// Sanity check: same Fetcher under two scopes resolves two
|
|
// different roots, so user-config and project installs cannot
|
|
// collide.
|
|
let cache = tempfile::tempdir().unwrap();
|
|
let proj = tempfile::tempdir().unwrap();
|
|
let fetcher = Fetcher::with_cache_dir(cache.path().to_path_buf());
|
|
|
|
let proj_inst = Installer::new(
|
|
fetcher.clone(),
|
|
InstallScope::Project {
|
|
project_root: proj.path().to_path_buf(),
|
|
},
|
|
);
|
|
let proj_root = proj_inst.scope.resolve_root().unwrap();
|
|
assert!(proj_root.starts_with(proj.path()));
|
|
|
|
// The user-scope path depends on the test's environment
|
|
// (XDG_DATA_HOME / HOME). We just verify it differs from the
|
|
// project root, not its specific layout.
|
|
let user_inst = Installer::new(fetcher, InstallScope::User);
|
|
if let Ok(user_root) = user_inst.scope.resolve_root() {
|
|
assert_ne!(user_root, proj_root);
|
|
}
|
|
}
|
|
|
|
/// Build a sample-package bare repo whose `pmacs_required` is
|
|
/// pinned to `pmacs_required`. Used by the version-incompat test
|
|
/// to mint a manifest the running pmacs cannot satisfy.
|
|
fn make_pinned_pmacs_package(pmacs_required: &str) -> (TempDir, PathBuf) {
|
|
let td = tempfile::tempdir().unwrap();
|
|
let work = td.path().join("work");
|
|
let bare = td.path().join("upstream.git");
|
|
|
|
init_git_workdir(&work);
|
|
let toml = format!(
|
|
r#"name = "samplepkg"
|
|
version = "1.0.0"
|
|
summary = "test package"
|
|
pmacs_required = "{pmacs_required}"
|
|
entry = "init.lua"
|
|
exports = ["samplepkg"]
|
|
"#
|
|
);
|
|
std::fs::write(work.join("pmacs.toml"), toml).unwrap();
|
|
std::fs::write(work.join("init.lua"), "return { version = '1.0.0' }\n").unwrap();
|
|
run_git_at(&work, &["add", "."]);
|
|
run_git_at(&work, &["commit", "-m", "v1.0.0"]);
|
|
run_git_at(&work, &["tag", "v1.0.0"]);
|
|
clone_bare(&work, &bare);
|
|
|
|
(td, bare)
|
|
}
|
|
|
|
#[test]
|
|
fn install_rejects_unsatisfiable_pmacs_required() {
|
|
// `pmacs_required = ">= 99.0.0"` cannot be satisfied by any
|
|
// 0.x or 1.x pmacs build, so this is a stable
|
|
// running-version-independent assertion: the install path
|
|
// surfaces a typed `PmacsVersionIncompatible` error rather
|
|
// than letting the package's API mismatch bite at runtime.
|
|
let (_pkg, bare) = make_pinned_pmacs_package(">= 99.0.0");
|
|
let install_td = tempfile::tempdir().unwrap();
|
|
let (_cache, installer) = make_installer(install_td.path());
|
|
|
|
let spec = InstallSpec {
|
|
address: Address::Url(file_url(&bare)),
|
|
pin: InstallPin::Version(VersionReq::parse("^1.0.0").unwrap()),
|
|
};
|
|
|
|
match installer.install(&spec).unwrap_err() {
|
|
InstallError::PmacsVersionIncompatible {
|
|
required, running, ..
|
|
} => {
|
|
assert!(
|
|
required.contains("99"),
|
|
"error must echo the failing constraint: required={required}"
|
|
);
|
|
assert_eq!(running, env!("CARGO_PKG_VERSION"));
|
|
}
|
|
other => panic!("expected PmacsVersionIncompatible, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn install_accepts_satisfiable_pmacs_required() {
|
|
// The other side of the gate: a constraint that *is*
|
|
// satisfied by the running build proceeds normally. Pin the
|
|
// constraint to the running version exactly so the check is
|
|
// tight (matches `=X.Y.Z`).
|
|
let (_pkg, bare) = make_pinned_pmacs_package(&format!("={}", env!("CARGO_PKG_VERSION")));
|
|
let install_td = tempfile::tempdir().unwrap();
|
|
let (_cache, installer) = make_installer(install_td.path());
|
|
|
|
let spec = InstallSpec {
|
|
address: Address::Url(file_url(&bare)),
|
|
pin: InstallPin::Version(VersionReq::parse("^1.0.0").unwrap()),
|
|
};
|
|
installer
|
|
.install(&spec)
|
|
.expect("matching pmacs_required must install");
|
|
}
|
|
|
|
#[test]
|
|
fn running_pmacs_version_matches_cargo_pkg_version() {
|
|
// Self-test for the helper. If Cargo ever changes the env-var
|
|
// shape or someone introduces a non-semver suffix, this fires
|
|
// before the install path does.
|
|
let v = running_pmacs_version();
|
|
assert_eq!(v.to_string(), env!("CARGO_PKG_VERSION"));
|
|
}
|
|
}
|