Skip to main content

dnx_core/
catalog.rs

1use crate::config::DnxConfig;
2use crate::errors::{DnxError, Result};
3use serde::Deserialize;
4use std::collections::HashMap;
5use std::path::Path;
6
7/// Catalog configuration loaded from `dnx.toml` `[catalog]`/`[catalogs.*]`
8/// sections, or falling back to `dnx.catalog.toml`.
9///
10/// Allows centralised version management across workspace members.
11/// Members reference versions via `"catalog:"` (default group) or
12/// `"catalog:<group>"` (named group).
13#[derive(Debug, Clone, Deserialize)]
14pub struct CatalogConfig {
15    /// Default catalog entries: package name → version range.
16    #[serde(default)]
17    pub default: HashMap<String, String>,
18    /// Named catalog groups: group name → { package name → version range }.
19    #[serde(default)]
20    pub named: HashMap<String, HashMap<String, String>>,
21}
22
23impl CatalogConfig {
24    /// Load catalog config, checking multiple sources in order:
25    /// 1. `dnx.toml` `[catalog]` and `[catalogs.*]` sections (via DnxConfig)
26    /// 2. `dnx.catalog.toml` (legacy)
27    ///
28    /// Returns `Ok(None)` if no catalog configuration is found.
29    pub fn load(root: &Path) -> Result<Option<Self>> {
30        // First, try loading from dnx.toml via DnxConfig
31        let config = DnxConfig::load(root);
32        if !config.catalog_default.is_empty() || !config.catalog_named.is_empty() {
33            return Ok(Some(CatalogConfig {
34                default: config.catalog_default,
35                named: config.catalog_named,
36            }));
37        }
38
39        // Fallback: dnx.catalog.toml
40        Self::load_from_catalog_toml(root)
41    }
42
43    /// Load catalog config from `dnx.catalog.toml` only (legacy support).
44    /// Returns `Ok(None)` if the file does not exist.
45    pub fn load_from_catalog_toml(root: &Path) -> Result<Option<Self>> {
46        let path = root.join("dnx.catalog.toml");
47        if !path.exists() {
48            return Ok(None);
49        }
50
51        let content = std::fs::read_to_string(&path)
52            .map_err(|e| DnxError::Catalog(format!("Failed to read {}: {}", path.display(), e)))?;
53
54        let config: CatalogConfig = toml::from_str(&content)
55            .map_err(|e| DnxError::Catalog(format!("Failed to parse {}: {}", path.display(), e)))?;
56
57        Ok(Some(config))
58    }
59
60    /// Create a CatalogConfig directly from default and named maps.
61    pub fn from_maps(
62        default: HashMap<String, String>,
63        named: HashMap<String, HashMap<String, String>>,
64    ) -> Self {
65        Self { default, named }
66    }
67
68    /// Resolve a `catalog:` specifier for the given package name.
69    ///
70    /// - `"catalog:"` → look up `pkg_name` in `default`
71    /// - `"catalog:testing"` → look up `pkg_name` in `named.testing`
72    pub fn resolve(&self, pkg_name: &str, spec: &str) -> Result<String> {
73        let suffix = spec
74            .strip_prefix("catalog:")
75            .ok_or_else(|| DnxError::Catalog(format!("Not a catalog specifier: {}", spec)))?;
76
77        if suffix.is_empty() {
78            // Default catalog
79            self.default.get(pkg_name).cloned().ok_or_else(|| {
80                DnxError::Catalog(format!(
81                    "Package '{}' not found in default catalog",
82                    pkg_name
83                ))
84            })
85        } else {
86            // Named catalog group
87            let group = self.named.get(suffix).ok_or_else(|| {
88                DnxError::Catalog(format!("Catalog group '{}' not found", suffix))
89            })?;
90            group.get(pkg_name).cloned().ok_or_else(|| {
91                DnxError::Catalog(format!(
92                    "Package '{}' not found in catalog group '{}'",
93                    pkg_name, suffix
94                ))
95            })
96        }
97    }
98}
99
100/// Resolve all `catalog:` references in a dependency map, returning a new map
101/// with catalog references replaced by their resolved version ranges.
102pub fn resolve_catalog_deps(
103    deps: &HashMap<String, String>,
104    catalog: &CatalogConfig,
105) -> Result<HashMap<String, String>> {
106    let mut resolved = HashMap::with_capacity(deps.len());
107    for (name, spec) in deps {
108        if spec.starts_with("catalog:") {
109            resolved.insert(name.clone(), catalog.resolve(name, spec)?);
110        } else {
111            resolved.insert(name.clone(), spec.clone());
112        }
113    }
114    Ok(resolved)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn make_catalog() -> CatalogConfig {
122        let mut default = HashMap::new();
123        default.insert("react".to_string(), "^18.3.1".to_string());
124        default.insert("typescript".to_string(), "^5.4.0".to_string());
125        default.insert("lodash".to_string(), "^4.17.21".to_string());
126
127        let mut testing = HashMap::new();
128        testing.insert("jest".to_string(), "^29.0.0".to_string());
129        testing.insert("vitest".to_string(), "^1.0.0".to_string());
130
131        let mut named = HashMap::new();
132        named.insert("testing".to_string(), testing);
133
134        CatalogConfig { default, named }
135    }
136
137    #[test]
138    fn test_resolve_default_catalog() {
139        let catalog = make_catalog();
140        assert_eq!(catalog.resolve("react", "catalog:").unwrap(), "^18.3.1");
141    }
142
143    #[test]
144    fn test_resolve_named_catalog() {
145        let catalog = make_catalog();
146        assert_eq!(
147            catalog.resolve("jest", "catalog:testing").unwrap(),
148            "^29.0.0"
149        );
150    }
151
152    #[test]
153    fn test_resolve_missing_package() {
154        let catalog = make_catalog();
155        assert!(catalog.resolve("nonexistent", "catalog:").is_err());
156    }
157
158    #[test]
159    fn test_resolve_missing_group() {
160        let catalog = make_catalog();
161        assert!(catalog.resolve("jest", "catalog:nope").is_err());
162    }
163
164    #[test]
165    fn test_resolve_catalog_deps() {
166        let catalog = make_catalog();
167        let mut deps = HashMap::new();
168        deps.insert("react".to_string(), "catalog:".to_string());
169        deps.insert("jest".to_string(), "catalog:testing".to_string());
170        deps.insert("axios".to_string(), "^1.6.0".to_string());
171
172        let resolved = resolve_catalog_deps(&deps, &catalog).unwrap();
173        assert_eq!(resolved["react"], "^18.3.1");
174        assert_eq!(resolved["jest"], "^29.0.0");
175        assert_eq!(resolved["axios"], "^1.6.0");
176    }
177
178    #[test]
179    fn test_from_maps() {
180        let mut default = HashMap::new();
181        default.insert("react".to_string(), "^18.0.0".to_string());
182        let catalog = CatalogConfig::from_maps(default, HashMap::new());
183        assert_eq!(catalog.resolve("react", "catalog:").unwrap(), "^18.0.0");
184    }
185
186    #[test]
187    fn test_load_missing_file() {
188        let result = CatalogConfig::load_from_catalog_toml(Path::new("/nonexistent"));
189        assert!(result.unwrap().is_none());
190    }
191}