diff --git a/Cargo.lock b/Cargo.lock index 84998d22ca..76795c8cfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 8257b6ca98..12bdc5d1eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/pacquet/crates/catalogs-config/Cargo.toml b/pacquet/crates/catalogs-config/Cargo.toml new file mode 100644 index 0000000000..e38e971507 --- /dev/null +++ b/pacquet/crates/catalogs-config/Cargo.toml @@ -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 diff --git a/pacquet/crates/catalogs-config/src/lib.rs b/pacquet/crates/catalogs-config/src/lib.rs new file mode 100644 index 0000000000..2c562bfddf --- /dev/null +++ b/pacquet/crates/catalogs-config/src/lib.rs @@ -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 { + 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; diff --git a/pacquet/crates/catalogs-config/src/tests.rs b/pacquet/crates/catalogs-config/src/tests.rs new file mode 100644 index 0000000000..958c1e8d84 --- /dev/null +++ b/pacquet/crates/catalogs-config/src/tests.rs @@ -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()); +} diff --git a/pacquet/crates/catalogs-protocol-parser/Cargo.toml b/pacquet/crates/catalogs-protocol-parser/Cargo.toml new file mode 100644 index 0000000000..a519fdb248 --- /dev/null +++ b/pacquet/crates/catalogs-protocol-parser/Cargo.toml @@ -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 diff --git a/pacquet/crates/catalogs-protocol-parser/src/lib.rs b/pacquet/crates/catalogs-protocol-parser/src/lib.rs new file mode 100644 index 0000000000..84ccc690ba --- /dev/null +++ b/pacquet/crates/catalogs-protocol-parser/src/lib.rs @@ -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; diff --git a/pacquet/crates/catalogs-protocol-parser/src/tests.rs b/pacquet/crates/catalogs-protocol-parser/src/tests.rs new file mode 100644 index 0000000000..2121f25912 --- /dev/null +++ b/pacquet/crates/catalogs-protocol-parser/src/tests.rs @@ -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")); +} diff --git a/pacquet/crates/catalogs-resolver/Cargo.toml b/pacquet/crates/catalogs-resolver/Cargo.toml new file mode 100644 index 0000000000..b7466c473d --- /dev/null +++ b/pacquet/crates/catalogs-resolver/Cargo.toml @@ -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 diff --git a/pacquet/crates/catalogs-resolver/src/lib.rs b/pacquet/crates/catalogs-resolver/src/lib.rs new file mode 100644 index 0000000000..e34e5462d2 --- /dev/null +++ b/pacquet/crates/catalogs-resolver/src/lib.rs @@ -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; diff --git a/pacquet/crates/catalogs-resolver/src/tests.rs b/pacquet/crates/catalogs-resolver/src/tests.rs new file mode 100644 index 0000000000..f480f9c681 --- /dev/null +++ b/pacquet/crates/catalogs-resolver/src/tests.rs @@ -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.", + ); +} diff --git a/pacquet/crates/catalogs-types/Cargo.toml b/pacquet/crates/catalogs-types/Cargo.toml new file mode 100644 index 0000000000..e15f1a229f --- /dev/null +++ b/pacquet/crates/catalogs-types/Cargo.toml @@ -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 diff --git a/pacquet/crates/catalogs-types/src/lib.rs b/pacquet/crates/catalogs-types/src/lib.rs new file mode 100644 index 0000000000..95216d7772 --- /dev/null +++ b/pacquet/crates/catalogs-types/src/lib.rs @@ -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; + +/// 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; diff --git a/pacquet/crates/cli/tests/install.rs b/pacquet/crates/cli/tests/install.rs index 1d77c457fd..39680bfeec 100644 --- a/pacquet/crates/cli/tests/install.rs +++ b/pacquet/crates/cli/tests/install.rs @@ -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 +} diff --git a/pacquet/crates/package-manager/Cargo.toml b/pacquet/crates/package-manager/Cargo.toml index 3cdf883a43..5d33982af4 100644 --- a/pacquet/crates/package-manager/Cargo.toml +++ b/pacquet/crates/package-manager/Cargo.toml @@ -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 } diff --git a/pacquet/crates/package-manager/src/install.rs b/pacquet/crates/package-manager/src/install.rs index 12e93da206..2a24529b1a 100644 --- a/pacquet/crates/package-manager/src/install.rs +++ b/pacquet/crates/package-manager/src/install.rs @@ -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 + // . + 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::() .await diff --git a/pacquet/crates/package-manager/src/install_without_lockfile.rs b/pacquet/crates/package-manager/src/install_without_lockfile.rs index 4cc69eb782..412609ae86 100644 --- a/pacquet/crates/package-manager/src/install_without_lockfile.rs +++ b/pacquet/crates/package-manager/src/install_without_lockfile.rs @@ -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 = diff --git a/pacquet/crates/resolving-deps-resolver/Cargo.toml b/pacquet/crates/resolving-deps-resolver/Cargo.toml index dfbd140f43..2a5362a013 100644 --- a/pacquet/crates/resolving-deps-resolver/Cargo.toml +++ b/pacquet/crates/resolving-deps-resolver/Cargo.toml @@ -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 } diff --git a/pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs b/pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs index ee74ffd32a..8b8a2b4d58 100644 --- a/pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs +++ b/pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs @@ -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, 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`. diff --git a/pacquet/crates/resolving-deps-resolver/src/resolve_importer.rs b/pacquet/crates/resolving-deps-resolver/src/resolve_importer.rs index 5f5dc016eb..80963c5b61 100644 --- a/pacquet/crates/resolving-deps-resolver/src/resolve_importer.rs +++ b/pacquet/crates/resolving-deps-resolver/src/resolve_importer.rs @@ -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; diff --git a/pacquet/crates/resolving-deps-resolver/src/resolve_importer/tests.rs b/pacquet/crates/resolving-deps-resolver/src/resolve_importer/tests.rs index 38c2a72bcb..7178750b6a 100644 --- a/pacquet/crates/resolving-deps-resolver/src/resolve_importer/tests.rs +++ b/pacquet/crates/resolving-deps-resolver/src/resolve_importer/tests.rs @@ -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:?}"), + } +} diff --git a/pacquet/crates/workspace/Cargo.toml b/pacquet/crates/workspace/Cargo.toml index ff5aab8461..7f93efef4a 100644 --- a/pacquet/crates/workspace/Cargo.toml +++ b/pacquet/crates/workspace/Cargo.toml @@ -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 } diff --git a/pacquet/crates/workspace/src/manifest.rs b/pacquet/crates/workspace/src/manifest.rs index c86fc29fd2..51a258d5a6 100644 --- a/pacquet/crates/workspace/src/manifest.rs +++ b/pacquet/crates/workspace/src/manifest.rs @@ -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>, + + /// Top-level shorthand for the default catalog. Mutually exclusive + /// with `catalogs.default` — `pacquet_catalogs_config` enforces + /// that. + #[serde(default)] + pub catalog: Option, + + /// 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, } /// Raised when `pnpm-workspace.yaml` parses as YAML but fails an diff --git a/pacquet/crates/workspace/src/manifest/tests.rs b/pacquet/crates/workspace/src/manifest/tests.rs index b31bab5ba9..5729e9d516 100644 --- a/pacquet/crates/workspace/src/manifest/tests.rs +++ b/pacquet/crates/workspace/src/manifest/tests.rs @@ -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::::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();