feat(pacquet): port resolving.local-resolver (file:/link:/workspace:) (#11778)

* feat(pacquet/resolving-local-resolver): port file:/link:/workspace: resolver

Ports pnpm's @pnpm/resolving.local-resolver:

- parse_bare_specifier mirrors parseLocalScheme/parseLocalPath/fromLocal
  (link:/workspace:/file: prefixes, bare path shapes, tarball-filename
  detection, tilde/drive-letter handling, preserveAbsolutePaths,
  injected directories get file: not link:).
- local_resolver provides resolve_from_local_scheme / _path /
  resolve_latest_from_local matching upstream's three exports;
  ssri-based tarball integrity for file: tarballs;
  safe_read_package_json_from_dir for directories with the upstream
  fallback (warn + name=basename / version='0.0.0' for missing link:
  targets, LINKED_PKG_DIR_NOT_FOUND for missing file: targets,
  NOT_PACKAGE_DIRECTORY for ENOTDIR + Windows stat-check) and
  PATH_IS_UNSUPPORTED_PROTOCOL for path:.
- chain.rs wraps the free functions behind the Resolver trait so the
  default-resolver dispatcher can compose this in alongside the npm
  resolver. ResolveResult.name_ver is None for local resolutions —
  the canonical name lives in the fetched manifest, not the
  resolver-time signal.

17 ported tests mirror resolving/local-resolver/test/index.ts plus
3 chain-dispatch tests verifying the trait wiring. The missing-link:
target warn is emitted via tracing::warn! because pacquet's reporter
doesn't yet have a generic pnpm:logger channel.

Install-side wiring is left for a follow-up alongside the Stage-1
directory-fetcher integration: surfacing Directory resolutions to
install_without_lockfile today would only swap the
SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER error for an UnsupportedResolution
one in install_package_from_registry.

Written by an agent (Claude Code, claude-opus-4-7).

* fix(pacquet): satisfy doc + dylint CI

Doc:
- pacquet_directory_fetcher intra-doc link was unresolved
  (resolving-local-resolver has no such dep — it's a sibling).
- LocalSpecError doc linked to crate-private parse_local_scheme /
  parse_local_path.

Dylint (perfectionist):
- PkgResolutionId / WantedLocalDependency / ParseOptions /
  PathProtocolNotSupportedError derive lists reordered to
  prefix_then_alphabetical.
- Single-letter closure params (|p|, |s|, |v|) renamed.
- Impure expression passed to tracing::warn! bound to a let first.
- Multi-line format!/assert_eq! macro invocations gained trailing
  commas; the single-line assert! shed its stray trailing comma.

Written by an agent (Claude Code, claude-opus-4-7).

* fix(pacquet/resolving-local-resolver): surface NOT_PACKAGE_DIRECTORY on Windows

`safe_read_package_json_from_dir` opens `<spec>/package.json` and lets
the OS error surface. On Unix that's `ENOTDIR` for a file path; on
Windows it's `NotFound`, so the resolver fell through to the
fallback-manifest branch instead of returning `NOT_PACKAGE_DIRECTORY`.

Upstream pnpm has the same gap on Windows and patches around it inside
`readProjectManifestOnly` (workspace/project-manifest-reader/src/index.ts#L100-L114
at ef87f3ccff) by stat-checking the spec and synthesizing ENOTDIR.
Mirror the check here so `link:./foo.tgz` raises NOT_PACKAGE_DIRECTORY
on every platform.

Written by an agent (Claude Code, claude-opus-4-7).

* fix(pacquet/resolving-local-resolver): raise LINKED_PKG_DIR_NOT_FOUND for missing tarballs

The `file:` tarball branch wrapped every read failure in
`ResolveLocalError::Integrity`, so `file:./missing.tgz` lost the
pnpm-compatible error code. The directory branch already maps the
same scenario to `LINKED_PKG_DIR_NOT_FOUND`; thread the tarball case
through the same code by short-circuiting on `NotFound` from
`compute_tarball_integrity`. Mirrors upstream's `resolveSpec` catch,
where `getTarballIntegrity`'s ENOENT funnels into the same
LINKED_PKG_DIR_NOT_FOUND throw the directory branch raises.

Adds a `fail_when_resolving_missing_tarball_with_file_protocol` test
to pin the contract.

Resolves a CodeRabbit review comment on #11778.

Written by an agent (Claude Code, claude-opus-4-7).

* feat(pacquet/package-manager): wire LocalResolver into install_without_lockfile chain

`install_without_lockfile`'s resolver chain was `[npm, git]` — `link:`
/ `file:` / `workspace:` and bare-path specifiers raised
`SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER` even though the
`resolving-local-resolver` crate that handles them has been ported.
Slot `LocalResolver` in after git, mirroring upstream's
`createResolver` order (npm → jsr → git → tarball → local-scheme → …
→ local-path).

Pacquet doesn't expose `preserveAbsolutePaths` through `Config` yet
so the context defaults to `false`; once that setting lands in
`pacquet-config` the install path can thread it through.

The install pass still can't materialise Directory resolutions — the
tarball-shaped install path raises `UnsupportedResolution` — but the
resolver chain now correctly dispatches to the local resolver, so
the failure mode moves one step closer to the install-side gap
that's tracked by the Stage-1 `directory-fetcher` integration.

Written by an agent (Claude Code, claude-opus-4-7).

* fix(pacquet): satisfy cargo fmt --check

Local cargo fmt formatted the `fail_when_resolving_missing_tarball_with_file_protocol`
struct literal on one line; the CI Format job (`cargo fmt --all -- --check`)
disagreed. Re-run fmt locally to flush the diff.

Written by an agent (Claude Code, claude-opus-4-7).
This commit is contained in:
Zoltan Kochan
2026-05-20 23:49:30 +02:00
committed by GitHub
parent 606ff8f648
commit a8a8cbce6d
11 changed files with 1534 additions and 2 deletions

22
Cargo.lock generated
View File

@@ -2306,6 +2306,7 @@ dependencies = [
"pacquet-resolving-default-resolver",
"pacquet-resolving-deps-resolver",
"pacquet-resolving-git-resolver",
"pacquet-resolving-local-resolver",
"pacquet-resolving-npm-resolver",
"pacquet-resolving-resolver-base",
"pacquet-resolving-tarball-resolver",
@@ -2474,6 +2475,27 @@ dependencies = [
"miette 7.6.0",
]
[[package]]
name = "pacquet-resolving-local-resolver"
version = "0.0.1"
dependencies = [
"derive_more",
"home",
"miette 7.6.0",
"pacquet-lockfile",
"pacquet-package-manifest",
"pacquet-resolving-default-resolver",
"pacquet-resolving-resolver-base",
"pacquet-testing-utils",
"pathdiff",
"pretty_assertions",
"serde_json",
"ssri",
"tempfile",
"tokio",
"tracing",
]
[[package]]
name = "pacquet-resolving-npm-resolver"
version = "0.0.1"

View File

@@ -42,6 +42,7 @@ pacquet-resolving-default-resolver = { path = "pacquet/crates/resolving-d
pacquet-resolving-deps-resolver = { path = "pacquet/crates/resolving-deps-resolver" }
pacquet-resolving-git-resolver = { path = "pacquet/crates/resolving-git-resolver" }
pacquet-resolving-jsr-specifier-parser = { path = "pacquet/crates/resolving-jsr-specifier-parser" }
pacquet-resolving-local-resolver = { path = "pacquet/crates/resolving-local-resolver" }
pacquet-resolving-npm-resolver = { path = "pacquet/crates/resolving-npm-resolver" }
pacquet-resolving-parse-wanted-dependency = { path = "pacquet/crates/resolving-parse-wanted-dependency" }
pacquet-resolving-resolver-base = { path = "pacquet/crates/resolving-resolver-base" }

View File

@@ -33,6 +33,7 @@ pacquet-reporter = { workspace = true }
pacquet-resolving-default-resolver = { workspace = true }
pacquet-resolving-deps-resolver = { workspace = true }
pacquet-resolving-git-resolver = { workspace = true }
pacquet-resolving-local-resolver = { workspace = true }
pacquet-resolving-npm-resolver = { workspace = true }
pacquet-resolving-resolver-base = { workspace = true }
pacquet-resolving-tarball-resolver = { workspace = true }

View File

@@ -18,6 +18,7 @@ use pacquet_resolving_deps_resolver::{
ResolvePeersOptions, resolve_dependency_tree, resolve_peers,
};
use pacquet_resolving_git_resolver::{GitResolver, RealGitProbe, RealGitRunner};
use pacquet_resolving_local_resolver::{LocalResolver, LocalResolverContext};
use pacquet_resolving_npm_resolver::{InMemoryPackageMetaCache, NpmResolver};
use pacquet_resolving_resolver_base::{ResolveOptions, Resolver};
use pacquet_resolving_tarball_resolver::TarballResolver;
@@ -179,14 +180,28 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> {
Arc::new(RealGitRunner::new()),
);
let tarball_resolver = TarballResolver { http_client: Arc::clone(&http_client_arc) };
// `preserveAbsolutePaths` is wired through `Config`; thread the
// current value into the local-resolver context so absolute
// `file:` / `link:` specs round-trip the same shape upstream
// produces under the matching `--config.preserve-absolute-paths`
// setting. Pacquet doesn't expose `preserveAbsolutePaths` yet,
// so the context defaults to `false`.
let local_resolver =
LocalResolver::new(LocalResolverContext { preserve_absolute_paths: false });
// Order mirrors upstream's chain at
// <https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/default-resolver/src/index.ts#L97-L173>:
// npm, then git, then tarball. Local/workspace/runtimes will
// slot in as those crates land.
// npm → git → tarball → local. Runtimes / named-registry slot
// in as those crates land; `LocalResolver` covers both the
// scheme branch (where upstream interleaves it between tarball
// and runtimes) and the path branch (last in upstream's chain)
// because no intermediate resolvers exist yet that would split
// the two — when named-registry lands the two halves split
// into separate trait impls.
let resolver: Box<dyn Resolver> = Box::new(DefaultResolver::new(vec![
Box::new(npm_resolver),
Box::new(git_resolver),
Box::new(tarball_resolver),
Box::new(local_resolver),
]));
// Compile `minimumReleaseAge` (and its exclude pattern set)

View File

@@ -0,0 +1,36 @@
[package]
name = "pacquet-resolving-local-resolver"
version = "0.0.1"
publish = false
authors.workspace = true
description.workspace = true
edition.workspace = true
homepage.workspace = true
keywords.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
pacquet-lockfile = { workspace = true }
pacquet-package-manifest = { workspace = true }
pacquet-resolving-resolver-base = { workspace = true }
derive_more = { workspace = true }
home = { workspace = true }
miette = { workspace = true }
pathdiff = { workspace = true }
serde_json = { workspace = true }
ssri = { workspace = true }
tokio = { workspace = true, features = ["fs", "io-util", "rt"] }
tracing = { workspace = true }
[dev-dependencies]
pacquet-resolving-default-resolver = { workspace = true }
pacquet-testing-utils = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }
[lints]
workspace = true

View File

@@ -0,0 +1,109 @@
//! Chain-friendly wrapper that implements
//! [`pacquet_resolving_resolver_base::Resolver`] over the free
//! functions in [`super::local_resolver`].
//!
//! Equivalent to upstream's
//! [`resolveSchemeOrPath`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/default-resolver/src/index.ts#L97-L173)
//! step inside `createResolver`: try the scheme-prefix interpretation
//! first; fall through to the path-shape interpretation; defer
//! (`Ok(None)`) when neither claims.
use pacquet_resolving_resolver_base::{
LatestQuery, ResolveError, ResolveFuture, ResolveLatestFuture, ResolveOptions, ResolveResult,
Resolver, UpdateBehavior, WantedDependency,
};
use crate::local_resolver::{
LocalResolverContext, LocalResolverOptions, LocalResolverUpdate, resolve_from_local_path,
resolve_from_local_scheme, resolve_latest_from_local,
};
use crate::parse_bare_specifier::WantedLocalDependency;
/// `Resolver`-trait wrapper that the default-resolver chain consumes.
/// Holds the install-scoped [`LocalResolverContext`] (just
/// `preserveAbsolutePaths` today); the per-resolve `project_dir` /
/// `lockfile_dir` / `update` come from [`ResolveOptions`].
#[derive(Debug, Default, Clone, Copy)]
pub struct LocalResolver {
pub ctx: LocalResolverContext,
}
impl LocalResolver {
pub fn new(ctx: LocalResolverContext) -> Self {
Self { ctx }
}
}
impl Resolver for LocalResolver {
fn resolve<'a>(
&'a self,
wanted_dependency: &'a WantedDependency,
opts: &'a ResolveOptions,
) -> ResolveFuture<'a> {
Box::pin(self.resolve_impl(wanted_dependency, opts))
}
fn resolve_latest<'a>(
&'a self,
query: &'a LatestQuery,
_opts: &'a ResolveOptions,
) -> ResolveLatestFuture<'a> {
let info = resolve_latest_from_local(query);
Box::pin(async move { Ok(info) })
}
}
impl LocalResolver {
async fn resolve_impl(
&self,
wanted_dependency: &WantedDependency,
opts: &ResolveOptions,
) -> Result<Option<ResolveResult>, ResolveError> {
let Some(bare) = wanted_dependency.bare_specifier.clone() else {
return Ok(None);
};
let wd = WantedLocalDependency {
bare_specifier: bare,
injected: wanted_dependency.injected.unwrap_or(false),
};
let local_opts = LocalResolverOptions {
project_dir: opts.project_dir.clone(),
lockfile_dir: Some(opts.lockfile_dir.clone()),
current_pkg: None,
update: match opts.update {
UpdateBehavior::Off => LocalResolverUpdate::Off,
UpdateBehavior::Compatible | UpdateBehavior::Latest => LocalResolverUpdate::On,
},
};
if let Some(result) = resolve_from_local_scheme(&self.ctx, &wd, &local_opts)
.await
.map_err(|err| Box::new(err) as ResolveError)?
{
return Ok(Some(into_chain_result(result, wanted_dependency)));
}
if let Some(result) = resolve_from_local_path(&self.ctx, &wd, &local_opts)
.await
.map_err(|err| Box::new(err) as ResolveError)?
{
return Ok(Some(into_chain_result(result, wanted_dependency)));
}
Ok(None)
}
}
/// Thread the alias from the [`WantedDependency`] onto the chain
/// result so the install layer can address the resolved package by
/// the manifest key. Mirrors upstream's
/// [`alias: wantedDep.alias`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/default-resolver/src/index.ts#L123)
/// thread.
fn into_chain_result(
result: crate::local_resolver::LocalResolveResult,
wanted_dependency: &WantedDependency,
) -> ResolveResult {
let mut chain: ResolveResult = result.into();
chain.alias = wanted_dependency.alias.clone();
chain
}

View File

@@ -0,0 +1,38 @@
//! Pacquet port of pnpm's
//! [`@pnpm/resolving.local-resolver`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts).
//!
//! Resolves `file:`, `link:`, `workspace:`, and bare filesystem
//! specifiers — the four shapes the install layer can satisfy from
//! the project tree rather than a registry or git host. The fetch
//! side for the directory case lives in `pacquet-directory-fetcher`;
//! this crate is resolution-only.
//!
//! Three public entry points mirror upstream's:
//!
//! - [`resolve_from_local_scheme`] — claims a wanted dep iff its bare
//! specifier starts with `link:`, `workspace:`, or `file:`. The
//! `path:` prefix is rejected with [`PathProtocolNotSupportedError`]
//! to match upstream's
//! [`PATH_IS_UNSUPPORTED_PROTOCOL`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L29-L34)
//! error.
//! - [`resolve_from_local_path`] — claims a wanted dep purely by path
//! shape (relative path, absolute path, drive letter, tarball
//! filename). Bare-specifier dispatchers run this *after*
//! [`resolve_from_local_scheme`] so scheme prefixes don't slip
//! through.
//! - [`resolve_latest_from_local`] — declines (returns
//! `Ok(LatestInfo::default())`) for `link:` / `file:` /
//! `workspace:` specs so they don't accidentally route to a
//! named-registry alias named `link` / `file` / `workspace`.
mod chain;
mod local_resolver;
mod parse_bare_specifier;
pub use chain::LocalResolver;
pub use local_resolver::{
LocalCurrentPkg, LocalResolveResult, LocalResolverContext, LocalResolverOptions,
LocalResolverUpdate, LocalSpecError, ResolveLocalError, resolve_from_local_path,
resolve_from_local_scheme, resolve_latest_from_local,
};
pub use parse_bare_specifier::{PathProtocolNotSupportedError, WantedLocalDependency};

View File

@@ -0,0 +1,371 @@
//! Port of pnpm's
//! [`resolving/local-resolver/src/index.ts`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts).
//!
//! Three free functions match upstream's exported entry points; they
//! all funnel into [`resolve_spec`], which handles the tarball
//! integrity / directory manifest reading once a [`LocalPackageSpec`]
//! has been chosen.
use std::path::PathBuf;
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_lockfile::{DirectoryResolution, LockfileResolution, TarballResolution};
use pacquet_package_manifest::{PackageManifestError, safe_read_package_json_from_dir};
use pacquet_resolving_resolver_base::{LatestInfo, LatestQuery, PkgResolutionId, ResolveResult};
use ssri::{Algorithm, Integrity, IntegrityOpts};
use crate::parse_bare_specifier::{
LocalPackageSpec, LocalSpecKind, ParseOptions, PathProtocolNotSupportedError,
WantedLocalDependency, parse_local_path, parse_local_scheme,
};
/// Per-install knobs the dispatcher threads into every resolver call.
/// Mirrors upstream's
/// [`LocalResolverContext`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L22-L24).
#[derive(Debug, Default, Clone, Copy)]
pub struct LocalResolverContext {
/// When `true`, an absolute path in the wanted specifier is
/// preserved in the resolved `id` rather than being relativised
/// against the project / lockfile root. Mirrors pnpm's
/// `preserveAbsolutePaths` config (off by default).
pub preserve_absolute_paths: bool,
}
/// Per-call options. Mirrors upstream's
/// [`LocalResolverOptions`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L26-L34).
#[derive(Debug, Clone)]
pub struct LocalResolverOptions {
pub project_dir: PathBuf,
/// Lockfile root. Defaults to `project_dir` when `None` — mirrors
/// upstream's `opts.lockfileDir ?? opts.projectDir` fallback.
pub lockfile_dir: Option<PathBuf>,
/// Previously resolved entry from the lockfile, threaded so the
/// resolver can short-circuit directory resolution when the
/// install isn't asking for an update. Mirrors upstream's
/// `currentPkg` field.
pub current_pkg: Option<LocalCurrentPkg>,
/// `false` keeps the lockfile pin. Mirrors upstream's
/// `update?: false | 'compatible' | 'latest'` tri-state, but
/// pacquet collapses the two truthy values because the local
/// resolver only branches on truthy / falsy.
pub update: LocalResolverUpdate,
}
/// Lockfile-pinned slice the local resolver short-circuits on for
/// directory deps when no update is requested. Mirrors upstream's
/// inline `currentPkg?.{ id, resolution }`.
#[derive(Debug, Clone)]
pub struct LocalCurrentPkg {
pub id: PkgResolutionId,
pub resolution: LockfileResolution,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum LocalResolverUpdate {
/// Keep the lockfile pin.
#[default]
Off,
/// Re-resolve. Pnpm uses two truthy values (`'compatible'` and
/// `'latest'`); the local resolver treats both identically.
On,
}
/// Outcome of a successful local resolve. Mirrors upstream's
/// [`LocalResolveResult`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L15-L20).
#[derive(Debug, Clone)]
pub struct LocalResolveResult {
pub id: PkgResolutionId,
pub manifest: Option<serde_json::Value>,
pub normalized_bare_specifier: Option<String>,
pub resolution: LockfileResolution,
/// `local-filesystem` — the same `resolvedVia` tag upstream uses
/// across every shape this resolver produces.
pub resolved_via: &'static str,
}
impl From<LocalResolveResult> for ResolveResult {
fn from(result: LocalResolveResult) -> Self {
ResolveResult {
id: result.id,
// Local resolutions don't have a `name@version` shape —
// the canonical name lives in the fetched manifest, not
// the resolver-time signal. Leave `name_ver` empty so
// downstream consumers fall back to reading
// `result.manifest`.
name_ver: None,
latest: None,
published_at: None,
manifest: result.manifest,
resolution: result.resolution,
resolved_via: result.resolved_via.to_string(),
normalized_bare_specifier: result.normalized_bare_specifier,
alias: None,
policy_violation: None,
}
}
}
/// Error returned when bare-specifier parsing itself fails (today:
/// the `path:` protocol case). Surfaces as
/// [`PathProtocolNotSupportedError`] downstream.
#[derive(Debug, Display, Error, Diagnostic)]
pub enum LocalSpecError {
PathProtocolNotSupported(#[error(source)] PathProtocolNotSupportedError),
}
/// Aggregate error type returned by the three public entry points.
/// Mirrors the three branches upstream's `resolveSpec` raises.
#[derive(Debug, Display, Error, Diagnostic)]
pub enum ResolveLocalError {
/// The wanted specifier carries an unsupported scheme. Today only
/// `path:`; reserved for future additions.
Spec(#[error(source)] LocalSpecError),
/// `file:` directory or tarball points at a path that doesn't
/// exist. Mirrors pnpm's
/// [`LINKED_PKG_DIR_NOT_FOUND`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L111-L114).
#[display("Could not install from \"{path}\" as it does not exist.")]
#[diagnostic(code(LINKED_PKG_DIR_NOT_FOUND))]
LinkedPkgDirNotFound {
#[error(not(source))]
path: String,
},
/// `<spec.fetchSpec>` exists but isn't a directory (ENOTDIR).
/// Mirrors pnpm's
/// [`NOT_PACKAGE_DIRECTORY`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L127-L129).
#[display("Could not install from \"{path}\" as it is not a directory.")]
#[diagnostic(code(NOT_PACKAGE_DIRECTORY))]
NotPackageDirectory {
#[error(not(source))]
path: String,
},
/// Tarball integrity computation failed for a `file:` spec.
Integrity(#[error(source)] std::io::Error),
/// Reading `<spec.fetchSpec>/package.json` raised something the
/// resolver doesn't have a specific code for (malformed JSON,
/// permission denied, …).
ReadManifest(#[error(source)] PackageManifestError),
}
/// Resolve a wanted dep declared with an explicit local scheme.
/// Mirrors pnpm's
/// [`resolveFromLocalScheme`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L40-L49).
pub async fn resolve_from_local_scheme(
ctx: &LocalResolverContext,
wanted_dependency: &WantedLocalDependency,
opts: &LocalResolverOptions,
) -> Result<Option<LocalResolveResult>, ResolveLocalError> {
let project_dir = opts.project_dir.as_path();
let lockfile_dir = opts.lockfile_dir.as_deref().unwrap_or(project_dir);
let parse_opts = ParseOptions { preserve_absolute_paths: ctx.preserve_absolute_paths };
let spec = match parse_local_scheme(wanted_dependency, project_dir, lockfile_dir, parse_opts) {
Ok(maybe) => maybe,
Err(err) => {
return Err(ResolveLocalError::Spec(LocalSpecError::PathProtocolNotSupported(err)));
}
};
resolve_spec(spec, opts).await
}
/// Resolve a wanted dep by path shape alone — no scheme prefix.
/// Mirrors pnpm's
/// [`resolveFromLocalPath`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L51-L65).
pub async fn resolve_from_local_path(
ctx: &LocalResolverContext,
wanted_dependency: &WantedLocalDependency,
opts: &LocalResolverOptions,
) -> Result<Option<LocalResolveResult>, ResolveLocalError> {
let project_dir = opts.project_dir.as_path();
let lockfile_dir = opts.lockfile_dir.as_deref().unwrap_or(project_dir);
let parse_opts = ParseOptions { preserve_absolute_paths: ctx.preserve_absolute_paths };
let spec = parse_local_path(wanted_dependency, project_dir, lockfile_dir, parse_opts);
resolve_spec(spec, opts).await
}
/// Latest-version companion. Mirrors pnpm's
/// [`resolveLatestFromLocal`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L67-L77):
/// claims `link:` / `file:` / `workspace:` specs with an empty
/// [`LatestInfo`] so the dispatcher stops there instead of routing
/// the dep into a user-configured named-registry alias of the same
/// name.
pub fn resolve_latest_from_local(query: &LatestQuery) -> Option<LatestInfo> {
let bare = query.wanted_dependency.bare_specifier.as_deref()?;
if bare.starts_with("link:") || bare.starts_with("file:") || bare.starts_with("workspace:") {
return Some(LatestInfo::default());
}
None
}
async fn resolve_spec(
spec: Option<LocalPackageSpec>,
opts: &LocalResolverOptions,
) -> Result<Option<LocalResolveResult>, ResolveLocalError> {
let Some(spec) = spec else {
return Ok(None);
};
if matches!(spec.kind, LocalSpecKind::File) {
// A missing tarball file raises the same `LINKED_PKG_DIR_NOT_FOUND`
// code the directory branch uses for a missing `file:` target —
// matches upstream's behavior where `getTarballIntegrity` raises
// ENOENT and the same catch in
// [`resolveSpec`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L108-L141)
// routes both kinds of missing `file:` target through one
// pnpm-compatible error code.
let integrity = match compute_tarball_integrity(&spec.fetch_spec).await {
Ok(integrity) => integrity,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(ResolveLocalError::LinkedPkgDirNotFound {
path: spec.fetch_spec.display().to_string(),
});
}
Err(err) => return Err(ResolveLocalError::Integrity(err)),
};
return Ok(Some(LocalResolveResult {
id: spec.id.clone(),
manifest: None,
normalized_bare_specifier: Some(spec.normalized_bare_specifier),
resolution: LockfileResolution::Tarball(TarballResolution {
tarball: spec.id.as_str().to_string(),
integrity: Some(integrity),
git_hosted: None,
path: None,
}),
resolved_via: "local-filesystem",
}));
}
// Directory branch. Short-circuit when the lockfile already has
// a pin and the install isn't asking for an update — mirrors
// upstream's
// [`opts.currentPkg.resolution && spec.type === 'directory' && !opts.update`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L99-L104).
if let Some(current) = &opts.current_pkg
&& opts.update == LocalResolverUpdate::Off
{
return Ok(Some(LocalResolveResult {
id: current.id.clone(),
manifest: None,
normalized_bare_specifier: Some(spec.normalized_bare_specifier),
resolution: current.resolution.clone(),
resolved_via: "local-filesystem",
}));
}
let manifest = match safe_read_package_json_from_dir(&spec.fetch_spec) {
Ok(Some(manifest)) => manifest,
Ok(None) => synthesize_fallback_manifest(&spec, opts)?,
Err(err) => return Err(handle_manifest_read_failure(err, &spec)),
};
Ok(Some(LocalResolveResult {
id: spec.id.clone(),
manifest: Some(manifest),
normalized_bare_specifier: Some(spec.normalized_bare_specifier),
resolution: LockfileResolution::Directory(DirectoryResolution {
directory: spec.dependency_path,
}),
resolved_via: "local-filesystem",
}))
}
/// Decide the fall-back when `package.json` is missing. For `file:`
/// specs (copy-shaped) upstream throws `LINKED_PKG_DIR_NOT_FOUND` when
/// the directory itself doesn't exist; for `link:` and missing
/// `package.json` it warns and substitutes a manifest with the
/// directory basename and `version: '0.0.0'`. Mirrors upstream's
/// [`existsSync(spec.fetchSpec)` branch](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L108-L141).
fn synthesize_fallback_manifest(
spec: &LocalPackageSpec,
opts: &LocalResolverOptions,
) -> Result<serde_json::Value, ResolveLocalError> {
let metadata = std::fs::metadata(&spec.fetch_spec);
if matches!(&metadata, Err(err) if err.kind() == std::io::ErrorKind::NotFound) {
if spec.id.as_str().starts_with("file:") {
return Err(ResolveLocalError::LinkedPkgDirNotFound {
path: spec.fetch_spec.display().to_string(),
});
}
// Match upstream's `logger.warn({ message, prefix })` emit
// via `tracing::warn!` until pacquet's reporter grows a
// generic `pnpm:logger` channel. Same payload shape.
let prefix = opts.project_dir.display();
let fetch_spec = spec.fetch_spec.display();
tracing::warn!(
target: "pacquet::resolving-local-resolver",
prefix = %prefix,
"Installing a dependency from a non-existent directory: {fetch_spec}",
);
} else if let Ok(metadata) = metadata
&& !metadata.is_dir()
{
// The path exists but isn't a directory (e.g. a `.tgz` that
// slipped past tarball-shape detection because the spec used
// the `link:` scheme). Upstream raises ENOTDIR explicitly on
// Windows — where `read(<file>/package.json)` returns
// `NotFound` rather than `NotADirectory` — inside
// [`readProjectManifestOnly`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/project-manifest-reader/src/index.ts#L100-L114).
// Pacquet does the equivalent check here so the resolver
// surfaces `NOT_PACKAGE_DIRECTORY` on every platform.
return Err(ResolveLocalError::NotPackageDirectory {
path: spec.fetch_spec.display().to_string(),
});
}
let name = spec
.fetch_spec
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
Ok(serde_json::json!({ "name": name, "version": "0.0.0" }))
}
/// Map a [`PackageManifestError`] from
/// [`safe_read_package_json_from_dir`] into the resolver's error
/// surface. Upstream's catch block dispatches on the inner code:
/// `ENOTDIR` → `NOT_PACKAGE_DIRECTORY`, `ENOENT` →
/// fall-back manifest, anything else → re-throw.
fn handle_manifest_read_failure(
err: PackageManifestError,
spec: &LocalPackageSpec,
) -> ResolveLocalError {
if let PackageManifestError::Io(io_err) = &err {
match io_err.kind() {
std::io::ErrorKind::NotADirectory => {
return ResolveLocalError::NotPackageDirectory {
path: spec.fetch_spec.display().to_string(),
};
}
std::io::ErrorKind::NotFound => {
// Mirrors upstream's ENOENT fall-through: synthesize
// the placeholder manifest. The caller is responsible
// for wrapping this back into a `ResolveLocalError`
// since the public API surface is fallible only.
// Handled by `synthesize_fallback_manifest` already
// — Ok(None) from `safe_read_package_json_from_dir`
// hits that branch first, so reaching this arm means
// a directory was renamed mid-read; pacquet treats it
// as a hard failure rather than racing back into the
// fall-back path.
}
_ => {}
}
}
ResolveLocalError::ReadManifest(err)
}
/// Port of pnpm's
/// [`getTarballIntegrity`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/crypto/hash/src/index.ts#L40-L42)
/// — computes the SSRI integrity hash for a local tarball. Upstream
/// streams the file through ssri; pacquet reads the whole file and
/// feeds it to ssri's incremental hasher in one shot. Tarballs in the
/// `file:` install path are typically a few MB so the simpler shape
/// has no measurable cost.
async fn compute_tarball_integrity(path: &std::path::Path) -> std::io::Result<Integrity> {
let bytes = tokio::fs::read(path).await?;
let mut opts = IntegrityOpts::new().algorithm(Algorithm::Sha512);
opts.input(&bytes);
Ok(opts.result())
}

View File

@@ -0,0 +1,351 @@
//! Port of pnpm's
//! [`parseBareSpecifier.ts`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts).
//!
//! Decides whether a wanted dep is a local-filesystem shape (and which
//! protocol — `link:` vs `file:`) and builds the [`LocalPackageSpec`]
//! the resolver consumes.
use std::path::{Component, Path, PathBuf};
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_resolving_resolver_base::PkgResolutionId;
/// The wanted-dependency slice the local resolver consumes. Mirrors
/// pnpm's
/// [`WantedLocalDependency`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L21-L24).
#[derive(Debug, Default, Clone)]
pub struct WantedLocalDependency {
pub bare_specifier: String,
/// `dependenciesMeta[*].injected` for this entry. When set on a
/// directory dep the resolver picks `file:` (copy semantics)
/// instead of `link:` (symlink semantics).
pub injected: bool,
}
/// Parsed local-spec the resolver chain consumes. Mirrors pnpm's
/// [`LocalPackageSpec`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L14-L20).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LocalPackageSpec {
/// Where the directory will be addressed from inside the lockfile.
/// For directories: a normalized path string (relative to the
/// lockfile dir for injected file:, absolute for link:).
pub dependency_path: String,
/// Absolute path the resolver actually inspects (the location of
/// `package.json` for directories, the tarball file for files).
pub fetch_spec: PathBuf,
/// `PkgResolutionId` upstream calls this — the branded identifier
/// the install layer uses to dedupe and key into the lockfile.
/// Formatted as `<protocol><normalized-path>`.
pub id: PkgResolutionId,
pub kind: LocalSpecKind,
/// Normalized echo of the bare specifier (with the chosen
/// protocol prefix). The dispatcher writes this back to the
/// manifest spec when `add` / `update` runs.
pub normalized_bare_specifier: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LocalSpecKind {
Directory,
File,
}
/// Options shared by [`parse_local_scheme`] and [`parse_local_path`].
/// Mirrors upstream's
/// [`{ preserveAbsolutePaths }`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L40-L44)
/// option bag.
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ParseOptions {
pub preserve_absolute_paths: bool,
}
/// `path:` is rejected so users get the same nudge they'd get from
/// pnpm. Mirrors upstream's
/// [`PathIsUnsupportedProtocolError`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L27-L36).
#[derive(Debug, Display, Error, Diagnostic, Clone)]
#[display(
"Local dependencies via `path:` protocol are not supported. \
Use the `link:` protocol for folder dependencies and `file:` for local tarballs"
)]
#[diagnostic(code(PATH_IS_UNSUPPORTED_PROTOCOL))]
pub struct PathProtocolNotSupportedError {
pub bare_specifier: String,
pub protocol: String,
}
/// Parse a wanted dep with an explicit local-scheme prefix
/// (`link:` / `workspace:` / `file:`). Returns `Ok(None)` when the
/// specifier doesn't carry one of those prefixes; returns
/// `Err(PathProtocolNotSupportedError)` for `path:`.
///
/// Mirrors upstream's
/// [`parseLocalScheme`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L38-L55).
pub(crate) fn parse_local_scheme(
wd: &WantedLocalDependency,
project_dir: &Path,
lockfile_dir: &Path,
opts: ParseOptions,
) -> Result<Option<LocalPackageSpec>, PathProtocolNotSupportedError> {
let bare = wd.bare_specifier.as_str();
if bare.starts_with("link:") || bare.starts_with("workspace:") {
return Ok(Some(from_local(wd, project_dir, lockfile_dir, LocalSpecKind::Directory, opts)));
}
if bare.starts_with("file:") {
let kind =
if is_tarball_filename(bare) { LocalSpecKind::File } else { LocalSpecKind::Directory };
return Ok(Some(from_local(wd, project_dir, lockfile_dir, kind, opts)));
}
if let Some(rest) = bare.strip_prefix("path:") {
let _ = rest;
return Err(PathProtocolNotSupportedError {
bare_specifier: bare.to_string(),
protocol: "path:".to_string(),
});
}
Ok(None)
}
/// Parse a wanted dep by path shape alone — no scheme prefix. The
/// dispatcher calls this *after* [`parse_local_scheme`] so explicit
/// `link:`/`file:`/`workspace:`/`path:` prefixes don't slip through.
///
/// Mirrors upstream's
/// [`parseLocalPath`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L57-L73).
pub(crate) fn parse_local_path(
wd: &WantedLocalDependency,
project_dir: &Path,
lockfile_dir: &Path,
opts: ParseOptions,
) -> Option<LocalPackageSpec> {
let bare = wd.bare_specifier.as_str();
if is_tarball_filename(bare) || contains_path_sep(bare) || is_filespec(bare) {
let kind =
if is_tarball_filename(bare) { LocalSpecKind::File } else { LocalSpecKind::Directory };
return Some(from_local(wd, project_dir, lockfile_dir, kind, opts));
}
None
}
/// Build the final [`LocalPackageSpec`] from a wanted dep that has
/// already been claimed by either entry point.
///
/// Mirrors upstream's
/// [`fromLocal`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L75-L136).
fn from_local(
wd: &WantedLocalDependency,
project_dir: &Path,
lockfile_dir: &Path,
kind: LocalSpecKind,
opts: ParseOptions,
) -> LocalPackageSpec {
let bare = wd.bare_specifier.as_str();
let spec = normalize_specifier(bare);
let protocol: &'static str = if bare.starts_with("file:") {
"file:"
} else if bare.starts_with("link:")
|| (matches!(kind, LocalSpecKind::Directory) && !wd.injected)
{
"link:"
} else {
"file:"
};
let (fetch_spec, normalized_bare_specifier) = if let Some(rest) = strip_tilde_prefix(&spec) {
let home = home::home_dir().unwrap_or_default();
let fetched = resolve_path(&home, rest);
let normalized = format!("{protocol}{spec}");
(fetched, normalized)
} else {
let fetched = resolve_path(project_dir, &spec);
if is_absolute_specifier(&spec) {
(fetched, format!("{protocol}{spec}"))
} else {
let relative = forward_slashes(
pathdiff::diff_paths(&fetched, project_dir)
.map(|path| path.display().to_string())
.unwrap_or_else(|| fetched.display().to_string()),
);
let fetch_spec = fetched;
(fetch_spec, format!("{protocol}{relative}"))
}
};
// After upstream's `protocol = type === 'directory' && !injected ? 'link:' : 'file:'` step,
// upstream re-uses the `injected` variable to mean "is the dep
// copy-shaped" (`protocol === 'file:'`) for the dependencyPath /
// id calculations below. Match the rebind explicitly so the next
// few lines read identically.
let copy_shaped = protocol == "file:";
let dependency_path = if copy_shaped {
normalize_relative_or_absolute(lockfile_dir, &fetch_spec, &spec, opts)
} else {
forward_slashes(fetch_spec.display().to_string())
};
let id_value = if !copy_shaped
&& (matches!(kind, LocalSpecKind::Directory) || project_dir == lockfile_dir)
{
format!(
"{protocol}{}",
normalize_relative_or_absolute(project_dir, &fetch_spec, &spec, opts),
)
} else {
format!(
"{protocol}{}",
normalize_relative_or_absolute(lockfile_dir, &fetch_spec, &spec, opts),
)
};
LocalPackageSpec {
dependency_path,
fetch_spec,
id: PkgResolutionId::from(id_value),
kind,
normalized_bare_specifier,
}
}
/// Mirror upstream's
/// [`bareSpecifier.replace(...)`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L82-L84)
/// chain:
///
/// 1. Replace all `\` with `/`.
/// 2. Drive-letter prefix: `^(file|link|workspace):/*([A-Z]:)` → `$1`.
/// 3. `^(file|link|workspace):(?:/*([~./]))?` → `$1`. The captured
/// char class **includes `/`**, so a leading slash after the
/// protocol survives (collapsed to a single one).
fn normalize_specifier(bare: &str) -> String {
let forward = bare.replace('\\', "/");
let Some(after_proto) =
["file:", "link:", "workspace:"].iter().find_map(|proto| forward.strip_prefix(proto))
else {
return forward;
};
let after_slashes = after_proto.trim_start_matches('/');
if is_drive_letter_prefix(after_slashes) {
return after_slashes.to_string();
}
match after_proto.chars().next() {
Some('/') => {
let trimmed = after_slashes;
if let Some(c) = trimmed.chars().next()
&& matches!(c, '~' | '.')
{
trimmed.to_string()
} else {
let mut result = String::with_capacity(trimmed.len() + 1);
result.push('/');
result.push_str(trimmed);
result
}
}
_ => after_proto.to_string(),
}
}
/// Resolve `spec` against `where_dir`, mirroring Node's
/// [`path.resolve`](https://nodejs.org/api/path.html#pathresolvepaths)
/// behavior: an absolute `spec` is returned unchanged; otherwise the
/// host's path resolver joins the two and canonicalises the result.
fn resolve_path(where_dir: &Path, spec: &str) -> PathBuf {
if is_absolute_specifier(spec) {
return PathBuf::from(spec);
}
let mut joined = where_dir.to_path_buf();
joined.push(spec);
normalize_components(&joined)
}
/// Collapse `.` and `..` components the way Node's `path.resolve`
/// does (purely lexically — no syscalls). Preserves the absolute /
/// relative distinction of the input.
fn normalize_components(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
out.push("..");
}
}
other => out.push(other.as_os_str()),
}
}
out
}
/// `normalizeRelativeOrAbsolute` from upstream
/// [`fromLocal`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L109-L117).
/// When `preserveAbsolutePaths` is on and the input spec is absolute,
/// the result keeps the absolute form (slash-normalised); otherwise
/// the result is relative to `relative_to`.
fn normalize_relative_or_absolute(
relative_to: &Path,
from_path: &Path,
original_spec: &str,
opts: ParseOptions,
) -> String {
if opts.preserve_absolute_paths && is_absolute_specifier(original_spec) {
return forward_slashes(from_path.display().to_string());
}
let relative = pathdiff::diff_paths(from_path, relative_to)
.map(|path| path.display().to_string())
.unwrap_or_else(|| from_path.display().to_string());
forward_slashes(relative)
}
fn forward_slashes(input: String) -> String {
if input.contains('\\') { input.replace('\\', "/") } else { input }
}
/// Match upstream's `isAbsolutePath` regex (`/^\/|^[A-Z]:/i`).
fn is_absolute_specifier(spec: &str) -> bool {
let mut chars = spec.chars();
match chars.next() {
Some('/') => true,
Some(c) if c.is_ascii_alphabetic() => chars.next() == Some(':'),
_ => false,
}
}
/// Match upstream's `isFilespec` regex:
/// - Windows: `/^(?:[./\\]|~\/|[a-z]:)/i`
/// - POSIX: `/^(?:[./]|~\/|[a-z]:)/i`
///
/// Implemented uniformly because pacquet doesn't need the `\\`
/// alternative outside the bigger normalize step (`is_filespec` is
/// only consulted on already-forward-slashed paths in the upstream
/// flow because `parse_local_path` only inspects `bare_specifier`
/// which hasn't been normalised yet; we accept the backslash for
/// Windows-host inputs to keep parity).
fn is_filespec(spec: &str) -> bool {
let mut chars = spec.chars();
match chars.next() {
Some('.') | Some('/') | Some('\\') => true,
Some('~') => chars.next() == Some('/'),
Some(c) if c.is_ascii_alphabetic() => chars.next() == Some(':'),
_ => false,
}
}
fn is_drive_letter_prefix(spec: &str) -> bool {
let mut chars = spec.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic()) && matches!(chars.next(), Some(':'))
}
fn strip_tilde_prefix(spec: &str) -> Option<&str> {
spec.strip_prefix("~/")
}
fn is_tarball_filename(bare: &str) -> bool {
let lower = bare.to_ascii_lowercase();
lower.ends_with(".tgz") || lower.ends_with(".tar.gz") || lower.ends_with(".tar")
}
fn contains_path_sep(bare: &str) -> bool {
bare.contains(std::path::MAIN_SEPARATOR) || bare.contains('/')
}

View File

@@ -0,0 +1,92 @@
//! Verify that [`LocalResolver`] composes into the
//! [`pacquet_resolving_default_resolver::DefaultResolver`] chain the
//! same way upstream's local resolver composes into pnpm's
//! [`createResolver`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/default-resolver/src/index.ts#L97-L173).
use std::fs;
use std::path::PathBuf;
use pacquet_lockfile::LockfileResolution;
use pacquet_resolving_default_resolver::{DefaultResolver, SpecNotSupportedByAnyResolverError};
use pacquet_resolving_local_resolver::{LocalResolver, LocalResolverContext};
use pacquet_resolving_resolver_base::{ResolveOptions, WantedDependency};
use tempfile::TempDir;
fn setup_project() -> (TempDir, PathBuf) {
let tmp = TempDir::new().expect("tempdir");
let inner = tmp.path().join("inner");
fs::create_dir_all(&inner).expect("create inner");
fs::write(
tmp.path().join("package.json"),
r#"{"name":"@pnpm/resolving.local-resolver","version":"0.0.0"}"#,
)
.expect("write package.json");
(tmp, inner)
}
#[tokio::test]
async fn dispatcher_routes_link_specifier_through_local_resolver() {
let (_tmp, project_dir) = setup_project();
let resolver =
DefaultResolver::new(vec![Box::new(LocalResolver::new(LocalResolverContext::default()))]);
let opts = ResolveOptions {
project_dir: project_dir.clone(),
lockfile_dir: project_dir.clone(),
..ResolveOptions::default()
};
let wd = WantedDependency {
alias: Some("parent".to_string()),
bare_specifier: Some("link:..".to_string()),
..WantedDependency::default()
};
let result = resolver.resolve(&wd, &opts).await.expect("resolve");
assert_eq!(result.id.as_str(), "link:..");
assert_eq!(result.alias.as_deref(), Some("parent"));
assert!(matches!(result.resolution, LockfileResolution::Directory(_)));
assert_eq!(result.resolved_via, "local-filesystem");
}
#[tokio::test]
async fn dispatcher_falls_through_when_specifier_is_neither_local_nor_npm() {
let (_tmp, project_dir) = setup_project();
let resolver =
DefaultResolver::new(vec![Box::new(LocalResolver::new(LocalResolverContext::default()))]);
let opts = ResolveOptions {
project_dir: project_dir.clone(),
lockfile_dir: project_dir,
..ResolveOptions::default()
};
let wd = WantedDependency {
alias: Some("acme".to_string()),
bare_specifier: Some("^1.0.0".to_string()),
..WantedDependency::default()
};
let err = resolver
.resolve(&wd, &opts)
.await
.expect_err("chain with only the local resolver shouldn't claim a registry-shaped dep");
assert!(err.downcast_ref::<SpecNotSupportedByAnyResolverError>().is_some(), "got {err}");
}
#[tokio::test]
async fn resolve_latest_claims_local_scheme_specifiers() {
let (_tmp, project_dir) = setup_project();
let resolver =
DefaultResolver::new(vec![Box::new(LocalResolver::new(LocalResolverContext::default()))]);
let opts = ResolveOptions {
project_dir: project_dir.clone(),
lockfile_dir: project_dir,
..ResolveOptions::default()
};
let query = pacquet_resolving_resolver_base::LatestQuery {
wanted_dependency: WantedDependency {
alias: Some("parent".to_string()),
bare_specifier: Some("link:..".to_string()),
..WantedDependency::default()
},
compatible: false,
};
let info = resolver.resolve_latest(&query, &opts).await.expect("resolve_latest");
assert!(info.is_some(), "local resolver should claim link: specs in resolve_latest");
}

View File

@@ -0,0 +1,496 @@
//! Port of pnpm's
//! [`resolving/local-resolver/test/index.ts`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/test/index.ts).
//!
//! Each `#[tokio::test]` mirrors one upstream `test(...)` block; the
//! upstream test name is preserved in the Rust function name.
use std::fs;
use std::path::{Path, PathBuf};
use pacquet_lockfile::{LockfileResolution, TarballResolution};
use pacquet_resolving_local_resolver::{
LocalResolverContext, LocalResolverOptions, LocalResolverUpdate, ResolveLocalError,
WantedLocalDependency, resolve_from_local_path, resolve_from_local_scheme,
};
use pacquet_resolving_resolver_base::PkgResolutionId;
use tempfile::TempDir;
/// Set up a `<tmp>/inner/` directory with a package.json carrying the
/// `name` upstream's tests assert against. Returns `(tmp, inner)` so
/// the temp dir lives as long as the test.
fn fixture() -> (TempDir, PathBuf) {
let tmp = TempDir::new().expect("tempdir");
let inner = tmp.path().join("inner");
fs::create_dir_all(&inner).expect("create inner dir");
fs::write(
tmp.path().join("package.json"),
r#"{"name":"@pnpm/resolving.local-resolver","version":"0.0.0"}"#,
)
.expect("write package.json");
(tmp, inner)
}
fn opts(project_dir: &Path) -> LocalResolverOptions {
LocalResolverOptions {
project_dir: project_dir.to_path_buf(),
lockfile_dir: None,
current_pkg: None,
update: LocalResolverUpdate::Off,
}
}
fn ctx_default() -> LocalResolverContext {
LocalResolverContext::default()
}
#[tokio::test]
async fn resolve_directory() {
let (_tmp, project_dir) = fixture();
let wd = WantedLocalDependency { bare_specifier: "..".to_string(), injected: false };
let result = resolve_from_local_path(&ctx_default(), &wd, &opts(&project_dir))
.await
.expect("resolve")
.expect("claims");
assert_eq!(result.id.as_str(), "link:..");
assert_eq!(result.normalized_bare_specifier.as_deref(), Some("link:.."));
let manifest = result.manifest.as_ref().expect("manifest");
assert_eq!(
manifest.get("name").and_then(|value| value.as_str()),
Some("@pnpm/resolving.local-resolver"),
);
let LockfileResolution::Directory(dir) = &result.resolution else {
panic!("expected directory resolution, got {:?}", result.resolution);
};
let expected_dir =
forward_slashes(project_dir.join("..").lexical_normalize().display().to_string());
assert_eq!(dir.directory, expected_dir);
}
#[tokio::test]
async fn resolve_directory_specified_using_absolute_path() {
let (_tmp, project_dir) = fixture();
let linked_dir = project_dir.join("..").lexical_normalize();
let normalized_linked_dir = forward_slashes(linked_dir.display().to_string());
let wd = WantedLocalDependency {
bare_specifier: format!("link:{}", linked_dir.display()),
injected: false,
};
let result = resolve_from_local_scheme(&ctx_default(), &wd, &opts(&project_dir))
.await
.expect("resolve")
.expect("claims");
assert_eq!(result.id.as_str(), "link:..");
assert_eq!(
result.normalized_bare_specifier.as_deref(),
Some(format!("link:{normalized_linked_dir}").as_str()),
);
let LockfileResolution::Directory(dir) = &result.resolution else {
panic!("expected directory resolution, got {:?}", result.resolution);
};
assert_eq!(dir.directory, normalized_linked_dir);
}
#[tokio::test]
async fn resolve_directory_specified_using_absolute_path_with_preserve_absolute_paths() {
let (_tmp, project_dir) = fixture();
let linked_dir = project_dir.join("..").lexical_normalize();
let normalized_linked_dir = forward_slashes(linked_dir.display().to_string());
let wd = WantedLocalDependency {
bare_specifier: format!("link:{}", linked_dir.display()),
injected: false,
};
let ctx = LocalResolverContext { preserve_absolute_paths: true };
let result = resolve_from_local_scheme(&ctx, &wd, &opts(&project_dir))
.await
.expect("resolve")
.expect("claims");
assert_eq!(result.id.as_str(), format!("link:{normalized_linked_dir}"));
assert_eq!(
result.normalized_bare_specifier.as_deref(),
Some(format!("link:{normalized_linked_dir}").as_str()),
);
}
#[tokio::test]
async fn resolve_directory_specified_using_absolute_path_with_preserve_absolute_paths_and_file_scheme()
{
let (_tmp, project_dir) = fixture();
let linked_dir = project_dir.join("..").lexical_normalize();
let normalized_linked_dir = forward_slashes(linked_dir.display().to_string());
let wd = WantedLocalDependency {
bare_specifier: format!("file:{}", linked_dir.display()),
injected: false,
};
let ctx = LocalResolverContext { preserve_absolute_paths: true };
let result = resolve_from_local_scheme(&ctx, &wd, &opts(&project_dir))
.await
.expect("resolve")
.expect("claims");
assert_eq!(result.id.as_str(), format!("file:{normalized_linked_dir}"));
assert_eq!(
result.normalized_bare_specifier.as_deref(),
Some(format!("file:{normalized_linked_dir}").as_str()),
);
}
#[tokio::test]
async fn resolve_injected_directory() {
let (_tmp, project_dir) = fixture();
let wd = WantedLocalDependency { bare_specifier: "..".to_string(), injected: true };
let result = resolve_from_local_path(&ctx_default(), &wd, &opts(&project_dir))
.await
.expect("resolve")
.expect("claims");
assert_eq!(result.id.as_str(), "file:..");
assert_eq!(result.normalized_bare_specifier.as_deref(), Some("file:.."));
let LockfileResolution::Directory(dir) = &result.resolution else {
panic!("expected directory resolution, got {:?}", result.resolution);
};
assert_eq!(dir.directory, "..");
}
#[tokio::test]
async fn resolve_workspace_directory() {
let (_tmp, project_dir) = fixture();
let wd = WantedLocalDependency { bare_specifier: "workspace:..".to_string(), injected: false };
let result = resolve_from_local_scheme(&ctx_default(), &wd, &opts(&project_dir))
.await
.expect("resolve")
.expect("claims");
assert_eq!(result.id.as_str(), "link:..");
assert_eq!(result.normalized_bare_specifier.as_deref(), Some("link:.."));
}
#[tokio::test]
async fn resolve_directory_specified_using_the_file_protocol() {
let (_tmp, project_dir) = fixture();
let wd = WantedLocalDependency { bare_specifier: "file:..".to_string(), injected: false };
let result = resolve_from_local_scheme(&ctx_default(), &wd, &opts(&project_dir))
.await
.expect("resolve")
.expect("claims");
assert_eq!(result.id.as_str(), "file:..");
assert_eq!(result.normalized_bare_specifier.as_deref(), Some("file:.."));
let LockfileResolution::Directory(dir) = &result.resolution else {
panic!("expected directory resolution");
};
assert_eq!(dir.directory, "..");
}
#[tokio::test]
async fn resolve_directory_specified_using_the_link_protocol() {
let (_tmp, project_dir) = fixture();
let wd = WantedLocalDependency { bare_specifier: "link:..".to_string(), injected: false };
let result = resolve_from_local_scheme(&ctx_default(), &wd, &opts(&project_dir))
.await
.expect("resolve")
.expect("claims");
assert_eq!(result.id.as_str(), "link:..");
assert_eq!(result.normalized_bare_specifier.as_deref(), Some("link:.."));
}
/// Build a tiny tarball at `path` and return its sha512 SSRI string.
fn write_tarball(path: &Path) -> String {
// Any bytes work — the test asserts the integrity round-trips
// through the resolver, not a specific upstream-pinned value.
let bytes: &[u8] = b"\x1f\x8b\x08\x00fake-tarball-bytes-for-test\n";
fs::write(path, bytes).expect("write tarball");
let mut opts = ssri::IntegrityOpts::new().algorithm(ssri::Algorithm::Sha512);
opts.input(bytes);
opts.result().to_string()
}
#[tokio::test]
async fn resolve_file() {
let tmp = TempDir::new().expect("tempdir");
let test_dir = tmp.path().join("tgz");
fs::create_dir_all(&test_dir).expect("create tgz dir");
let tarball_path = test_dir.join("pnpm-local-resolver-0.1.1.tgz");
let integrity = write_tarball(&tarball_path);
let wd = WantedLocalDependency {
bare_specifier: "./pnpm-local-resolver-0.1.1.tgz".to_string(),
injected: false,
};
let result = resolve_from_local_path(&ctx_default(), &wd, &opts(&test_dir))
.await
.expect("resolve")
.expect("claims");
assert_eq!(result.id.as_str(), "file:pnpm-local-resolver-0.1.1.tgz");
assert_eq!(
result.normalized_bare_specifier.as_deref(),
Some("file:pnpm-local-resolver-0.1.1.tgz"),
);
let LockfileResolution::Tarball(TarballResolution {
tarball, integrity: got_integrity, ..
}) = &result.resolution
else {
panic!("expected tarball resolution, got {:?}", result.resolution);
};
assert_eq!(tarball, "file:pnpm-local-resolver-0.1.1.tgz");
assert_eq!(got_integrity.as_ref().expect("integrity").to_string(), integrity);
assert_eq!(result.resolved_via, "local-filesystem");
}
#[tokio::test]
async fn resolve_file_when_lockfile_directory_differs_from_the_packages_dir() {
let tmp = TempDir::new().expect("tempdir");
let test_dir = tmp.path().join("tgz");
fs::create_dir_all(&test_dir).expect("create tgz dir");
let tarball_path = test_dir.join("pnpm-local-resolver-0.1.1.tgz");
let _integrity = write_tarball(&tarball_path);
let mut options = opts(&test_dir);
options.lockfile_dir = Some(test_dir.join("..").lexical_normalize());
let wd = WantedLocalDependency {
bare_specifier: "./pnpm-local-resolver-0.1.1.tgz".to_string(),
injected: false,
};
let result = resolve_from_local_path(&ctx_default(), &wd, &options)
.await
.expect("resolve")
.expect("claims");
assert_eq!(result.id.as_str(), "file:tgz/pnpm-local-resolver-0.1.1.tgz");
assert_eq!(
result.normalized_bare_specifier.as_deref(),
Some("file:pnpm-local-resolver-0.1.1.tgz"),
);
let LockfileResolution::Tarball(TarballResolution { tarball, .. }) = &result.resolution else {
panic!("expected tarball resolution");
};
assert_eq!(tarball, "file:tgz/pnpm-local-resolver-0.1.1.tgz");
}
#[tokio::test]
async fn resolve_tarball_specified_with_file_protocol() {
let tmp = TempDir::new().expect("tempdir");
let test_dir = tmp.path().join("tgz");
fs::create_dir_all(&test_dir).expect("create tgz dir");
let tarball_path = test_dir.join("pnpm-local-resolver-0.1.1.tgz");
let _integrity = write_tarball(&tarball_path);
let wd = WantedLocalDependency {
bare_specifier: "file:./pnpm-local-resolver-0.1.1.tgz".to_string(),
injected: false,
};
let result = resolve_from_local_scheme(&ctx_default(), &wd, &opts(&test_dir))
.await
.expect("resolve")
.expect("claims");
assert_eq!(result.id.as_str(), "file:pnpm-local-resolver-0.1.1.tgz");
assert_eq!(
result.normalized_bare_specifier.as_deref(),
Some("file:pnpm-local-resolver-0.1.1.tgz"),
);
}
#[tokio::test]
async fn resolve_file_with_different_integrity_force_fetch() {
let tmp = TempDir::new().expect("tempdir");
let test_dir = tmp.path().join("tgz");
fs::create_dir_all(&test_dir).expect("create tgz dir");
let tarball_path = test_dir.join("pnpm-local-resolver-0.1.1.tgz");
let true_integrity = write_tarball(&tarball_path);
let mut options = opts(&test_dir);
options.current_pkg = Some(pacquet_resolving_local_resolver::LocalCurrentPkg {
id: PkgResolutionId::from("file:pnpm-local-resolver-0.1.1.tgz"),
resolution: LockfileResolution::Tarball(TarballResolution {
tarball: "file:pnpm-local-resolver-0.1.1.tgz".to_string(),
integrity: Some(
"sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
.parse()
.expect("parse"),
),
git_hosted: None,
path: None,
}),
});
let wd = WantedLocalDependency {
bare_specifier: "file:./pnpm-local-resolver-0.1.1.tgz".to_string(),
injected: false,
};
let result = resolve_from_local_scheme(&ctx_default(), &wd, &options)
.await
.expect("resolve")
.expect("claims");
let LockfileResolution::Tarball(TarballResolution { integrity, .. }) = &result.resolution
else {
panic!("expected tarball resolution");
};
assert_eq!(integrity.as_ref().expect("integrity").to_string(), true_integrity);
}
#[tokio::test]
async fn fail_when_resolving_tarball_specified_with_the_link_protocol() {
let tmp = TempDir::new().expect("tempdir");
let test_dir = tmp.path().join("tgz");
fs::create_dir_all(&test_dir).expect("create tgz dir");
let tarball_path = test_dir.join("pnpm-local-resolver-0.1.1.tgz");
let _ = write_tarball(&tarball_path);
let wd = WantedLocalDependency {
bare_specifier: "link:./pnpm-local-resolver-0.1.1.tgz".to_string(),
injected: false,
};
let err = resolve_from_local_scheme(&ctx_default(), &wd, &opts(&test_dir))
.await
.expect_err("expected NOT_PACKAGE_DIRECTORY");
assert!(matches!(err, ResolveLocalError::NotPackageDirectory { .. }), "got {err:?}");
}
#[tokio::test]
async fn fail_when_resolving_from_not_existing_directory_an_injected_dependency() {
let tmp = TempDir::new().expect("tempdir");
let project_dir = tmp.path();
let wd = WantedLocalDependency {
bare_specifier: "file:./dir-does-not-exist".to_string(),
injected: false,
};
let err = resolve_from_local_scheme(&ctx_default(), &wd, &opts(project_dir))
.await
.expect_err("expected LINKED_PKG_DIR_NOT_FOUND");
let expected = project_dir.join("dir-does-not-exist").display().to_string();
match err {
ResolveLocalError::LinkedPkgDirNotFound { path } => assert_eq!(path, expected),
other => panic!("unexpected error: {other:?}"),
}
}
/// A `file:./missing.tgz` spec funnels through the tarball branch
/// where `compute_tarball_integrity` raises ENOENT. The resolver must
/// surface the same `LINKED_PKG_DIR_NOT_FOUND` code the directory
/// branch raises for a missing `file:` target — both kinds of
/// missing `file:` target share one pnpm-compatible error path
/// (`resolveSpec` upstream:
/// <https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L108-L141>).
#[tokio::test]
async fn fail_when_resolving_missing_tarball_with_file_protocol() {
let tmp = TempDir::new().expect("tempdir");
let project_dir = tmp.path();
let wd =
WantedLocalDependency { bare_specifier: "file:./missing.tgz".to_string(), injected: false };
let err = resolve_from_local_scheme(&ctx_default(), &wd, &opts(project_dir))
.await
.expect_err("expected LINKED_PKG_DIR_NOT_FOUND");
let expected = project_dir.join("missing.tgz").display().to_string();
match err {
ResolveLocalError::LinkedPkgDirNotFound { path } => assert_eq!(path, expected),
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn do_not_fail_when_resolving_from_not_existing_directory() {
let tmp = TempDir::new().expect("tempdir");
let project_dir = tmp.path();
let wd = WantedLocalDependency {
bare_specifier: "link:./dir-does-not-exist".to_string(),
injected: false,
};
let result = resolve_from_local_scheme(&ctx_default(), &wd, &opts(project_dir))
.await
.expect("resolve")
.expect("claims");
let manifest = result.manifest.as_ref().expect("manifest");
assert_eq!(manifest.get("name").and_then(|value| value.as_str()), Some("dir-does-not-exist"));
assert_eq!(manifest.get("version").and_then(|value| value.as_str()), Some("0.0.0"));
}
#[tokio::test]
async fn throw_error_when_the_path_protocol_is_used() {
let tmp = TempDir::new().expect("tempdir");
let project_dir = tmp.path();
let wd = WantedLocalDependency { bare_specifier: "path:..".to_string(), injected: false };
let err = resolve_from_local_scheme(&ctx_default(), &wd, &opts(project_dir))
.await
.expect_err("expected PATH_IS_UNSUPPORTED_PROTOCOL");
match err {
ResolveLocalError::Spec(
pacquet_resolving_local_resolver::LocalSpecError::PathProtocolNotSupported(inner),
) => {
assert_eq!(inner.bare_specifier, "path:..");
assert_eq!(inner.protocol, "path:");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn resolve_from_local_path_ignores_explicit_local_schemes() {
let tmp = TempDir::new().expect("tempdir");
let project_dir = tmp.path();
for bare in ["foo"] {
let wd = WantedLocalDependency { bare_specifier: bare.to_string(), injected: false };
let outcome = resolve_from_local_scheme(&ctx_default(), &wd, &opts(project_dir))
.await
.expect("resolve_from_local_scheme should not fail on bare specifier");
assert!(outcome.is_none(), "scheme parser should defer on '{bare}'");
}
for bare in ["link:..", "workspace:..", "file:..", "path:.."] {
let wd = WantedLocalDependency { bare_specifier: bare.to_string(), injected: false };
let outcome = resolve_from_local_path(&ctx_default(), &wd, &opts(project_dir))
.await
.expect("resolve_from_local_path should not fail on scheme prefix");
assert!(outcome.is_none(), "path parser should defer on '{bare}'");
}
}
/// Lexically normalize `.` and `..` components without resolving
/// symlinks — matches Node's `path.resolve` semantics that the
/// upstream tests compare against. `canonicalize` would resolve
/// macOS's `/var` → `/private/var` symlink and diverge from the
/// upstream string-equality assertions.
trait LexicalNormalize: Sized {
fn lexical_normalize(self) -> PathBuf;
}
impl LexicalNormalize for PathBuf {
fn lexical_normalize(self) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for component in self.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
out.push("..");
}
}
other => out.push(other.as_os_str()),
}
}
out
}
}
fn forward_slashes(input: String) -> String {
if input.contains('\\') { input.replace('\\', "/") } else { input }
}