Skip to content

Commit 7fe3a83

Browse files
elrrrrrrrclaude
andcommitted
feat(pm): implement catalog protocol for dependency resolution
Add support for the `catalog:` dependency protocol, which allows defining shared version specs in `.utoo.toml` and referencing them from package.json via `catalog:` or `catalog:<name>`. Key changes: - Parse `catalog:` as `PackageSpec::Local { protocol: Catalog }` in spec parser - Resolve catalog specs inline in `process_dependency` (same pattern as workspace/git) - Add `Catalogs` type and `resolve_catalog_spec` to ruborist spec module - Normalize default catalog keys ("" / "default") at config build time - Add catalog-aware lock freshness check in `deps_match` closure - Add `catalogs` field to `BuildDepsConfig` and `BuildDepsOptions` - Add e2e test fixture and test cases for catalog resolution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7cf1298 commit 7fe3a83

20 files changed

Lines changed: 336 additions & 54 deletions

File tree

crates/pm/src/helper/lock.rs

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use serde_json::Value;
66
use utoo_ruborist::lock::{LockPackage, PackageLock};
77
use utoo_ruborist::manifest::{DepsView, EnginesView, PackageJson};
88
use utoo_ruborist::registry::resolve_package;
9-
use utoo_ruborist::spec::{PackageSpec, Protocol};
9+
use utoo_ruborist::spec::{PackageSpec, Protocol, resolve_catalog_spec};
1010
use utoo_ruborist::util::PackageNameStr;
1111

1212
use super::ruborist_context::Context;
@@ -19,7 +19,7 @@ use crate::util::git_resolver::{resolve_git_spec, resolve_github_spec};
1919
use crate::util::json::{load_package_json, load_package_lock_json_from_path, read_json_file};
2020
use crate::util::logger::{finish_progress_bar, start_progress_bar};
2121
use crate::util::save_type::{PackageAction, SaveType};
22-
use crate::util::user_config::{get_legacy_peer_deps, set_package_json};
22+
use crate::util::user_config::{get_catalogs, get_legacy_peer_deps, set_package_json};
2323

2424
// Platform-specific line endings
2525
#[cfg(target_os = "windows")]
@@ -53,18 +53,6 @@ pub fn extract_package_name(path: &str) -> String {
5353
}
5454
}
5555

56-
/// Compare a PackageJson dependency map with a lock file dependency map.
57-
/// Treats empty maps and None/empty objects as equal.
58-
fn deps_map_equals_lock(
59-
pkg_deps: &HashMap<String, String>,
60-
lock_deps: Option<&HashMap<String, String>>,
61-
) -> bool {
62-
match lock_deps {
63-
Some(ld) => *pkg_deps == *ld,
64-
None => pkg_deps.is_empty(),
65-
}
66-
}
67-
6856
pub async fn ensure_package_lock(root_path: &Path) -> Result<PackageLock> {
6957
// Check package.json exists in project directory
7058
if fs::metadata(root_path.join("package.json")).await.is_err() {
@@ -362,6 +350,22 @@ pub async fn is_pkg_lock_outdated(root_path: &Path) -> Result<bool> {
362350
let pkg: DepsView = load_package_json(root_path).await?;
363351
let lock_file: PackageLock = read_json_file(&root_path.join("package-lock.json")).await?;
364352

353+
let catalogs = get_catalogs().await;
354+
let deps_match = |pkg_deps: &HashMap<String, String>,
355+
lock_deps: Option<&HashMap<String, String>>|
356+
-> bool {
357+
match lock_deps {
358+
None => pkg_deps.is_empty(),
359+
Some(ld) => {
360+
pkg_deps.len() == ld.len()
361+
&& pkg_deps.iter().all(|(name, spec)| {
362+
let resolved = resolve_catalog_spec(name, spec, &catalogs).unwrap_or(spec);
363+
ld.get(name).is_some_and(|v| v == resolved)
364+
})
365+
}
366+
}
367+
};
368+
365369
let packages = &lock_file.packages;
366370

367371
// prepare packages to check: (relative_path, deps)
@@ -394,26 +398,25 @@ pub async fn is_pkg_lock_outdated(root_path: &Path) -> Result<bool> {
394398
let name = if path.is_empty() { "root" } else { path };
395399

396400
// check dependencies whether changed
397-
if !deps_map_equals_lock(&pkg.dependencies, lock.dependencies.as_ref()) {
401+
if !deps_match(&pkg.dependencies, lock.dependencies.as_ref()) {
398402
tracing::warn!("package-lock.json is outdated, {name} dependencies changed");
399403
return Ok(true);
400404
}
401405

402-
if !deps_map_equals_lock(
406+
if !deps_match(
403407
&pkg.optional_dependencies,
404408
lock.optional_dependencies.as_ref(),
405409
) {
406410
tracing::warn!("package-lock.json is outdated, {name} optionalDependencies changed");
407411
return Ok(true);
408412
}
409413

410-
if !deps_map_equals_lock(&pkg.dev_dependencies, lock.dev_dependencies.as_ref()) {
414+
if !deps_match(&pkg.dev_dependencies, lock.dev_dependencies.as_ref()) {
411415
tracing::warn!("package-lock.json is outdated, {name} devDependencies changed");
412416
return Ok(true);
413417
}
414418

415-
if !legacy_peer_deps
416-
&& !deps_map_equals_lock(&pkg.peer_dependencies, lock.peer_dependencies.as_ref())
419+
if !legacy_peer_deps && !deps_match(&pkg.peer_dependencies, lock.peer_dependencies.as_ref())
417420
{
418421
tracing::warn!("package-lock.json is outdated, {name} peerDependencies changed");
419422
return Ok(true);

crates/pm/src/helper/ruborist_context.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ use crate::service::pipeline::{PipelineChannels, PipelineReceiver};
77
use crate::util::cache::get_cache_dir;
88
use crate::util::logger::ProgressReceiver;
99
use crate::util::user_config::{
10-
get_legacy_peer_deps, get_manifests_concurrency_limit, get_registry, get_supports_semver,
10+
get_catalogs, get_legacy_peer_deps, get_manifests_concurrency_limit, get_registry,
11+
get_supports_semver,
1112
};
1213

1314
/// Tokio-based glob implementation.
@@ -40,6 +41,7 @@ impl Context {
4041
cwd: PathBuf,
4142
receiver: R,
4243
) -> BuildDepsOptions<GlobImpl, R> {
44+
let catalogs = get_catalogs().await;
4345
BuildDepsOptions {
4446
cwd,
4547
registry_url: get_registry(),
@@ -49,6 +51,7 @@ impl Context {
4951
glob: TokioGlob,
5052
receiver,
5153
supports_semver: get_supports_semver(),
54+
catalogs,
5255
}
5356
}
5457

crates/pm/src/util/config_file.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::fmt::Debug;
55
use std::fs;
66
use std::path::{Path, PathBuf};
77
use std::sync::OnceLock;
8+
use utoo_ruborist::spec::Catalogs;
89

910
pub type ConfigResult<T> = Result<T>;
1011

@@ -84,12 +85,18 @@ impl Config {
8485
}
8586

8687
/// Build a `Catalogs` map from the parsed `[catalog]` and `[catalogs.*]` sections.
87-
#[allow(dead_code)] // used by catalog protocol (upcoming)
88-
pub fn catalogs(&self) -> HashMap<String, HashMap<String, String>> {
88+
///
89+
/// The default catalog (`[catalog]`) is stored under both `""` and `"default"`
90+
/// so that `catalog:` and `catalog:default` both resolve without extra normalization.
91+
pub fn catalogs(&self) -> Catalogs {
8992
let mut result = self.catalogs.clone();
9093
if !self.catalog.is_empty() {
9194
result.insert(String::new(), self.catalog.clone());
9295
}
96+
// Duplicate default catalog under "default" key for direct lookup
97+
if let Some(default) = result.get("").cloned() {
98+
result.entry("default".to_string()).or_insert(default);
99+
}
93100
result
94101
}
95102

crates/pm/src/util/user_config.rs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::sync::{LazyLock, OnceLock};
55
use anyhow::Result;
66
use dashmap::DashMap;
77
use utoo_ruborist::manifest::PackageJson;
8+
use utoo_ruborist::spec::Catalogs;
89

910
use super::config_file::{Config, ConfigValue};
1011
use super::http::client_builder;
@@ -64,13 +65,18 @@ pub fn get_registry() -> String {
6465
REGISTRY.get_sync()
6566
}
6667

67-
#[allow(dead_code)] // used by catalog protocol (upcoming)
68-
pub async fn get_catalogs()
69-
-> std::collections::HashMap<String, std::collections::HashMap<String, String>> {
70-
Config::load(false)
68+
static CATALOGS: OnceLock<Catalogs> = OnceLock::new();
69+
70+
pub async fn get_catalogs() -> Catalogs {
71+
if let Some(cached) = CATALOGS.get() {
72+
return cached.clone();
73+
}
74+
let catalogs = Config::load(false)
7175
.await
7276
.map(|c| c.catalogs())
73-
.unwrap_or_default()
77+
.unwrap_or_default();
78+
let _ = CATALOGS.set(catalogs.clone());
79+
catalogs
7480
}
7581

7682
pub fn set_legacy_peer_deps(value: Option<bool>) {

crates/ruborist/src/lib.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
pub mod model;
2626
pub mod resolver;
2727
pub mod service;
28+
pub mod spec;
2829
pub mod traits;
2930

3031
// ============================================================================
@@ -79,9 +80,9 @@ pub mod compat {
7980
pub use crate::model::compatibility::{is_cpu_compatible, is_os_compatible};
8081
}
8182

82-
/// Package specification types.
83-
pub mod spec {
84-
pub use crate::model::spec::{PackageSpec, Protocol, SpecStr};
83+
/// Node types for the dependency graph.
84+
pub mod node {
85+
pub use crate::model::node::NodeType;
8586
}
8687

8788
/// Git clone and resolution helpers.

crates/ruborist/src/model/mod.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,6 @@ pub mod node;
125125
pub mod override_rule;
126126
pub mod package_json;
127127
pub mod package_lock;
128-
pub mod spec;
129128
pub mod tarball_info;
130129
pub(crate) mod util;
131130

crates/ruborist/src/model/node.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ pub enum EdgeType {
1414
}
1515

1616
/// Node type representing the kind of package in the graph.
17-
#[derive(Debug, Clone, PartialEq, Eq)]
17+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1818
pub enum NodeType {
1919
/// Root project package
2020
Root,

crates/ruborist/src/resolver/builder.rs

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ use crate::model::graph::{DependencyGraph, FindResult, PackageNode};
2727
use crate::model::manifest::NodeManifest;
2828
use crate::model::node::EdgeType;
2929
use crate::model::package_json::PackageJson;
30-
use crate::model::spec::{PackageSpec, Protocol};
3130
use crate::resolver::preload::{PreloadConfig, preload_manifests};
3231
use crate::resolver::registry::{ResolveError, resolve_registry_dep};
32+
use crate::spec::{Catalogs, PackageSpec, Protocol, resolve_catalog_spec};
3333
use crate::traits::progress::{BuildEvent, EventReceiver, NoopReceiver};
3434
use crate::traits::registry::{RegistryClient, ResolvedPackage};
3535

@@ -83,6 +83,9 @@ pub struct BuildDepsConfig {
8383
pub cache_dir: Option<PathBuf>,
8484
/// Shared dedup cache for concurrent git clone operations
8585
pub git_clone_cache: Arc<GitCloneCache>,
86+
/// Catalog definitions for the `catalog:` dependency protocol.
87+
/// Key `""` = default catalog, other keys = named catalogs.
88+
pub catalogs: Catalogs,
8689
}
8790

8891
impl Default for BuildDepsConfig {
@@ -93,6 +96,7 @@ impl Default for BuildDepsConfig {
9396
skip_preload: false,
9497
cache_dir: dirs::home_dir().map(|d| d.join(".cache/nm")),
9598
git_clone_cache: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
99+
catalogs: HashMap::new(),
96100
}
97101
}
98102
}
@@ -121,6 +125,12 @@ impl BuildDepsConfig {
121125
self.cache_dir = Some(cache_dir);
122126
self
123127
}
128+
129+
/// Set catalog definitions for `catalog:` protocol resolution.
130+
pub fn with_catalogs(mut self, catalogs: Catalogs) -> Self {
131+
self.catalogs = catalogs;
132+
self
133+
}
124134
}
125135

126136
/// Snapshot of node dependency flags to avoid borrowing conflicts.
@@ -135,7 +145,7 @@ struct NodeFlags {
135145

136146
/// Gather all unresolved deps from root and workspace nodes for preloading.
137147
fn gather_preload_deps(graph: &DependencyGraph, legacy_peer_deps: bool) -> Vec<(String, String)> {
138-
use crate::model::spec::SpecStr;
148+
use crate::spec::SpecStr;
139149
use std::collections::HashSet;
140150

141151
let mut deps = HashSet::new();
@@ -412,6 +422,35 @@ pub async fn process_dependency<R: RegistryClient>(
412422
);
413423
return Ok(ProcessResult::Skipped);
414424
}
425+
PackageSpec::Local {
426+
protocol: Protocol::Catalog,
427+
..
428+
} => {
429+
let resolved_spec =
430+
resolve_catalog_spec(&edge_info.name, &edge_info.spec, &config.catalogs)
431+
.ok_or_else(|| ResolveError::Unsupported {
432+
spec: edge_info.spec.clone(),
433+
reason: "catalog entry not found",
434+
})?;
435+
match resolve_registry_dep(
436+
registry,
437+
&edge_info.name,
438+
resolved_spec,
439+
&edge_info.edge_type,
440+
)
441+
.await?
442+
{
443+
Some(resolved) => resolved,
444+
None => {
445+
tracing::debug!(
446+
"Skipped optional catalog dependency {}@{}",
447+
edge_info.name,
448+
edge_info.spec
449+
);
450+
return Ok(ProcessResult::Skipped);
451+
}
452+
}
453+
}
415454
PackageSpec::Local { .. } => {
416455
return Err(ResolveError::Unsupported {
417456
spec: edge_info.spec.clone(),

crates/ruborist/src/resolver/edges.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ impl DependencySource for CoreVersionManifest {
9999
}
100100

101101
/// Add dependency edges from any source that implements `DependencySource`.
102+
///
103+
/// Specs are added to the graph as-is. Protocol-prefixed specs like `catalog:`
104+
/// are resolved later in `process_dependency`.
102105
pub fn add_edges_from<S: DependencySource>(
103106
graph: &mut DependencyGraph,
104107
node_index: NodeIndex,

crates/ruborist/src/resolver/git.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use anyhow::{Context, Result, anyhow};
2727

2828
use crate::model::git::GitCloneResult;
2929
use crate::model::manifest::{CoreVersionManifest, Dist};
30-
use crate::model::spec::PackageSpec;
30+
use crate::spec::PackageSpec;
3131
use crate::traits::registry::ResolvedPackage;
3232

3333
// ============================================================================

0 commit comments

Comments
 (0)