feat(pacquet): port catalogs (types, protocol-parser, resolver, config) (#11787)

* feat(pacquet): port catalogs (types, protocol-parser, resolver, config)

Adds four new crates mirroring upstream's `catalogs/*` packages:

- pacquet-catalogs-types: `Catalog`/`Catalogs` map aliases plus
  `DEFAULT_CATALOG_NAME`.
- pacquet-catalogs-protocol-parser: `parse_catalog_protocol`; folds
  `catalog:` shorthand into `"default"` to match upstream.
- pacquet-catalogs-resolver: `resolve_from_catalog` with the four
  upstream `PnpmError` codes (`CATALOG_ENTRY_NOT_FOUND_FOR_SPEC`,
  `CATALOG_ENTRY_INVALID_RECURSIVE_DEFINITION`,
  `CATALOG_ENTRY_INVALID_WORKSPACE_SPEC`,
  `CATALOG_ENTRY_INVALID_SPEC`). Rust callers `match` on the result
  enum directly instead of porting the TS-ergonomic
  `matchCatalogResolveResult` visitor.
- pacquet-catalogs-config: `get_catalogs_from_workspace_manifest` plus
  the `INVALID_CATALOGS_CONFIGURATION` mutual-exclusion check.

`pacquet-workspace`'s `WorkspaceManifest` now actually deserializes
the `catalog` and `catalogs` fields (previously dropped). The
resolver is not yet wired into the install path — `deps-installer`
hasn't been ported — but the crates are ready for that next step.

Tests are 1:1 ports of the upstream Jest suites.

* fix(pacquet): satisfy rustdoc and perfectionist lints in catalogs port

- catalogs-resolver: drop the intra-doc-link form on the
  `pacquet_resolving_resolver_base::WantedDependency` reference; the
  crate isn't a dependency, so rustdoc failed to resolve it under
  `-D rustdoc::broken-intra-doc-links`.
- catalogs-resolver, catalogs-config: reorder the `CatalogResolutionError`
  / `InvalidCatalogsConfigurationError` derive lists to
  `Debug, Display, Error, Diagnostic, Clone, PartialEq, Eq` so they
  match the `prefix_then_alphabetical` rule that the CI-only
  Perfectionist dylint enforces. `just ready` doesn't surface this lint
  locally.

* feat(pacquet): resolve catalog: specifiers during install

Wires the catalogs port into the install path:

- `install.rs`: read `pnpm-workspace.yaml` after `find_workspace_dir`
  and normalize via `get_catalogs_from_workspace_manifest` into a
  `Catalogs` map. Adds `InstallError::{ReadWorkspaceManifest,
  InvalidCatalogsConfiguration}` so the upstream
  `ERR_PNPM_INVALID_CATALOGS_CONFIGURATION` propagates verbatim.
- `install_without_lockfile.rs`: threads the `Catalogs` map into
  `ResolveDependencyTreeOptions`. (Frozen-lockfile catalog handling
  needs a lockfile-snapshot pass and is a separate slice.)
- `resolve_dependency_tree`: replaces direct (importer-level)
  `catalog:` bare specifiers with the catalog's recorded version
  before the resolver chain dispatches. Catalog resolution does NOT
  run on transitive deps, matching upstream's importer-only scope.
  Misconfigured entries surface as `CatalogMisconfiguration` with
  the upstream `ERR_PNPM_CATALOG_ENTRY_*` code instead of leaking
  through to `SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER`.

Tests:

- deps-resolver: two new unit tests prove direct-dep rewriting and
  the misconfiguration code path.
- cli (e2e): `install_resolves_catalog_protocol` runs the binary
  against a workspace with a `catalog:` entry and checks the
  virtual-store layout; `install_surfaces_catalog_misconfiguration`
  asserts the upstream message is surfaced when the catalog has no
  matching alias.

* style(pacquet): apply rustfmt to catalogs test after rebase

* fix(pacquet): rename single-letter closure param in catalogs e2e test

Perfectionist's `single_letter_closure_param` lint (CI-only via
Dylint) flagged `|c|` in the box-drawing-strip filter.
This commit is contained in:
Zoltan Kochan
2026-05-21 02:06:05 +02:00
committed by GitHub
parent df990fdb51
commit b2a95fa1f7
24 changed files with 1001 additions and 10 deletions

36
Cargo.lock generated
View File

@@ -1971,6 +1971,37 @@ version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
[[package]]
name = "pacquet-catalogs-config"
version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
"pacquet-catalogs-types",
"pacquet-workspace",
]
[[package]]
name = "pacquet-catalogs-protocol-parser"
version = "0.0.1"
dependencies = [
"pacquet-catalogs-types",
]
[[package]]
name = "pacquet-catalogs-resolver"
version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
"pacquet-catalogs-protocol-parser",
"pacquet-catalogs-types",
]
[[package]]
name = "pacquet-catalogs-types"
version = "0.0.1"
[[package]]
name = "pacquet-cli"
version = "0.0.1"
@@ -2366,6 +2397,8 @@ dependencies = [
"insta",
"miette 7.6.0",
"node-semver",
"pacquet-catalogs-config",
"pacquet-catalogs-types",
"pacquet-cmd-shim",
"pacquet-config",
"pacquet-crypto-hash",
@@ -2527,6 +2560,8 @@ dependencies = [
"futures-util",
"miette 7.6.0",
"node-semver",
"pacquet-catalogs-resolver",
"pacquet-catalogs-types",
"pacquet-deps-path",
"pacquet-lockfile",
"pacquet-package-manifest",
@@ -2719,6 +2754,7 @@ version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
"pacquet-catalogs-types",
"pacquet-package-manifest",
"pretty_assertions",
"serde",

View File

@@ -13,6 +13,10 @@ repository = "https://github.com/pnpm/pacquet"
[workspace.dependencies]
# Crates
pacquet-catalogs-config = { path = "pacquet/crates/catalogs-config" }
pacquet-catalogs-protocol-parser = { path = "pacquet/crates/catalogs-protocol-parser" }
pacquet-catalogs-resolver = { path = "pacquet/crates/catalogs-resolver" }
pacquet-catalogs-types = { path = "pacquet/crates/catalogs-types" }
pacquet-cli = { path = "pacquet/crates/cli" }
pacquet-cmd-shim = { path = "pacquet/crates/cmd-shim" }
pacquet-crypto-hash = { path = "pacquet/crates/crypto-hash" }

View File

@@ -0,0 +1,21 @@
[package]
name = "pacquet-catalogs-config"
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-catalogs-types = { workspace = true }
pacquet-workspace = { workspace = true }
derive_more = { workspace = true }
miette = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1,81 @@
//! Pacquet port of pnpm's
//! [`@pnpm/catalogs.config`](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/config/src/getCatalogsFromWorkspaceManifest.ts).
//!
//! Normalizes the two surface forms `pnpm-workspace.yaml` supports for
//! defining the default catalog — `catalog:` at the top level vs.
//! `catalogs.default` nested under the named catalogs — into the
//! single flat [`Catalogs`] map every resolver consumer expects.
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_catalogs_types::{Catalogs, DEFAULT_CATALOG_NAME};
use pacquet_workspace::WorkspaceManifest;
/// Raised when the workspace manifest defines the default catalog
/// twice — once via the top-level `catalog:` shorthand and once via
/// the explicit `catalogs.default` key.
///
/// Mirrors upstream's `INVALID_CATALOGS_CONFIGURATION` `PnpmError`
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/config/src/getCatalogsFromWorkspaceManifest.ts#L32-L37)).
#[derive(Debug, Display, Error, Diagnostic, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum InvalidCatalogsConfigurationError {
#[display(
"The 'default' catalog was defined multiple times. Use the 'catalog' field or 'catalogs.default', but not both."
)]
#[diagnostic(code(ERR_PNPM_INVALID_CATALOGS_CONFIGURATION))]
DefaultDefinedMultipleTimes,
}
/// Project the catalog-shaped fields from a parsed workspace manifest
/// into a single flat [`Catalogs`] map.
///
/// Returns an empty map when `workspace_manifest` is `None`, matching
/// the upstream "no workspace manifest → no catalogs" branch.
///
/// Mirrors upstream's `getCatalogsFromWorkspaceManifest`
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/config/src/getCatalogsFromWorkspaceManifest.ts#L5-L30)).
pub fn get_catalogs_from_workspace_manifest(
workspace_manifest: Option<&WorkspaceManifest>,
) -> Result<Catalogs, InvalidCatalogsConfigurationError> {
let Some(manifest) = workspace_manifest else {
return Ok(Catalogs::new());
};
check_default_catalog_is_defined_once(manifest)?;
// Upstream spreads `workspace.catalogs` after writing `default`, so
// an explicit `catalogs.default` overrides the (already-validated
// to be absent) `catalog` field. With `catalog`/`catalogs.default`
// mutually exclusive only one branch ever populates the key.
let mut catalogs = Catalogs::new();
if let Some(default) = &manifest.catalog {
catalogs.insert(DEFAULT_CATALOG_NAME.to_string(), default.clone());
}
if let Some(named) = &manifest.catalogs {
for (name, catalog) in named {
catalogs.insert(name.clone(), catalog.clone());
}
}
Ok(catalogs)
}
/// Validate that the default catalog is defined through at most one of
/// the two surface forms.
///
/// Mirrors upstream's `checkDefaultCatalogIsDefinedOnce`
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/config/src/getCatalogsFromWorkspaceManifest.ts#L32-L40)).
pub fn check_default_catalog_is_defined_once(
manifest: &WorkspaceManifest,
) -> Result<(), InvalidCatalogsConfigurationError> {
if manifest.catalog.is_some()
&& manifest.catalogs.as_ref().is_some_and(|c| c.contains_key(DEFAULT_CATALOG_NAME))
{
return Err(InvalidCatalogsConfigurationError::DefaultDefinedMultipleTimes);
}
Ok(())
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,68 @@
//! Ports of `catalogs/config/test/getCatalogsFromWorkspaceManifest.test.ts`
//! ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/config/test/getCatalogsFromWorkspaceManifest.test.ts)).
use super::{InvalidCatalogsConfigurationError, get_catalogs_from_workspace_manifest};
use pacquet_catalogs_types::{Catalog, Catalogs};
use pacquet_workspace::WorkspaceManifest;
fn catalog_from(entries: &[(&str, &str)]) -> Catalog {
entries.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect()
}
#[test]
fn combines_implicit_default_and_named_catalogs() {
let manifest = WorkspaceManifest {
catalog: Some(catalog_from(&[("foo", "^1.0.0")])),
catalogs: Some(Catalogs::from([("bar".to_string(), catalog_from(&[("baz", "^2.0.0")]))])),
..WorkspaceManifest::default()
};
let expected = Catalogs::from([
("default".to_string(), catalog_from(&[("foo", "^1.0.0")])),
("bar".to_string(), catalog_from(&[("baz", "^2.0.0")])),
]);
assert_eq!(get_catalogs_from_workspace_manifest(Some(&manifest)).unwrap(), expected);
}
#[test]
fn combines_explicit_default_and_named_catalogs() {
let manifest = WorkspaceManifest {
catalog: None,
catalogs: Some(Catalogs::from([
("default".to_string(), catalog_from(&[("foo", "^1.0.0")])),
("bar".to_string(), catalog_from(&[("baz", "^2.0.0")])),
])),
..WorkspaceManifest::default()
};
let expected = Catalogs::from([
("default".to_string(), catalog_from(&[("foo", "^1.0.0")])),
("bar".to_string(), catalog_from(&[("baz", "^2.0.0")])),
]);
assert_eq!(get_catalogs_from_workspace_manifest(Some(&manifest)).unwrap(), expected);
}
#[test]
fn throws_if_default_catalog_is_defined_multiple_times() {
let manifest = WorkspaceManifest {
catalog: Some(catalog_from(&[("bar", "^2.0.0")])),
catalogs: Some(Catalogs::from([(
"default".to_string(),
catalog_from(&[("foo", "^1.0.0")]),
)])),
..WorkspaceManifest::default()
};
let err = get_catalogs_from_workspace_manifest(Some(&manifest)).unwrap_err();
assert_eq!(err, InvalidCatalogsConfigurationError::DefaultDefinedMultipleTimes);
assert_eq!(
err.to_string(),
"The 'default' catalog was defined multiple times. \
Use the 'catalog' field or 'catalogs.default', but not both.",
);
}
#[test]
fn returns_empty_map_for_missing_workspace_manifest() {
assert_eq!(get_catalogs_from_workspace_manifest(None).unwrap(), Catalogs::new());
}

View File

@@ -0,0 +1,17 @@
[package]
name = "pacquet-catalogs-protocol-parser"
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-catalogs-types = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1,27 @@
//! Pacquet port of pnpm's
//! [`@pnpm/catalogs.protocol-parser`](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/protocol-parser/src/parseCatalogProtocol.ts).
//!
//! Splits the `catalog:` protocol prefix off a manifest bare specifier
//! and returns the requested catalog name. Used by the resolver chain
//! to decide whether a wanted dependency should be looked up in a
//! catalog before falling through to the npm / git / tarball resolvers.
use pacquet_catalogs_types::DEFAULT_CATALOG_NAME;
const CATALOG_PROTOCOL: &str = "catalog:";
/// Parse a package.json dependency specifier using the `catalog:`
/// protocol.
///
/// Returns `None` if the specifier does not start with `catalog:`.
/// An empty `catalog:` is shorthand for [`DEFAULT_CATALOG_NAME`].
///
/// Mirrors upstream's `parseCatalogProtocol`
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/protocol-parser/src/parseCatalogProtocol.ts#L3-L16)).
pub fn parse_catalog_protocol(bare_specifier: &str) -> Option<&str> {
let raw = bare_specifier.strip_prefix(CATALOG_PROTOCOL)?.trim();
Some(if raw.is_empty() { DEFAULT_CATALOG_NAME } else { raw })
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,25 @@
//! Ports of `catalogs/protocol-parser/test/parseCatalogProtocol.test.ts`
//! ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/protocol-parser/test/parseCatalogProtocol.test.ts)).
use super::parse_catalog_protocol;
#[test]
fn parses_named_catalog() {
assert_eq!(parse_catalog_protocol("catalog:foo"), Some("foo"));
assert_eq!(parse_catalog_protocol("catalog:bar"), Some("bar"));
}
#[test]
fn returns_none_for_specifier_not_using_catalog_protocol() {
assert_eq!(parse_catalog_protocol("^1.0.0"), None);
}
#[test]
fn parses_explicit_default_catalog() {
assert_eq!(parse_catalog_protocol("catalog:default"), Some("default"));
}
#[test]
fn parses_implicit_default_catalog() {
assert_eq!(parse_catalog_protocol("catalog:"), Some("default"));
}

View File

@@ -0,0 +1,21 @@
[package]
name = "pacquet-catalogs-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-catalogs-protocol-parser = { workspace = true }
pacquet-catalogs-types = { workspace = true }
derive_more = { workspace = true }
miette = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1,184 @@
//! Pacquet port of pnpm's
//! [`@pnpm/catalogs.resolver`](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts).
//!
//! Dereferences a `catalog:` bare specifier against a parsed
//! [`Catalogs`] map and returns either the configured version or one of
//! the upstream misconfiguration errors. The npm-resolver chain calls
//! [`resolve_from_catalog`] before its own protocol dispatch so a
//! resolved [`CatalogResolutionFound::resolution`] feeds back in as a
//! plain bare specifier.
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_catalogs_protocol_parser::parse_catalog_protocol;
use pacquet_catalogs_types::Catalogs;
/// Subset of `pacquet-resolving-resolver-base`'s `WantedDependency`
/// that catalog resolution needs. Modeled as its own type so this
/// crate doesn't depend on the resolver-base crate; the conversion
/// is a trivial field copy at the call site.
///
/// Mirrors upstream's `WantedDependency` interface
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L5-L8)).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WantedDependency {
pub alias: String,
pub bare_specifier: String,
}
/// Outcome of [`resolve_from_catalog`].
///
/// Mirrors upstream's `CatalogResolutionResult` discriminated union
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L19)).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CatalogResolutionResult {
/// The catalog protocol resolved to a usable specifier.
Found(CatalogResolutionFound),
/// The catalog entry was missing or used a forbidden protocol.
Misconfiguration(CatalogResolutionMisconfiguration),
/// The wanted dependency does not use the catalog protocol.
Unused,
}
/// Successful catalog dereference.
///
/// Mirrors upstream's `CatalogResolutionFound`
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L21-L24)).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogResolutionFound {
pub resolution: CatalogResolution,
}
/// Resolved (catalog name, specifier) pair.
///
/// Mirrors upstream's `CatalogResolution`
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L26-L38)).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogResolution {
/// Catalog the entry was found in.
pub catalog_name: String,
/// Version specifier the catalog entry resolved to.
pub specifier: String,
}
/// A user-misconfigured catalog entry. Carries the error so the call
/// site can rethrow or render it.
///
/// Mirrors upstream's `CatalogResolutionMisconfiguration`
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L40-L52)).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogResolutionMisconfiguration {
pub catalog_name: String,
pub error: CatalogResolutionError,
}
/// The four ways a `catalog:` lookup can fail. Each variant maps
/// byte-for-byte to the upstream `PnpmError` code.
///
/// Mirrors the four errors raised by upstream's `resolveFromCatalog`
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L60-L120)).
#[derive(Debug, Display, Error, Diagnostic, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CatalogResolutionError {
#[display("No catalog entry '{alias}' was found for catalog '{catalog_name}'.")]
#[diagnostic(code(ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC))]
EntryNotFoundForSpec { alias: String, catalog_name: String },
#[display(
"Found invalid catalog entry using the catalog protocol recursively. The entry for '{alias}' in catalog '{catalog_name}' is invalid."
)]
#[diagnostic(code(ERR_PNPM_CATALOG_ENTRY_INVALID_RECURSIVE_DEFINITION))]
EntryInvalidRecursiveDefinition { alias: String, catalog_name: String },
#[display(
"The workspace protocol cannot be used as a catalog value. The entry for '{alias}' in catalog '{catalog_name}' is invalid."
)]
#[diagnostic(code(ERR_PNPM_CATALOG_ENTRY_INVALID_WORKSPACE_SPEC))]
EntryInvalidWorkspaceSpec { alias: String, catalog_name: String },
#[display(
"The entry for '{alias}' in catalog '{catalog_name}' declares a dependency using the '{protocol}' protocol. This is not yet supported, but may be in a future version of pnpm."
)]
#[diagnostic(code(ERR_PNPM_CATALOG_ENTRY_INVALID_SPEC))]
EntryInvalidSpec { alias: String, catalog_name: String, protocol: String },
}
/// Resolve a wanted dependency through the catalogs map.
///
/// - Non-`catalog:` specifiers return [`CatalogResolutionResult::Unused`]
/// so the caller falls through to the next resolver.
/// - Missing entries, recursive entries, and forbidden protocols
/// (`workspace:`, `link:`, `file:`) return
/// [`CatalogResolutionResult::Misconfiguration`].
/// - A valid entry returns [`CatalogResolutionResult::Found`].
///
/// Mirrors upstream's `resolveFromCatalog`
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L60-L130)).
pub fn resolve_from_catalog(
catalogs: &Catalogs,
wanted_dependency: &WantedDependency,
) -> CatalogResolutionResult {
let Some(catalog_name) = parse_catalog_protocol(&wanted_dependency.bare_specifier) else {
return CatalogResolutionResult::Unused;
};
let catalog_lookup =
catalogs.get(catalog_name).and_then(|catalog| catalog.get(&wanted_dependency.alias));
let Some(catalog_lookup) = catalog_lookup else {
return CatalogResolutionResult::Misconfiguration(CatalogResolutionMisconfiguration {
catalog_name: catalog_name.to_string(),
error: CatalogResolutionError::EntryNotFoundForSpec {
alias: wanted_dependency.alias.clone(),
catalog_name: catalog_name.to_string(),
},
});
};
if parse_catalog_protocol(catalog_lookup).is_some() {
return CatalogResolutionResult::Misconfiguration(CatalogResolutionMisconfiguration {
catalog_name: catalog_name.to_string(),
error: CatalogResolutionError::EntryInvalidRecursiveDefinition {
alias: wanted_dependency.alias.clone(),
catalog_name: catalog_name.to_string(),
},
});
}
// Banning `workspace:` matches upstream's three-part justification
// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/src/resolveFromCatalog.ts#L84-L100)):
// it's silly to indirect through a catalog when the workspace
// protocol resolves directly, and `link:` resolutions cannot be
// cached in `pnpm-lock.yaml` across importers the way semver
// selectors can.
let protocol_of_lookup = catalog_lookup.split(':').next().unwrap_or("");
if protocol_of_lookup == "workspace" {
return CatalogResolutionResult::Misconfiguration(CatalogResolutionMisconfiguration {
catalog_name: catalog_name.to_string(),
error: CatalogResolutionError::EntryInvalidWorkspaceSpec {
alias: wanted_dependency.alias.clone(),
catalog_name: catalog_name.to_string(),
},
});
}
if matches!(protocol_of_lookup, "link" | "file") {
return CatalogResolutionResult::Misconfiguration(CatalogResolutionMisconfiguration {
catalog_name: catalog_name.to_string(),
error: CatalogResolutionError::EntryInvalidSpec {
alias: wanted_dependency.alias.clone(),
catalog_name: catalog_name.to_string(),
protocol: protocol_of_lookup.to_string(),
},
});
}
CatalogResolutionResult::Found(CatalogResolutionFound {
resolution: CatalogResolution {
catalog_name: catalog_name.to_string(),
specifier: catalog_lookup.clone(),
},
})
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,187 @@
//! Ports of `catalogs/resolver/test/resolveFromCatalog.test.ts`
//! ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/resolver/test/resolveFromCatalog.test.ts)).
use super::{
CatalogResolution, CatalogResolutionError, CatalogResolutionFound, CatalogResolutionResult,
WantedDependency, resolve_from_catalog,
};
use pacquet_catalogs_types::{Catalog, Catalogs};
fn catalogs_from(entries: &[(&str, &[(&str, &str)])]) -> Catalogs {
entries
.iter()
.map(|(name, items)| {
let catalog: Catalog =
items.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect();
((*name).to_string(), catalog)
})
.collect()
}
fn wanted(alias: &str, bare_specifier: &str) -> WantedDependency {
WantedDependency { alias: alias.to_string(), bare_specifier: bare_specifier.to_string() }
}
#[test]
fn default_catalog_resolves_using_implicit_name() {
let catalogs = catalogs_from(&[("default", &[("foo", "1.0.0")])]);
assert_eq!(
resolve_from_catalog(&catalogs, &wanted("foo", "catalog:")),
CatalogResolutionResult::Found(CatalogResolutionFound {
resolution: CatalogResolution {
catalog_name: "default".to_string(),
specifier: "1.0.0".to_string(),
},
}),
);
}
#[test]
fn default_catalog_resolves_using_explicit_name() {
let catalogs = catalogs_from(&[("default", &[("foo", "1.0.0")])]);
assert_eq!(
resolve_from_catalog(&catalogs, &wanted("foo", "catalog:default")),
CatalogResolutionResult::Found(CatalogResolutionFound {
resolution: CatalogResolution {
catalog_name: "default".to_string(),
specifier: "1.0.0".to_string(),
},
}),
);
}
#[test]
fn resolves_named_catalog() {
let catalogs = catalogs_from(&[("foo", &[("bar", "1.0.0")])]);
assert_eq!(
resolve_from_catalog(&catalogs, &wanted("bar", "catalog:foo")),
CatalogResolutionResult::Found(CatalogResolutionFound {
resolution: CatalogResolution {
catalog_name: "foo".to_string(),
specifier: "1.0.0".to_string(),
},
}),
);
}
#[test]
fn returns_unused_for_specifier_not_using_catalog_protocol() {
let catalogs = catalogs_from(&[("foo", &[("bar", "1.0.0")])]);
assert_eq!(
resolve_from_catalog(&catalogs, &wanted("bar", "^2.0.0")),
CatalogResolutionResult::Unused,
);
}
#[test]
fn returns_error_for_missing_unresolved_catalog() {
let catalogs = catalogs_from(&[("foo", &[("bar", "1.0.0")])]);
for (alias, bare, expected_catalog) in [
("bar", "catalog:", "default"),
("bar", "catalog:baz", "baz"),
("foo", "catalog:foo", "foo"),
] {
let result = resolve_from_catalog(&catalogs, &wanted(alias, bare));
let CatalogResolutionResult::Misconfiguration(misconfig) = &result else {
panic!("expected misconfiguration for ({alias}, {bare}), got {result:?}");
};
assert_eq!(misconfig.catalog_name, expected_catalog);
assert_eq!(
misconfig.error,
CatalogResolutionError::EntryNotFoundForSpec {
alias: alias.to_string(),
catalog_name: expected_catalog.to_string(),
},
);
assert_eq!(
misconfig.error.to_string(),
format!("No catalog entry '{alias}' was found for catalog '{expected_catalog}'."),
);
}
}
#[test]
fn returns_error_for_recursive_catalog() {
let catalogs = catalogs_from(&[("foo", &[("bar", "catalog:foo")])]);
let result = resolve_from_catalog(&catalogs, &wanted("bar", "catalog:foo"));
let CatalogResolutionResult::Misconfiguration(misconfig) = &result else {
panic!("expected misconfiguration, got {result:?}");
};
assert_eq!(
misconfig.error,
CatalogResolutionError::EntryInvalidRecursiveDefinition {
alias: "bar".to_string(),
catalog_name: "foo".to_string(),
},
);
assert_eq!(
misconfig.error.to_string(),
"Found invalid catalog entry using the catalog protocol recursively. \
The entry for 'bar' in catalog 'foo' is invalid.",
);
}
#[test]
fn returns_error_for_workspace_protocol_in_catalog() {
let catalogs = catalogs_from(&[("foo", &[("bar", "workspace:*")])]);
let result = resolve_from_catalog(&catalogs, &wanted("bar", "catalog:foo"));
let CatalogResolutionResult::Misconfiguration(misconfig) = &result else {
panic!("expected misconfiguration, got {result:?}");
};
assert_eq!(
misconfig.error,
CatalogResolutionError::EntryInvalidWorkspaceSpec {
alias: "bar".to_string(),
catalog_name: "foo".to_string(),
},
);
assert_eq!(
misconfig.error.to_string(),
"The workspace protocol cannot be used as a catalog value. \
The entry for 'bar' in catalog 'foo' is invalid.",
);
}
#[test]
fn returns_error_for_file_protocol_in_catalog() {
let catalogs = catalogs_from(&[("foo", &[("bar", "file:./bar.tgz")])]);
let result = resolve_from_catalog(&catalogs, &wanted("bar", "catalog:foo"));
let CatalogResolutionResult::Misconfiguration(misconfig) = &result else {
panic!("expected misconfiguration, got {result:?}");
};
assert_eq!(
misconfig.error,
CatalogResolutionError::EntryInvalidSpec {
alias: "bar".to_string(),
catalog_name: "foo".to_string(),
protocol: "file".to_string(),
},
);
assert_eq!(
misconfig.error.to_string(),
"The entry for 'bar' in catalog 'foo' declares a dependency using the 'file' protocol. \
This is not yet supported, but may be in a future version of pnpm.",
);
}
#[test]
fn returns_error_for_link_protocol_in_catalog() {
let catalogs = catalogs_from(&[("foo", &[("bar", "link:./bar")])]);
let result = resolve_from_catalog(&catalogs, &wanted("bar", "catalog:foo"));
let CatalogResolutionResult::Misconfiguration(misconfig) = &result else {
panic!("expected misconfiguration, got {result:?}");
};
assert_eq!(
misconfig.error,
CatalogResolutionError::EntryInvalidSpec {
alias: "bar".to_string(),
catalog_name: "foo".to_string(),
protocol: "link".to_string(),
},
);
assert_eq!(
misconfig.error.to_string(),
"The entry for 'bar' in catalog 'foo' declares a dependency using the 'link' protocol. \
This is not yet supported, but may be in a future version of pnpm.",
);
}

View File

@@ -0,0 +1,16 @@
[package]
name = "pacquet-catalogs-types"
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]
[lints]
workspace = true

View File

@@ -0,0 +1,30 @@
//! Pacquet port of pnpm's
//! [`@pnpm/catalogs.types`](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/types/src/index.ts).
//!
//! Defines [`Catalog`] and [`Catalogs`], the normalized in-memory shape
//! every other catalogs crate consumes. Both are plain typed maps in
//! upstream — `interface Catalog { [name: string]: string | undefined }`
//! and `interface Catalogs { default?: Catalog; [name: string]: Catalog
//! | undefined }` — so pacquet exposes them as `BTreeMap` aliases. The
//! `"default"` catalog is just the well-known key inside [`Catalogs`];
//! no separate field is needed.
use std::collections::BTreeMap;
/// The well-known name of the default catalog. Matches the literal pnpm
/// uses everywhere a catalog name is implicit.
pub const DEFAULT_CATALOG_NAME: &str = "default";
/// One catalog: a map of dependency name to version specifier.
///
/// Mirrors upstream's `Catalog` interface
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/types/src/index.ts#L27-L29)).
pub type Catalog = BTreeMap<String, String>;
/// The full set of catalogs parsed from `pnpm-workspace.yaml`. The
/// default catalog lives at the [`DEFAULT_CATALOG_NAME`] key; any other
/// key is a user-defined named catalog.
///
/// Mirrors upstream's `Catalogs` interface
/// ([source](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/types/src/index.ts#L1-L25)).
pub type Catalogs = BTreeMap<String, Catalog>;

View File

@@ -364,3 +364,84 @@ fn auto_install_peers_hoists_missing_peers_at_importer() {
drop((root, mock_instance)); // cleanup
}
/// `catalog:` on a direct dep should be dereferenced through
/// `pnpm-workspace.yaml`'s `catalog` section before the npm resolver
/// sees it. The fetched virtual-store entry is the catalog's resolved
/// version, not the literal `catalog:` string.
///
/// Mirrors the upstream end-to-end coverage in
/// [`installing/deps-installer/test/catalogs.ts`](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/installing/deps-installer/test/catalogs.ts).
#[test]
fn install_resolves_catalog_protocol() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
eprintln!("Appending catalog to pnpm-workspace.yaml...");
let workspace_yaml = workspace.join("pnpm-workspace.yaml");
let mut existing = fs::read_to_string(&workspace_yaml).expect("read pnpm-workspace.yaml");
existing.push_str("catalog:\n '@pnpm.e2e/hello-world-js-bin-parent': '1.0.0'\n");
fs::write(&workspace_yaml, existing).expect("write pnpm-workspace.yaml");
eprintln!("Creating package.json that uses the catalog protocol...");
let manifest_path = workspace.join("package.json");
let package_json_content = serde_json::json!({
"dependencies": {
"@pnpm.e2e/hello-world-js-bin-parent": "catalog:",
},
});
fs::write(&manifest_path, package_json_content.to_string()).expect("write to package.json");
eprintln!("Executing command...");
pacquet.with_arg("install").assert().success();
eprintln!("Make sure the package is installed at the catalog's version");
let symlink_path = workspace.join("node_modules/@pnpm.e2e/hello-world-js-bin-parent");
assert!(is_symlink_or_junction(&symlink_path).unwrap());
let virtual_path =
workspace.join("node_modules/.pnpm/@pnpm.e2e+hello-world-js-bin-parent@1.0.0");
assert!(virtual_path.exists(), "expected virtual store entry at {virtual_path:?}");
drop((root, mock_instance)); // cleanup
}
/// A misconfigured catalog (specifier points at a missing entry) must
/// fail the install with the upstream `ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC`
/// rather than the chain's `SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER`.
#[test]
fn install_surfaces_catalog_misconfiguration() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
eprintln!("Creating package.json with a catalog: dep but no matching catalog entry...");
let manifest_path = workspace.join("package.json");
let package_json_content = serde_json::json!({
"dependencies": {
"@pnpm.e2e/hello-world-js-bin-parent": "catalog:",
},
});
fs::write(&manifest_path, package_json_content.to_string()).expect("write to package.json");
eprintln!("Executing command...");
let output = pacquet.with_arg("install").assert().failure();
let stderr = String::from_utf8_lossy(&output.get_output().stderr);
eprintln!("stderr={stderr}");
// The miette report hard-wraps the message and inserts a leading
// `│` on the wrapped line. Strip all whitespace and box-drawing
// characters before substring-matching so wrap position can't
// make the assertion brittle.
let flattened: String = stderr
.chars()
.filter(|ch| !ch.is_whitespace() && !matches!(ch, '│' | '├' | '╰' | '─' | '▶' | '×'))
.collect();
assert!(
flattened.contains(
"Nocatalogentry'@pnpm.e2e/hello-world-js-bin-parent'wasfoundforcatalog'default'.",
),
"stderr did not mention the missing-catalog-entry error: {stderr}",
);
drop((root, mock_instance)); // cleanup
}

View File

@@ -11,6 +11,8 @@ license.workspace = true
repository.workspace = true
[dependencies]
pacquet-catalogs-config = { workspace = true }
pacquet-catalogs-types = { workspace = true }
pacquet-cmd-shim = { workspace = true }
pacquet-crypto-hash = { workspace = true }
pacquet-deps-path = { workspace = true }

View File

@@ -7,6 +7,9 @@ use crate::{
};
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_catalogs_config::{
InvalidCatalogsConfigurationError, get_catalogs_from_workspace_manifest,
};
use pacquet_config::{Config, NodeLinker};
use pacquet_lockfile::{
LoadLockfileError, Lockfile, SaveLockfileError, StalenessReason, satisfies_package_manifest,
@@ -166,6 +169,17 @@ pub enum InstallError {
#[diagnostic(transparent)]
FindWorkspaceDir(#[error(source)] pacquet_workspace::FindWorkspaceDirError),
/// Reading `pnpm-workspace.yaml` to extract its `catalog` /
/// `catalogs` sections failed.
#[diagnostic(transparent)]
ReadWorkspaceManifest(#[error(source)] pacquet_workspace::ReadWorkspaceManifestError),
/// `pnpm-workspace.yaml` defined the `default` catalog twice
/// (once via the top-level `catalog:` field and once via
/// `catalogs.default`).
#[diagnostic(transparent)]
InvalidCatalogsConfiguration(#[error(source)] InvalidCatalogsConfigurationError),
/// Building the verifier list from config rejected a
/// `minimumReleaseAgeExclude` or `trustPolicyExclude` pattern.
/// Mirrors upstream's `INVALID_MINIMUM_RELEASE_AGE_EXCLUDE` /
@@ -239,9 +253,24 @@ where
//
// [bunyan]: https://github.com/trentm/node-bunyan
let manifest_dir = manifest.path().parent().expect("manifest path always has a parent dir");
let workspace_root = pacquet_workspace::find_workspace_dir(manifest_dir)
.map_err(InstallError::FindWorkspaceDir)?
.unwrap_or_else(|| manifest_dir.to_path_buf());
let workspace_dir_opt = pacquet_workspace::find_workspace_dir(manifest_dir)
.map_err(InstallError::FindWorkspaceDir)?;
let workspace_root =
workspace_dir_opt.clone().unwrap_or_else(|| manifest_dir.to_path_buf());
// Read `pnpm-workspace.yaml` for the catalog sections. Only
// consulted when a workspace manifest exists — single-project
// installs have no `catalog:` to honor. Mirrors upstream's
// `getCatalogsFromWorkspaceManifest(readWorkspaceManifest(...))`
// pipeline at
// <https://github.com/pnpm/pnpm/blob/a8a8cbce6d/catalogs/config/src/getCatalogsFromWorkspaceManifest.ts>.
let workspace_manifest = match workspace_dir_opt.as_deref() {
Some(dir) => pacquet_workspace::read_workspace_manifest(dir)
.map_err(InstallError::ReadWorkspaceManifest)?,
None => None,
};
let catalogs = get_catalogs_from_workspace_manifest(workspace_manifest.as_ref())
.map_err(InstallError::InvalidCatalogsConfiguration)?;
// Use `to_string_lossy` rather than `to_str().expect(...)` so a
// valid filesystem path with non-UTF-8 bytes (possible on Unix)
// doesn't panic the installer. `prefix` is used only for
@@ -519,6 +548,7 @@ where
dependency_groups,
logged_methods: &logged_methods,
requester: &prefix,
catalogs,
}
.run::<Reporter>()
.await

View File

@@ -7,6 +7,7 @@ use dashmap::{DashMap, mapref::entry::Entry};
use derive_more::{Display, Error};
use futures_util::future;
use miette::Diagnostic;
use pacquet_catalogs_types::Catalogs;
use pacquet_cmd_shim::{Host, LinkBinsError, link_bins};
use pacquet_config::Config;
use pacquet_engine_runtime_bun_resolver::BunResolver;
@@ -85,6 +86,9 @@ pub struct InstallWithoutLockfile<'a, DependencyGroupList> {
pub logged_methods: &'a AtomicU8,
/// Install root, threaded into reporter `requester` fields.
pub requester: &'a str,
/// Catalogs parsed from `pnpm-workspace.yaml`. Empty for projects
/// without a workspace manifest.
pub catalogs: Catalogs,
}
/// Error type of [`InstallWithoutLockfile`].
@@ -170,6 +174,7 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> {
resolved_packages,
logged_methods,
requester,
catalogs,
} = self;
let store_dir: &'static _ = &config.store_dir;
@@ -314,6 +319,7 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> {
published_by_exclude,
..ResolveOptions::default()
},
catalogs,
};
let importer_result =

View File

@@ -11,6 +11,8 @@ license.workspace = true
repository.workspace = true
[dependencies]
pacquet-catalogs-resolver = { workspace = true }
pacquet-catalogs-types = { workspace = true }
pacquet-deps-path = { workspace = true }
pacquet-package-manifest = { workspace = true }
pacquet-resolving-resolver-base = { workspace = true }

View File

@@ -4,6 +4,11 @@ use async_recursion::async_recursion;
use derive_more::{Display, Error};
use futures_util::future;
use miette::Diagnostic;
use pacquet_catalogs_resolver::{
CatalogResolutionError, CatalogResolutionResult, WantedDependency as CatalogWantedDependency,
resolve_from_catalog,
};
use pacquet_catalogs_types::Catalogs;
use pacquet_package_manifest::{DependencyGroup, PackageManifest};
use pacquet_resolving_resolver_base::{ResolveError, ResolveOptions, Resolver, WantedDependency};
use pipe_trait::Pipe;
@@ -48,6 +53,13 @@ pub enum ResolveDependencyTreeError {
#[error(not(source))]
specifier: String,
},
/// A `catalog:` specifier on a direct dependency referenced a
/// missing entry, used a forbidden protocol, or was otherwise
/// misconfigured. The inner error carries the upstream
/// `ERR_PNPM_CATALOG_ENTRY_*` code and message.
#[diagnostic(transparent)]
CatalogMisconfiguration(#[error(source)] CatalogResolutionError),
}
/// Walk `manifest` plus the entries in `dependency_groups`, dispatch
@@ -306,6 +318,35 @@ where
Ok(Some(DirectDep { alias, node_id, id }))
}
/// Replace `catalog:` bare specifiers on direct dependencies with the
/// version recorded in the catalogs map. Non-`catalog:` specifiers
/// pass through unchanged.
///
/// Catalog resolution runs only on importer-level deps, matching
/// upstream's
/// [importer-only scope](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/installing/deps-resolver/src/resolveDependencies.ts#L592-L600).
/// A misconfigured entry surfaces immediately rather than masquerading
/// as a `SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER`.
pub(crate) fn resolve_catalog_specifiers(
specs: Vec<(String, String)>,
catalogs: &Catalogs,
) -> Result<Vec<(String, String)>, ResolveDependencyTreeError> {
specs
.into_iter()
.map(|(name, range)| {
let wanted =
CatalogWantedDependency { alias: name.clone(), bare_specifier: range.clone() };
match resolve_from_catalog(catalogs, &wanted) {
CatalogResolutionResult::Found(found) => Ok((name, found.resolution.specifier)),
CatalogResolutionResult::Unused => Ok((name, range)),
CatalogResolutionResult::Misconfiguration(misconfig) => {
Err(ResolveDependencyTreeError::CatalogMisconfiguration(misconfig.error))
}
}
})
.collect()
}
/// Render `{alias}@{bare}` (either half dropped when absent) for the
/// error message. Mirrors upstream's `render_specifier` shape in
/// `default-resolver`.

View File

@@ -24,6 +24,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet};
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_catalogs_types::Catalogs;
use pacquet_package_manifest::{DependencyGroup, PackageManifest};
use pacquet_resolving_resolver_base::{
PreferredVersions, ResolveOptions, Resolver, VersionSelectorEntry, VersionSelectorType,
@@ -36,7 +37,9 @@ use crate::{
HoistPeersOptions, MissingPeerInfo, WorkspaceRootDep, get_hoistable_optional_peers,
hoist_peers,
},
resolve_dependency_tree::{ResolveDependencyTreeError, TreeCtx, extend_tree},
resolve_dependency_tree::{
ResolveDependencyTreeError, TreeCtx, extend_tree, resolve_catalog_specifiers,
},
resolve_peers::{ResolvePeersOptions, ResolvePeersResult, resolve_peers},
resolved_tree::ResolvedTree,
};
@@ -72,6 +75,12 @@ pub struct ResolveImporterOptions {
pub all_preferred_versions: PreferredVersions,
pub base_opts: ResolveOptions,
/// Catalogs parsed from `pnpm-workspace.yaml`. Applied only to the
/// importer's direct dependencies; transitive `catalog:` entries
/// are not resolved through the catalog, matching upstream's
/// [importer-only catalog scope](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/installing/deps-resolver/src/resolveDependencies.ts#L592-L600).
pub catalogs: Catalogs,
}
/// Result of [`fn@resolve_importer`] — the fully-walked tree plus the
@@ -113,6 +122,7 @@ where
resolve_peers_from_workspace_root,
mut all_preferred_versions,
base_opts,
catalogs,
} = opts;
let ctx = TreeCtx::new(base_opts);
@@ -131,6 +141,7 @@ where
.dependencies(groups)
.map(|(name, range)| (name.to_string(), range.to_string()))
.collect();
let initial_wanted = resolve_catalog_specifiers(initial_wanted, &catalogs)?;
let mut direct = extend_tree(&ctx, resolver, initial_wanted).await?;
update_preferred_versions_with_ctx(&ctx, &mut all_preferred_versions).await;

View File

@@ -7,7 +7,10 @@ use pacquet_resolving_resolver_base::{
};
use pretty_assertions::assert_eq;
use crate::{DepPath, resolve_importer, resolve_importer::ResolveImporterOptions};
use crate::{
DepPath, ResolveDependencyTreeError, resolve_importer,
resolve_importer::{ResolveImporterError, ResolveImporterOptions},
};
struct StubResolver {
table: HashMap<(String, String), ResolveResult>,
@@ -86,6 +89,7 @@ fn default_opts() -> ResolveImporterOptions {
resolve_peers_from_workspace_root: false,
all_preferred_versions: PreferredVersions::new(),
base_opts: ResolveOptions::default(),
catalogs: pacquet_catalogs_types::Catalogs::new(),
}
}
@@ -766,3 +770,57 @@ async fn auto_install_hoisted_peer_dep_reuses_regular_dep_version() {
"expected one c@2.0.0 entry (not a second copy via the peer arm), got: {c_entries:?}",
);
}
/// `catalog:` on a direct dependency is rewritten to the catalog's
/// recorded specifier before the resolver chain sees the wanted dep.
/// Mirrors upstream's
/// [importer-only catalog dereference](https://github.com/pnpm/pnpm/blob/a8a8cbce6d/installing/deps-resolver/src/resolveDependencies.ts#L592-L611).
#[tokio::test]
async fn catalog_protocol_on_direct_dep_is_rewritten() {
let mut table = HashMap::new();
table.insert(
("foo".to_string(), "^1.0.0".to_string()),
fake_result("foo", "1.2.0", serde_json::json!({ "name": "foo", "version": "1.2.0" })),
);
let resolver = StubResolver { table, calls: Mutex::new(Vec::new()) };
let (_tmp, manifest) = fake_manifest(serde_json::json!({ "foo": "catalog:" }));
let mut catalogs = pacquet_catalogs_types::Catalogs::new();
catalogs.insert(
"default".to_string(),
[("foo".to_string(), "^1.0.0".to_string())].into_iter().collect(),
);
let opts = ResolveImporterOptions { catalogs, ..default_opts() };
let result =
resolve_importer(&resolver, &manifest, [DependencyGroup::Prod], opts).await.unwrap();
assert_eq!(result.resolved_tree.direct.len(), 1);
assert_eq!(result.resolved_tree.direct[0].alias, "foo");
// The resolver chain only sees the catalog-rewritten range.
let calls = resolver.calls.lock().unwrap();
assert_eq!(&*calls, &[("foo".to_string(), "^1.0.0".to_string())]);
}
/// A misconfigured `catalog:` entry (here: missing alias) short-
/// circuits resolution with the upstream `CATALOG_ENTRY_NOT_FOUND_FOR_SPEC`
/// error rather than falling through to `SpecNotSupported`.
#[tokio::test]
async fn catalog_misconfiguration_surfaces_pnpm_error_code() {
let resolver = StubResolver { table: HashMap::new(), calls: Mutex::new(Vec::new()) };
let (_tmp, manifest) = fake_manifest(serde_json::json!({ "foo": "catalog:" }));
let err = resolve_importer(&resolver, &manifest, [DependencyGroup::Prod], default_opts())
.await
.expect_err("missing catalog entry must error");
match err {
ResolveImporterError::Resolve(ResolveDependencyTreeError::CatalogMisconfiguration(
inner,
)) => {
assert_eq!(
inner.to_string(),
"No catalog entry 'foo' was found for catalog 'default'.",
);
}
other => panic!("expected CatalogMisconfiguration, got {other:?}"),
}
}

View File

@@ -11,6 +11,7 @@ license.workspace = true
repository.workspace = true
[dependencies]
pacquet-catalogs-types = { workspace = true }
pacquet-package-manifest = { workspace = true }
derive_more = { workspace = true }

View File

@@ -23,6 +23,7 @@
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_catalogs_types::{Catalog, Catalogs};
use serde::Deserialize;
use std::{
fs,
@@ -41,11 +42,6 @@ pub const WORKSPACE_MANIFEST_FILENAME: &str = "pnpm-workspace.yaml";
/// Splitting along the upstream package boundary keeps each reader
/// focused on the shape its callers actually need and avoids a
/// monolithic struct that has to grow with every new pnpm setting.
///
/// Catalogs are accepted at parse time but not yet consumed downstream
/// — they belong to Stage 2 of the workspace roadmap. Parsing them now
/// keeps `pnpm-workspace.yaml` files with catalog entries valid input
/// to pacquet rather than tripping `deny_unknown_fields`.
#[derive(Debug, Default, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceManifest {
@@ -61,6 +57,17 @@ pub struct WorkspaceManifest {
/// promote `packages: []` into a recursive scan.
#[serde(default)]
pub packages: Option<Vec<String>>,
/// Top-level shorthand for the default catalog. Mutually exclusive
/// with `catalogs.default` — `pacquet_catalogs_config` enforces
/// that.
#[serde(default)]
pub catalog: Option<Catalog>,
/// Named catalogs. Includes a `default` key when the user opted for
/// the explicit form over the top-level [`Self::catalog`] field.
#[serde(default)]
pub catalogs: Option<Catalogs>,
}
/// Raised when `pnpm-workspace.yaml` parses as YAML but fails an

View File

@@ -2,6 +2,7 @@ use super::{
InvalidWorkspaceManifestError, ReadWorkspaceManifestError, WORKSPACE_MANIFEST_FILENAME,
WorkspaceManifest, read_workspace_manifest,
};
use pacquet_catalogs_types::{Catalog, Catalogs};
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::TempDir;
@@ -65,6 +66,40 @@ fn empty_packages_array_preserved_as_some_empty() {
assert_eq!(manifest.packages, Some(Vec::<String>::new()));
}
#[test]
fn parses_top_level_catalog_field() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(WORKSPACE_MANIFEST_FILENAME),
"catalog:\n foo: ^1.0.0\n bar: ^2.0.0\n",
)
.unwrap();
let manifest = read_workspace_manifest(tmp.path()).unwrap().unwrap();
let mut expected = Catalog::new();
expected.insert("foo".to_string(), "^1.0.0".to_string());
expected.insert("bar".to_string(), "^2.0.0".to_string());
assert_eq!(manifest.catalog, Some(expected));
assert_eq!(manifest.catalogs, None);
}
#[test]
fn parses_named_catalogs_field() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(WORKSPACE_MANIFEST_FILENAME),
"catalogs:\n default:\n foo: ^1.0.0\n legacy:\n bar: ^2.0.0\n",
)
.unwrap();
let manifest = read_workspace_manifest(tmp.path()).unwrap().unwrap();
let mut expected = Catalogs::new();
expected
.insert("default".to_string(), Catalog::from([("foo".to_string(), "^1.0.0".to_string())]));
expected
.insert("legacy".to_string(), Catalog::from([("bar".to_string(), "^2.0.0".to_string())]));
assert_eq!(manifest.catalog, None);
assert_eq!(manifest.catalogs, Some(expected));
}
#[test]
fn empty_package_entry_rejected() {
let tmp = TempDir::new().unwrap();