Skip to content

Commit 6920e9e

Browse files
committed
feat(core): support catalog: specifiers
Closes #258
1 parent a39e87c commit 6920e9e

15 files changed

Lines changed: 582 additions & 52 deletions

.cursorrules

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,13 @@ Use grep/rg ONLY for: multiple file types, intentionally searching comments, non
177177
2. Read actual implementation
178178
3. Base advice on verified facts
179179

180+
### Before Writing Tests
181+
182+
1. Read 2-3 existing tests in same file
183+
2. Identify the pattern (TestBuilder methods, assertion style)
184+
3. Match that pattern exactly
185+
4. Never invent APIs - only use what exists
186+
180187
### Scope Boundaries (Off-Limits)
181188

182189
- Don't modify: Context creation, 3-phase pattern

src/catalogs.rs

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
use {
2+
crate::{config::Config, specifier::Specifier},
3+
log::debug,
4+
serde::Deserialize,
5+
std::{collections::HashMap, fs, rc::Rc, time::Instant},
6+
};
7+
8+
pub type Catalog = HashMap<String, Rc<Specifier>>;
9+
pub type CatalogsByName = HashMap<String, Catalog>;
10+
11+
/// Extract catalogs from the project configuration.
12+
///
13+
/// Attempts to read catalog definitions from:
14+
/// 1. pnpm-workspace.yaml (pnpm catalogs)
15+
/// 2. package.json at project root (bun catalogs)
16+
///
17+
/// See:
18+
/// - https://pnpm.io/catalogs
19+
/// - https://bun.sh/docs/pm/catalogs
20+
pub fn from_config(config: &Config) -> Option<CatalogsByName> {
21+
let start = Instant::now();
22+
let catalogs = try_from_pnpm(config).or_else(|| try_from_bun(config));
23+
debug!("Catalog discovery completed in {:?}", start.elapsed());
24+
catalogs
25+
}
26+
27+
/// Try to read catalogs from pnpm-workspace.yaml
28+
///
29+
/// pnpm supports both:
30+
/// - `catalog:` (singular) - default catalog
31+
/// - `catalogs:` (plural) - named catalogs
32+
///
33+
/// Example pnpm-workspace.yaml:
34+
/// ```yaml
35+
/// packages:
36+
/// - 'packages/*'
37+
/// catalog:
38+
/// chalk: ^4.1.2
39+
/// catalogs:
40+
/// react16:
41+
/// react: ^16.7.0
42+
/// react-dom: ^16.7.0
43+
/// ```
44+
fn try_from_pnpm(config: &Config) -> Option<CatalogsByName> {
45+
let file_path = config.cli.cwd.join("pnpm-workspace.yaml");
46+
47+
if !file_path.exists() {
48+
return None;
49+
}
50+
51+
debug!("Reading catalogs from pnpm-workspace.yaml");
52+
53+
let contents = fs::read_to_string(&file_path).ok()?;
54+
let workspace: PnpmWorkspace = serde_yaml::from_str(&contents).ok()?;
55+
56+
let mut catalogs_by_name = CatalogsByName::new();
57+
58+
// Add default catalog if present
59+
if let Some(default_catalog) = workspace.catalog {
60+
let mut catalog = Catalog::new();
61+
for (name, version) in default_catalog {
62+
catalog.insert(name, Specifier::new(&version));
63+
}
64+
if !catalog.is_empty() {
65+
catalogs_by_name.insert("default".to_string(), catalog);
66+
}
67+
}
68+
69+
// Add named catalogs if present
70+
if let Some(named_catalogs) = workspace.catalogs {
71+
for (catalog_name, dependencies) in named_catalogs {
72+
let mut catalog = Catalog::new();
73+
for (name, version) in dependencies {
74+
catalog.insert(name, Specifier::new(&version));
75+
}
76+
if !catalog.is_empty() {
77+
catalogs_by_name.insert(catalog_name, catalog);
78+
}
79+
}
80+
}
81+
82+
if catalogs_by_name.is_empty() {
83+
debug!("No catalogs found in pnpm-workspace.yaml");
84+
None
85+
} else {
86+
debug!("Found {} catalog(s) in pnpm-workspace.yaml", catalogs_by_name.len());
87+
Some(catalogs_by_name)
88+
}
89+
}
90+
91+
/// Try to read catalogs from package.json at project root
92+
///
93+
/// Bun supports catalogs defined in the root package.json:
94+
/// - At top level: `catalog` and `catalogs`
95+
/// - Under workspaces: `workspaces.catalog` and `workspaces.catalogs`
96+
///
97+
/// Example package.json:
98+
/// ```json
99+
/// {
100+
/// "workspaces": {
101+
/// "catalog": {
102+
/// "react": "^19.0.0"
103+
/// },
104+
/// "catalogs": {
105+
/// "testing": {
106+
/// "jest": "30.0.0"
107+
/// }
108+
/// }
109+
/// }
110+
/// }
111+
/// ```
112+
fn try_from_bun(config: &Config) -> Option<CatalogsByName> {
113+
let file_path = config.cli.cwd.join("package.json");
114+
115+
if !file_path.exists() {
116+
return None;
117+
}
118+
119+
debug!("Reading catalogs from package.json");
120+
121+
let contents = fs::read_to_string(&file_path).ok()?;
122+
let package_json: BunPackageJson = serde_json::from_str(&contents).ok()?;
123+
124+
let mut catalogs_by_name = CatalogsByName::new();
125+
126+
// Try workspaces.catalog and workspaces.catalogs first
127+
if let Some(workspaces) = package_json.workspaces {
128+
if let Some(default_catalog) = workspaces.catalog {
129+
let mut catalog = Catalog::new();
130+
for (name, version) in default_catalog {
131+
catalog.insert(name, Specifier::new(&version));
132+
}
133+
if !catalog.is_empty() {
134+
catalogs_by_name.insert("default".to_string(), catalog);
135+
}
136+
}
137+
138+
if let Some(named_catalogs) = workspaces.catalogs {
139+
for (catalog_name, dependencies) in named_catalogs {
140+
let mut catalog = Catalog::new();
141+
for (name, version) in dependencies {
142+
catalog.insert(name, Specifier::new(&version));
143+
}
144+
if !catalog.is_empty() {
145+
catalogs_by_name.insert(catalog_name, catalog);
146+
}
147+
}
148+
}
149+
}
150+
151+
// Fall back to top-level catalog and catalogs
152+
if catalogs_by_name.is_empty() {
153+
if let Some(default_catalog) = package_json.catalog {
154+
let mut catalog = Catalog::new();
155+
for (name, version) in default_catalog {
156+
catalog.insert(name, Specifier::new(&version));
157+
}
158+
if !catalog.is_empty() {
159+
catalogs_by_name.insert("default".to_string(), catalog);
160+
}
161+
}
162+
163+
if let Some(named_catalogs) = package_json.catalogs {
164+
for (catalog_name, dependencies) in named_catalogs {
165+
let mut catalog = Catalog::new();
166+
for (name, version) in dependencies {
167+
catalog.insert(name, Specifier::new(&version));
168+
}
169+
if !catalog.is_empty() {
170+
catalogs_by_name.insert(catalog_name, catalog);
171+
}
172+
}
173+
}
174+
}
175+
176+
if catalogs_by_name.is_empty() {
177+
debug!("No catalogs found in package.json");
178+
None
179+
} else {
180+
debug!("Found {} catalog(s) in package.json", catalogs_by_name.len());
181+
Some(catalogs_by_name)
182+
}
183+
}
184+
185+
#[derive(Debug, Deserialize)]
186+
struct PnpmWorkspace {
187+
catalog: Option<HashMap<String, String>>,
188+
catalogs: Option<HashMap<String, HashMap<String, String>>>,
189+
}
190+
191+
#[derive(Debug, Deserialize)]
192+
struct BunPackageJson {
193+
catalog: Option<HashMap<String, String>>,
194+
catalogs: Option<HashMap<String, HashMap<String, String>>>,
195+
workspaces: Option<BunWorkspaces>,
196+
}
197+
198+
#[derive(Debug, Deserialize)]
199+
struct BunWorkspaces {
200+
catalog: Option<HashMap<String, String>>,
201+
catalogs: Option<HashMap<String, HashMap<String, String>>>,
202+
}

src/context.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use {
22
crate::{
3+
catalogs::CatalogsByName,
34
config::Config,
45
dependency::UpdateUrl,
56
instance::Instance,
@@ -33,6 +34,12 @@ use {
3334
/// See PATTERNS.md "Ownership and Borrowing" section for details.
3435
#[derive(Debug)]
3536
pub struct Context {
37+
/// If present, the contents of each bun or pnpm catalog. The default catalog
38+
/// is keyed under "default" and named by their names.
39+
///
40+
/// - https://pnpm.io/catalogs
41+
/// - https://bun.sh/docs/pm/catalogs
42+
pub catalogs: Option<CatalogsByName>,
3643
/// All default configuration with user config applied
3744
pub config: Config,
3845
/// The internal names of all failed updates
@@ -67,7 +74,12 @@ impl Context {
6774
/// Called from: src/main.rs
6875
/// Next step: visit_packages() in src/visit_packages.rs
6976
/// See also: .cursorrules for critical invariants
70-
pub fn create(config: Config, packages: Packages, registry_client: Option<Arc<dyn RegistryClient>>) -> Self {
77+
pub fn create(
78+
config: Config,
79+
packages: Packages,
80+
registry_client: Option<Arc<dyn RegistryClient>>,
81+
catalogs: Option<CatalogsByName>,
82+
) -> Self {
7183
let mut instances = vec![];
7284
let updates_by_internal_name = HashMap::new();
7385
let all_dependency_types = config.rcfile.get_all_dependency_types();
@@ -107,6 +119,7 @@ impl Context {
107119
});
108120

109121
Self {
122+
catalogs,
110123
config,
111124
failed_updates,
112125
instances,

src/instance_state.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ impl Ord for InstanceState {
206206

207207
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
208208
pub enum ValidInstance {
209+
/// - ✓ Instance uses the catalog: protocol and wins out
210+
IsCatalog,
209211
/// - ✓ Instance is configured to be ignored by Syncpack
210212
IsIgnored,
211213
/// - ✓ Instance is a local package and its version is valid
@@ -258,6 +260,11 @@ pub enum InvalidInstance {
258260
pub enum FixableInstance {
259261
/// - ✘ Instance is in a banned version group
260262
IsBanned,
263+
/// - ✓ Instance is in a highest/lowest semver group
264+
/// - ✓ One or more other instances use the catalog: protocol
265+
/// - ✘ Instance does not use the catalog: protocol
266+
/// - ! catalog: protocol wins
267+
DiffersToCatalog,
261268
/// - ✘ Instance mismatches the version of its locally-developed package
262269
DiffersToLocal,
263270
/// - ✘ Instance mismatches highest/lowest semver in its group

src/instance_test.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ fn returns_correct_registry_update_url() {
2222
"lit": "npm:lit@3.2.1",
2323
}
2424
})]);
25+
26+
let catalogs = None;
2527
let registry_client = None;
26-
let ctx = Context::create(config, packages, registry_client);
28+
let ctx = Context::create(config, packages, registry_client, catalogs);
2729

2830
let get_update_url_by_name = |name: &str| {
2931
ctx

src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use {
1919
#[path = "test/test.rs"]
2020
mod test;
2121

22+
mod catalogs;
2223
mod cli;
2324
mod commands;
2425
mod config;
@@ -60,6 +61,7 @@ async fn main() {
6061
debug!("{:#?}", config.rcfile);
6162

6263
let packages = Packages::from_config(&config);
64+
let catalogs = None; // catalogs::from_config(&config);
6365

6466
match packages.all.len() {
6567
0 => {
@@ -81,6 +83,7 @@ async fn main() {
8183
} else {
8284
None
8385
},
86+
catalogs,
8487
);
8588

8689
// PHASE 2 & 3: Inspect and Run

0 commit comments

Comments
 (0)