Skip to content

Commit 394559b

Browse files
elrrrrrrrclaude
andauthored
refactor(pm): add catalog config support and rename init_project_root (#2673)
- Add `[catalog]` and `[catalogs.*]` sections to Config struct via serde - Add `Config::catalogs()` method to build merged catalogs map - Add `get_catalogs()` accessor in user_config (used by upcoming catalog protocol) - Rename `update_cwd_to_root` to `init_project_root` for clarity - Add unit tests for catalog parsing (default, named, empty, coexistence) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e95f620 commit 394559b

6 files changed

Lines changed: 137 additions & 33 deletions

File tree

crates/pm/src/cmd/update.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
use crate::service::update::clean_package_lock;
2-
use crate::{cmd::install::install, helper::workspace::update_cwd_to_root};
2+
use crate::{cmd::install::install, helper::workspace::init_project_root};
33
use anyhow::{Context, Result};
44

55
pub async fn update(ignore_scripts: bool) -> Result<()> {
66
let cwd = std::env::current_dir().context("Failed to get current directory")?;
7-
let root_path = update_cwd_to_root(&cwd).await?;
7+
let root_path = init_project_root(&cwd).await?;
88

99
// Clean package-lock.json
1010
tracing::debug!("Cleaning package-lock.json...");

crates/pm/src/helper/workspace.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@ pub async fn find_project_path(cwd: &Path) -> Result<PathBuf> {
6666
.await
6767
}
6868

69-
/// Update current working directory to project root (with workspaces).
70-
pub async fn update_cwd_to_root(cwd: &Path) -> Result<PathBuf> {
69+
/// Resolve the workspace root and change into it.
70+
///
71+
/// This is the standard entry point for commands that operate on the
72+
/// project root (install, update, deps, etc.).
73+
pub async fn init_project_root(cwd: &Path) -> Result<PathBuf> {
7174
let root_dir = find_root_path(cwd).await?;
7275
if !compare_paths(cwd, &root_dir) {
7376
tracing::debug!(
@@ -76,6 +79,7 @@ pub async fn update_cwd_to_root(cwd: &Path) -> Result<PathBuf> {
7679
);
7780
env::set_current_dir(&root_dir).context("Failed to change to root directory")?;
7881
}
82+
7983
Ok(root_dir)
8084
}
8185

@@ -178,9 +182,9 @@ mod tests {
178182
}
179183

180184
#[tokio::test]
181-
async fn test_update_cwd_to_root_in_root() {
185+
async fn test_init_project_root_in_root() {
182186
let (_temp_dir, root_path) = setup_test_workspace().await;
183-
update_cwd_to_root(&root_path).await.unwrap();
187+
init_project_root(&root_path).await.unwrap();
184188
let result = update_cwd_to_project(&root_path).await.unwrap();
185189
assert!(compare_paths(&result, &root_path));
186190
}

crates/pm/src/main.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ use crate::constants::cmd::{
4242
WHOAMI_NAME,
4343
};
4444
use crate::constants::{APP_ABOUT, APP_NAME, APP_VERSION};
45-
use crate::helper::workspace::update_cwd_to_root;
45+
use crate::helper::workspace::init_project_root;
4646

4747
fn detect_shell_from_env() -> Option<clap_complete::Shell> {
4848
// Most common on Unix-like systems.
@@ -481,7 +481,7 @@ async fn async_main() -> Result<()> {
481481
}
482482
} else {
483483
let cwd = std::env::current_dir()?;
484-
let root_path = update_cwd_to_root(&cwd).await?;
484+
let root_path = init_project_root(&cwd).await?;
485485
install(ignore_scripts, &root_path).await?;
486486
log_time_end("All packages installed");
487487
}
@@ -519,7 +519,7 @@ async fn async_main() -> Result<()> {
519519
}
520520
Some(Commands::Deps { workspace_only }) => {
521521
let cwd = std::env::current_dir()?;
522-
let root_path = update_cwd_to_root(&cwd).await?;
522+
let root_path = init_project_root(&cwd).await?;
523523
if workspace_only {
524524
build_workspace(&root_path).await.map(|_| ())?
525525
} else {
@@ -640,7 +640,7 @@ async fn async_main() -> Result<()> {
640640
} else {
641641
// Default to install if no arguments
642642
let cwd = std::env::current_dir()?;
643-
let root_path = update_cwd_to_root(&cwd).await?;
643+
let root_path = init_project_root(&cwd).await?;
644644
install(cli.ignore_scripts, &root_path).await?;
645645
log_time_end("All packages installed");
646646
}

crates/pm/src/service/install.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use crate::helper::lock::{
1010
Package, UpdatePackageJsonOptions, extract_package_name, group_by_depth,
1111
prepare_global_package_json, update_package_json,
1212
};
13-
use crate::helper::workspace::update_cwd_to_root;
13+
use crate::helper::workspace::init_project_root;
1414
use crate::model::package::PackageInfo;
1515
use crate::service::rebuild::RebuildService;
1616
use crate::util::json::load_package_lock_json_from_path;
@@ -190,7 +190,7 @@ impl InstallService {
190190
let cwd = std::env::current_dir().context("Failed to get current directory")?;
191191

192192
// Update working directory to project root (if in workspace)
193-
let root_path = update_cwd_to_root(&cwd).await?;
193+
let root_path = init_project_root(&cwd).await?;
194194

195195
// Update package.json and package-lock.json for all packages in batch
196196
update_package_json(&UpdatePackageJsonOptions {

crates/pm/src/util/config_file.rs

Lines changed: 112 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,21 @@ use std::sync::OnceLock;
88

99
pub type ConfigResult<T> = Result<T>;
1010

11-
#[derive(Debug, Serialize, Deserialize, Default)]
11+
/// Cached merged config (global + local). Set on first `Config::load(false)`.
12+
static MERGED_CONFIG: OnceLock<Config> = OnceLock::new();
13+
14+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1215
pub struct Config {
16+
#[serde(default)]
1317
values: HashMap<String, String>,
1418
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
1519
arrays: HashMap<String, Vec<String>>,
20+
/// Default catalog: `[catalog]` section.
21+
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
22+
catalog: HashMap<String, String>,
23+
/// Named catalogs: `[catalogs.<name>]` sections.
24+
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
25+
catalogs: HashMap<String, HashMap<String, String>>,
1626
}
1727

1828
// global config path is ~/.utoo/config.toml
@@ -23,13 +33,30 @@ impl Config {
2333
return Self::load_from_path(&Self::global_config_path()?).await;
2434
}
2535

36+
// Return cached merged config if available
37+
if let Some(config) = MERGED_CONFIG.get() {
38+
return Ok(config.clone());
39+
}
40+
2641
let mut config = Self::load_from_path(&Self::global_config_path()?).await?;
27-
let local_path = Self::local_config_path()?;
28-
if crate::fs::try_exists(&local_path).await? {
29-
let local_config = Self::load_from_path(&local_path).await?;
30-
config.values.extend(local_config.values);
31-
config.arrays.extend(local_config.arrays);
42+
43+
let local_config = {
44+
let local_path = Self::local_config_path()?;
45+
if crate::fs::try_exists(&local_path).await? {
46+
Some(Self::load_from_path(&local_path).await?)
47+
} else {
48+
None
49+
}
50+
};
51+
if let Some(local) = local_config {
52+
config.values.extend(local.values);
53+
config.arrays.extend(local.arrays);
54+
// Catalogs are project-local only; take them from the local config
55+
config.catalog = local.catalog;
56+
config.catalogs = local.catalogs;
3257
}
58+
59+
let _ = MERGED_CONFIG.set(config.clone());
3360
Ok(config)
3461
}
3562

@@ -56,14 +83,22 @@ impl Config {
5683
self.save(global)
5784
}
5885

59-
pub(crate) async fn load_from_path(path: &Path) -> ConfigResult<Self> {
60-
if !crate::fs::try_exists(path).await? {
61-
return Ok(Config::default());
86+
/// 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>> {
89+
let mut result = self.catalogs.clone();
90+
if !self.catalog.is_empty() {
91+
result.insert(String::new(), self.catalog.clone());
6292
}
93+
result
94+
}
6395

64-
let content = fs::read_to_string(path)?;
65-
let config = toml::from_str(&content)?;
66-
Ok(config)
96+
pub(crate) async fn load_from_path(path: &Path) -> ConfigResult<Self> {
97+
match crate::fs::read_to_string(path).await {
98+
Ok(content) => Ok(toml::from_str(&content)?),
99+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
100+
Err(e) => Err(e.into()),
101+
}
67102
}
68103

69104
pub fn save(&self, global: bool) -> ConfigResult<()> {
@@ -147,15 +182,18 @@ impl<T: Clone + Debug + 'static> ConfigValue<T> {
147182
return value.clone();
148183
}
149184

150-
// load from config
151-
let config_result = Config::load(false).await;
152-
if let Ok(config) = config_result {
153-
let value_result = config.get(self.key);
154-
if let Ok(Some(value)) = value_result {
155-
let parsed_value = self.parse_config_value(&value);
156-
let _ = self.value.set(parsed_value.clone());
157-
return parsed_value;
158-
}
185+
// Ensure merged config is loaded and cached
186+
if MERGED_CONFIG.get().is_none() {
187+
let _ = Config::load(false).await; // populates MERGED_CONFIG
188+
}
189+
190+
// Read from cached merged config (no clone of Config itself)
191+
if let Some(config) = MERGED_CONFIG.get()
192+
&& let Ok(Some(value)) = config.get(self.key)
193+
{
194+
let parsed_value = self.parse_config_value(&value);
195+
let _ = self.value.set(parsed_value.clone());
196+
return parsed_value;
159197
}
160198

161199
self.default.clone()
@@ -254,4 +292,57 @@ mod tests {
254292
});
255293
});
256294
}
295+
296+
#[test]
297+
fn test_catalogs_default_and_named() {
298+
let config: Config = toml::from_str(
299+
r#"
300+
[catalog]
301+
lodash = "^4.17.21"
302+
react = "^18.0.0"
303+
304+
[catalogs.legacy]
305+
path-to-regexp = "^1.9.0"
306+
"#,
307+
)
308+
.unwrap();
309+
310+
let catalogs = config.catalogs();
311+
let default = catalogs.get("").unwrap();
312+
assert_eq!(default.get("lodash"), Some(&"^4.17.21".to_string()));
313+
assert_eq!(default.get("react"), Some(&"^18.0.0".to_string()));
314+
315+
let legacy = catalogs.get("legacy").unwrap();
316+
assert_eq!(legacy.get("path-to-regexp"), Some(&"^1.9.0".to_string()));
317+
}
318+
319+
#[test]
320+
fn test_catalogs_empty() {
321+
let config: Config = toml::from_str("").unwrap();
322+
assert!(config.catalogs().is_empty());
323+
}
324+
325+
#[test]
326+
fn test_catalogs_coexists_with_config_values() {
327+
let config: Config = toml::from_str(
328+
r#"
329+
[values]
330+
registry = "https://registry.npmmirror.com"
331+
332+
[catalog]
333+
lodash = "^4.17.21"
334+
"#,
335+
)
336+
.unwrap();
337+
338+
assert_eq!(
339+
config.get("registry").unwrap(),
340+
Some("https://registry.npmmirror.com".to_string())
341+
);
342+
let catalogs = config.catalogs();
343+
assert_eq!(
344+
catalogs.get("").unwrap().get("lodash"),
345+
Some(&"^4.17.21".to_string())
346+
);
347+
}
257348
}

crates/pm/src/util/user_config.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,15 @@ pub fn get_registry() -> String {
6464
REGISTRY.get_sync()
6565
}
6666

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)
71+
.await
72+
.map(|c| c.catalogs())
73+
.unwrap_or_default()
74+
}
75+
6776
pub fn set_legacy_peer_deps(value: Option<bool>) {
6877
LEGACY_PEER_DEPS.set(value);
6978
}

0 commit comments

Comments
 (0)