Skip to content

Commit 7ae96bb

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 7ae96bb

21 files changed

Lines changed: 352 additions & 60 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: 12 additions & 6 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: tokio::sync::OnceCell<Catalogs> = tokio::sync::OnceCell::const_new();
69+
70+
pub async fn get_catalogs() -> Catalogs {
71+
CATALOGS
72+
.get_or_init(|| async {
73+
Config::load(false)
74+
.await
75+
.map(|c| c.catalogs())
76+
.unwrap_or_default()
77+
})
7178
.await
72-
.map(|c| c.catalogs())
73-
.unwrap_or_default()
79+
.clone()
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/graph.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,13 @@ impl DependencyGraph {
306306
}
307307
}
308308

309+
/// Update the spec on a dependency edge (e.g. after resolving `catalog:` protocol).
310+
pub fn update_dependency_spec(&mut self, edge_id: EdgeIndex, spec: String) {
311+
if let Some(GraphEdge::Dependency(dep)) = self.graph.edge_weight_mut(edge_id) {
312+
dep.spec = spec;
313+
}
314+
}
315+
309316
/// Get node by index.
310317
pub fn get_node(&self, index: NodeIndex) -> Option<&PackageNode> {
311318
self.graph.node_weight(index)

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/model/package_lock.rs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -293,19 +293,21 @@ fn create_root_lock_package(graph: &DependencyGraph, node_index: NodeIndex) -> L
293293
let node = graph.get_node(node_index).expect("Node must exist");
294294
let manifest = &node.manifest;
295295

296-
LockPackage {
296+
let mut pkg = LockPackage {
297297
name: Some(node.name.clone()),
298298
version: Some(node.version.clone()),
299299
engines: manifest.engines().cloned(),
300300
workspaces: manifest
301301
.workspaces()
302302
.and_then(|v| serde_json::from_value(v).ok()),
303-
dependencies: manifest.dependencies().cloned(),
304-
dev_dependencies: manifest.dev_dependencies().cloned(),
305-
peer_dependencies: manifest.peer_dependencies().cloned(),
306-
optional_dependencies: manifest.optional_dependencies().cloned(),
307303
..LockPackage::default()
308-
}
304+
};
305+
306+
// Read deps from graph edges (not manifest) so that resolved `catalog:` specs
307+
// are written to the lockfile instead of the raw protocol references.
308+
collect_edge_deps(graph, node_index, &mut pkg);
309+
310+
pkg
309311
}
310312

311313
/// Create LockPackage for non-root nodes.

crates/ruborist/src/resolver/builder.rs

Lines changed: 44 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,38 @@ 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+
// Update the edge spec so the lockfile writes the resolved
436+
// version range instead of the raw `catalog:` reference.
437+
graph.update_dependency_spec(edge_info.edge_id, resolved_spec.to_string());
438+
match resolve_registry_dep(
439+
registry,
440+
&edge_info.name,
441+
resolved_spec,
442+
&edge_info.edge_type,
443+
)
444+
.await?
445+
{
446+
Some(resolved) => resolved,
447+
None => {
448+
tracing::debug!(
449+
"Skipped optional catalog dependency {}@{}",
450+
edge_info.name,
451+
edge_info.spec
452+
);
453+
return Ok(ProcessResult::Skipped);
454+
}
455+
}
456+
}
415457
PackageSpec::Local { .. } => {
416458
return Err(ResolveError::Unsupported {
417459
spec: edge_info.spec.clone(),

0 commit comments

Comments
 (0)