Skip to main content

dnx_core/
config.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3use std::fs;
4use std::path::Path;
5
6// ---------------------------------------------------------------------------
7// TOML deserialization structs for dnx.toml
8// ---------------------------------------------------------------------------
9
10/// Top-level structure for `dnx.toml`.
11#[derive(Debug, Clone, Deserialize, Default)]
12#[serde(default)]
13pub struct DnxToml {
14    pub workspace: WorkspaceSection,
15    pub settings: SettingsSection,
16    pub registries: RegistriesSection,
17    pub catalog: HashMap<String, String>,
18    pub catalogs: HashMap<String, HashMap<String, String>>,
19    pub overrides: HashMap<String, String>,
20    #[serde(rename = "peer-dependency-rules")]
21    pub peer_dependency_rules: PeerDependencyRulesToml,
22    #[serde(rename = "patched-dependencies")]
23    pub patched_dependencies: HashMap<String, String>,
24    #[serde(rename = "only-built-dependencies")]
25    pub only_built_dependencies: OnlyBuiltDependencies,
26    #[serde(rename = "never-built-dependencies")]
27    pub never_built_dependencies: NeverBuiltDependencies,
28}
29
30#[derive(Debug, Clone, Deserialize, Default)]
31#[serde(default)]
32pub struct WorkspaceSection {
33    pub packages: Option<Vec<String>>,
34}
35
36#[derive(Debug, Clone, Deserialize)]
37#[serde(default)]
38pub struct SettingsSection {
39    pub registry: String,
40    #[serde(rename = "store-dir")]
41    pub store_dir: String,
42    #[serde(rename = "cache-dir")]
43    pub cache_dir: String,
44    pub concurrency: usize,
45    #[serde(rename = "prefer-offline")]
46    pub prefer_offline: bool,
47    #[serde(rename = "frozen-lockfile")]
48    pub frozen_lockfile: bool,
49    #[serde(rename = "shamefully-hoist")]
50    pub shamefully_hoist: bool,
51    pub hoist: bool,
52    #[serde(rename = "hoist-pattern")]
53    pub hoist_pattern: Vec<String>,
54    #[serde(rename = "public-hoist-pattern")]
55    pub public_hoist_pattern: Vec<String>,
56    #[serde(rename = "auto-install-peers")]
57    pub auto_install_peers: bool,
58    #[serde(rename = "strict-peer-dependencies")]
59    pub strict_peer_dependencies: bool,
60    #[serde(rename = "node-linker")]
61    pub node_linker: String,
62    #[serde(rename = "side-effects-cache")]
63    pub side_effects_cache: bool,
64    #[serde(rename = "verify-store-integrity")]
65    pub verify_store_integrity: bool,
66    #[serde(rename = "ignore-scripts")]
67    pub ignore_scripts: bool,
68    #[serde(rename = "enable-pre-post-scripts")]
69    pub enable_pre_post_scripts: bool,
70    #[serde(rename = "resolution-mode")]
71    pub resolution_mode: String,
72    #[serde(rename = "shell-emulator")]
73    pub shell_emulator: bool,
74    #[serde(rename = "use-node-version")]
75    pub use_node_version: String,
76    /// Path to .pnpmfile.cjs (empty = auto-detect, "false" = disabled)
77    #[serde(default)]
78    pub pnpmfile: String,
79    /// Directory for global package installations
80    #[serde(rename = "global-dir", default)]
81    pub global_dir: String,
82    /// Directory for global bin scripts
83    #[serde(rename = "global-bin-dir", default)]
84    pub global_bin_dir: String,
85    pub proxy: ProxySection,
86}
87
88impl Default for SettingsSection {
89    fn default() -> Self {
90        Self {
91            registry: "https://registry.npmjs.org".to_string(),
92            store_dir: "~/.dnx/store".to_string(),
93            cache_dir: "~/.dnx/cache".to_string(),
94            concurrency: 64,
95            prefer_offline: false,
96            frozen_lockfile: false,
97            shamefully_hoist: false,
98            hoist: true,
99            hoist_pattern: vec!["*".to_string()],
100            public_hoist_pattern: vec!["@types/*".to_string()],
101            auto_install_peers: true,
102            strict_peer_dependencies: false,
103            node_linker: "isolated".to_string(),
104            side_effects_cache: true,
105            verify_store_integrity: true,
106            ignore_scripts: false,
107            enable_pre_post_scripts: true,
108            resolution_mode: "highest".to_string(),
109            shell_emulator: false,
110            use_node_version: String::new(),
111            pnpmfile: String::new(),
112            global_dir: String::new(),
113            global_bin_dir: String::new(),
114            proxy: ProxySection::default(),
115        }
116    }
117}
118
119#[derive(Debug, Clone, Deserialize, Default)]
120#[serde(default)]
121pub struct ProxySection {
122    #[serde(rename = "https-proxy")]
123    pub https_proxy: String,
124    #[serde(rename = "http-proxy")]
125    pub http_proxy: String,
126    #[serde(rename = "no-proxy")]
127    pub no_proxy: String,
128}
129
130/// The registries section supports a mixed format:
131/// - `default = "https://registry.npmjs.org"` (plain string)
132/// - `"@company" = { url = "...", auth-token = "..." }` (table with url and optional auth-token)
133///
134/// We use a custom deserializer to handle this.
135#[derive(Debug, Clone, Default)]
136pub struct RegistriesSection {
137    pub default: String,
138    pub scoped: HashMap<String, ScopedRegistryToml>,
139}
140
141#[derive(Debug, Clone, Deserialize)]
142pub struct ScopedRegistryToml {
143    pub url: String,
144    #[serde(rename = "auth-token")]
145    pub auth_token: Option<String>,
146}
147
148impl<'de> Deserialize<'de> for RegistriesSection {
149    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
150    where
151        D: serde::Deserializer<'de>,
152    {
153        use serde::de::{MapAccess, Visitor};
154
155        struct RegistriesVisitor;
156
157        impl<'de> Visitor<'de> for RegistriesVisitor {
158            type Value = RegistriesSection;
159
160            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
161                formatter.write_str(
162                    "a registries table with a default string and optional scoped registry tables",
163                )
164            }
165
166            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
167            where
168                M: MapAccess<'de>,
169            {
170                let mut section = RegistriesSection {
171                    default: "https://registry.npmjs.org".to_string(),
172                    scoped: HashMap::new(),
173                };
174
175                while let Some(key) = map.next_key::<String>()? {
176                    if key == "default" {
177                        section.default = map.next_value::<String>()?;
178                    } else {
179                        let scoped: ScopedRegistryToml = map.next_value()?;
180                        section.scoped.insert(key, scoped);
181                    }
182                }
183
184                Ok(section)
185            }
186        }
187
188        deserializer.deserialize_map(RegistriesVisitor)
189    }
190}
191
192#[derive(Debug, Clone, Deserialize, Default)]
193#[serde(default)]
194pub struct PeerDependencyRulesToml {
195    #[serde(rename = "ignore-missing")]
196    pub ignore_missing: Vec<String>,
197    #[serde(rename = "allowed-versions")]
198    pub allowed_versions: HashMap<String, String>,
199    #[serde(rename = "allow-any")]
200    pub allow_any: Vec<String>,
201}
202
203#[derive(Debug, Clone, Deserialize, Default)]
204#[serde(default)]
205pub struct OnlyBuiltDependencies {
206    pub packages: Vec<String>,
207}
208
209#[derive(Debug, Clone, Deserialize, Default)]
210#[serde(default)]
211pub struct NeverBuiltDependencies {
212    pub packages: Vec<String>,
213}
214
215// ---------------------------------------------------------------------------
216// Runtime configuration
217// ---------------------------------------------------------------------------
218
219/// Scoped registry configuration for private packages.
220#[derive(Debug, Clone)]
221pub struct ScopedRegistry {
222    pub url: String,
223    pub auth_token: Option<String>,
224}
225
226/// Peer dependency rules configuration.
227#[derive(Debug, Clone, Default)]
228pub struct PeerDependencyRules {
229    pub ignore_missing: Vec<String>,
230    pub allowed_versions: HashMap<String, String>,
231    pub allow_any: Vec<String>,
232}
233
234/// Dnx configuration loaded from dnx.toml, .npmrc files, and environment variables.
235#[derive(Debug, Clone)]
236pub struct DnxConfig {
237    // -- existing fields --
238    pub registry_url: String,
239    pub auth_token: Option<String>,
240    pub concurrency: usize,
241    pub cache_dir: Option<String>,
242    pub prefer_offline: bool,
243    pub frozen: bool,
244    pub scoped_registries: HashMap<String, ScopedRegistry>,
245    pub proxy: Option<String>,
246
247    // -- new fields --
248    pub store_dir: Option<String>,
249    pub shamefully_hoist: bool,
250    pub hoist: bool,
251    pub hoist_pattern: Vec<String>,
252    pub public_hoist_pattern: Vec<String>,
253    pub auto_install_peers: bool,
254    pub strict_peer_dependencies: bool,
255    pub node_linker: String,
256    pub side_effects_cache: bool,
257    pub verify_store_integrity: bool,
258    pub ignore_scripts: bool,
259    pub enable_pre_post_scripts: bool,
260    pub resolution_mode: String,
261    pub shell_emulator: bool,
262    pub use_node_version: Option<String>,
263    pub frozen_lockfile: bool,
264    pub overrides: HashMap<String, String>,
265    pub peer_dependency_rules: PeerDependencyRules,
266    pub patched_dependencies: HashMap<String, String>,
267    pub only_built_dependencies: Vec<String>,
268    pub never_built_dependencies: Vec<String>,
269    pub workspace_patterns: Option<Vec<String>>,
270    pub catalog_default: HashMap<String, String>,
271    pub catalog_named: HashMap<String, HashMap<String, String>>,
272    pub pnpmfile: Option<String>,
273    pub global_dir: String,
274    pub global_bin_dir: String,
275}
276
277impl Default for DnxConfig {
278    fn default() -> Self {
279        Self {
280            registry_url: "https://registry.npmjs.org".to_string(),
281            auth_token: None,
282            concurrency: 64,
283            cache_dir: None,
284            prefer_offline: false,
285            frozen: false,
286            scoped_registries: HashMap::new(),
287            proxy: None,
288
289            store_dir: None,
290            shamefully_hoist: false,
291            hoist: true,
292            hoist_pattern: vec!["*".to_string()],
293            public_hoist_pattern: vec!["@types/*".to_string()],
294            auto_install_peers: true,
295            strict_peer_dependencies: false,
296            node_linker: "isolated".to_string(),
297            side_effects_cache: true,
298            verify_store_integrity: true,
299            ignore_scripts: false,
300            enable_pre_post_scripts: true,
301            resolution_mode: "highest".to_string(),
302            shell_emulator: false,
303            use_node_version: None,
304            frozen_lockfile: false,
305            overrides: HashMap::new(),
306            peer_dependency_rules: PeerDependencyRules::default(),
307            patched_dependencies: HashMap::new(),
308            only_built_dependencies: Vec::new(),
309            never_built_dependencies: Vec::new(),
310            workspace_patterns: None,
311            catalog_default: HashMap::new(),
312            catalog_named: HashMap::new(),
313            pnpmfile: None,
314            global_dir: default_global_dir(),
315            global_bin_dir: default_global_bin_dir(),
316        }
317    }
318}
319
320fn default_global_dir() -> String {
321    dirs::home_dir()
322        .map(|h| h.join(".dnx").join("global").to_string_lossy().to_string())
323        .unwrap_or_else(|| "~/.dnx/global".to_string())
324}
325
326fn default_global_bin_dir() -> String {
327    dirs::home_dir()
328        .map(|h| h.join(".dnx").join("bin").to_string_lossy().to_string())
329        .unwrap_or_else(|| "~/.dnx/bin".to_string())
330}
331
332impl DnxConfig {
333    /// Load configuration from dnx.toml, .npmrc files, and environment variables.
334    /// `project_dir` is the directory containing the project.
335    ///
336    /// Resolution order:
337    /// 1. Start with defaults.
338    /// 2. If `dnx.toml` exists in `project_dir`, populate all fields from it.
339    /// 3. If no `dnx.toml`, fall back to .npmrc parsing (home dir + project dir).
340    /// 4. Environment variables override specific fields.
341    /// 5. Proxy environment variables as fallback.
342    /// 6. Enforce HTTPS for non-local registries.
343    pub fn load(project_dir: &Path) -> Self {
344        let mut config = Self::default();
345
346        let dnx_toml_path = project_dir.join("dnx.toml");
347
348        if dnx_toml_path.exists() {
349            // ---- Load from dnx.toml ----
350            if let Ok(content) = fs::read_to_string(&dnx_toml_path) {
351                if let Ok(toml) = toml::from_str::<DnxToml>(&content) {
352                    apply_dnx_toml(&mut config, &toml);
353                }
354            }
355        } else {
356            // ---- Fallback: Load .npmrc (home dir then project dir) ----
357            let home = dirs::home_dir().unwrap_or_default();
358            let npmrc_paths = [home.join(".npmrc"), project_dir.join(".npmrc")];
359
360            for npmrc_path in &npmrc_paths {
361                if let Ok(content) = fs::read_to_string(npmrc_path) {
362                    parse_npmrc(&content, &mut config);
363                }
364            }
365        }
366
367        // ---- Environment variable overrides ----
368        if let Ok(registry) = std::env::var("DNX_REGISTRY") {
369            config.registry_url = registry;
370        }
371        if let Ok(concurrency) = std::env::var("DNX_CONCURRENCY") {
372            if let Ok(c) = concurrency.parse::<usize>() {
373                if c > 0 {
374                    config.concurrency = c.min(256);
375                }
376            }
377        }
378        if let Ok(cache_dir) = std::env::var("DNX_CACHE_DIR") {
379            config.cache_dir = Some(cache_dir);
380        }
381        if std::env::var("DNX_OFFLINE").as_deref() == Ok("true") {
382            config.prefer_offline = true;
383        }
384        if std::env::var("DNX_FROZEN").as_deref() == Ok("true") {
385            config.frozen = true;
386            config.frozen_lockfile = true;
387        }
388
389        // ---- Proxy environment variable fallback ----
390        if config.proxy.is_none() {
391            for var in &["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"] {
392                if let Ok(proxy) = std::env::var(var) {
393                    if !proxy.is_empty() {
394                        config.proxy = Some(proxy);
395                        break;
396                    }
397                }
398            }
399        }
400
401        // ---- Enforce HTTPS for non-local registries ----
402        if config.registry_url.starts_with("http://")
403            && !config.registry_url.contains("localhost")
404            && !config.registry_url.contains("127.0.0.1")
405        {
406            config.registry_url = config.registry_url.replace("http://", "https://");
407        }
408
409        config
410    }
411}
412
413/// Apply all fields from a parsed `DnxToml` onto the runtime `DnxConfig`.
414fn apply_dnx_toml(config: &mut DnxConfig, toml: &DnxToml) {
415    // Workspace
416    config.workspace_patterns = toml.workspace.packages.clone();
417
418    // Settings
419    let s = &toml.settings;
420    config.registry_url = s.registry.clone();
421
422    if !s.store_dir.is_empty() {
423        config.store_dir = Some(s.store_dir.clone());
424    }
425    if !s.cache_dir.is_empty() {
426        config.cache_dir = Some(s.cache_dir.clone());
427    }
428
429    config.concurrency = s.concurrency;
430    config.prefer_offline = s.prefer_offline;
431    config.frozen = s.frozen_lockfile;
432    config.frozen_lockfile = s.frozen_lockfile;
433    config.shamefully_hoist = s.shamefully_hoist;
434    config.hoist = s.hoist;
435    config.hoist_pattern = s.hoist_pattern.clone();
436    config.public_hoist_pattern = s.public_hoist_pattern.clone();
437    config.auto_install_peers = s.auto_install_peers;
438    config.strict_peer_dependencies = s.strict_peer_dependencies;
439    config.node_linker = s.node_linker.clone();
440    config.side_effects_cache = s.side_effects_cache;
441    config.verify_store_integrity = s.verify_store_integrity;
442    config.ignore_scripts = s.ignore_scripts;
443    config.enable_pre_post_scripts = s.enable_pre_post_scripts;
444    config.resolution_mode = s.resolution_mode.clone();
445    config.shell_emulator = s.shell_emulator;
446
447    if !s.use_node_version.is_empty() {
448        config.use_node_version = Some(s.use_node_version.clone());
449    }
450
451    // Proxy from settings
452    if !s.proxy.https_proxy.is_empty() {
453        config.proxy = Some(s.proxy.https_proxy.clone());
454    } else if !s.proxy.http_proxy.is_empty() {
455        config.proxy = Some(s.proxy.http_proxy.clone());
456    }
457
458    // Registries
459    if !toml.registries.default.is_empty() {
460        config.registry_url = toml.registries.default.clone();
461    }
462    for (scope, scoped) in &toml.registries.scoped {
463        config.scoped_registries.insert(
464            scope.clone(),
465            ScopedRegistry {
466                url: scoped.url.clone(),
467                auth_token: scoped.auth_token.clone(),
468            },
469        );
470    }
471
472    // Catalogs
473    config.catalog_default = toml.catalog.clone();
474    config.catalog_named = toml.catalogs.clone();
475
476    // Overrides
477    config.overrides = toml.overrides.clone();
478
479    // Peer dependency rules
480    config.peer_dependency_rules = PeerDependencyRules {
481        ignore_missing: toml.peer_dependency_rules.ignore_missing.clone(),
482        allowed_versions: toml.peer_dependency_rules.allowed_versions.clone(),
483        allow_any: toml.peer_dependency_rules.allow_any.clone(),
484    };
485
486    // Patched dependencies
487    config.patched_dependencies = toml.patched_dependencies.clone();
488
489    // Build dependency filters
490    config.only_built_dependencies = toml.only_built_dependencies.packages.clone();
491    config.never_built_dependencies = toml.never_built_dependencies.packages.clone();
492
493    // Pnpmfile
494    if !s.pnpmfile.is_empty() {
495        if s.pnpmfile == "false" {
496            config.pnpmfile = Some("false".to_string());
497        } else {
498            config.pnpmfile = Some(s.pnpmfile.clone());
499        }
500    }
501
502    // Global dirs
503    if !s.global_dir.is_empty() {
504        config.global_dir = s.global_dir.clone();
505    }
506    if !s.global_bin_dir.is_empty() {
507        config.global_bin_dir = s.global_bin_dir.clone();
508    }
509}
510
511/// Parse a `.npmrc` file and apply its settings to the config.
512fn parse_npmrc(content: &str, config: &mut DnxConfig) {
513    for line in content.lines() {
514        let trimmed = line.trim();
515        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
516            continue;
517        }
518
519        // Scoped registry: @scope:registry=<url>
520        if trimmed.starts_with('@') {
521            if let Some(idx) = trimmed.find(":registry") {
522                let scope = &trimmed[..idx]; // e.g. "@company"
523                let rest = &trimmed[idx + 9..]; // after ":registry"
524                let rest = rest.trim_start();
525                if let Some(url) = rest.strip_prefix('=') {
526                    let url = url.trim().trim_matches(|c| c == '"' || c == '\'');
527                    config.scoped_registries.insert(
528                        scope.to_string(),
529                        ScopedRegistry {
530                            url: url.to_string(),
531                            auth_token: None,
532                        },
533                    );
534                }
535                continue;
536            }
537        }
538
539        // Auth token: //<registry>/:_authToken=<token>
540        if let Some(rest) = trimmed.strip_prefix("//") {
541            if let Some(token_part) = rest.split("/:_authToken=").nth(1) {
542                let registry_host = rest.split("/:_authToken=").next().unwrap_or("");
543                // Check if this token is for a scoped registry
544                let mut matched_scope = false;
545                for (_scope, scoped) in config.scoped_registries.iter_mut() {
546                    if scoped.url.contains(registry_host) {
547                        scoped.auth_token = Some(token_part.to_string());
548                        matched_scope = true;
549                    }
550                }
551                if !matched_scope {
552                    config.auth_token = Some(token_part.to_string());
553                }
554                continue;
555            }
556        }
557
558        // Proxy: proxy=<url> or https-proxy=<url>
559        if let Some(rest) = trimmed.strip_prefix("proxy") {
560            let rest = rest.trim_start();
561            if let Some(url) = rest.strip_prefix('=') {
562                let url = url.trim().trim_matches(|c| c == '"' || c == '\'');
563                if !url.is_empty() {
564                    config.proxy = Some(url.to_string());
565                }
566            }
567            continue;
568        }
569        if let Some(rest) = trimmed.strip_prefix("https-proxy") {
570            let rest = rest.trim_start();
571            if let Some(url) = rest.strip_prefix('=') {
572                let url = url.trim().trim_matches(|c| c == '"' || c == '\'');
573                if !url.is_empty() {
574                    config.proxy = Some(url.to_string());
575                }
576            }
577            continue;
578        }
579
580        // Registry URL: registry=<url>
581        if let Some(rest) = trimmed.strip_prefix("registry") {
582            let rest = rest.trim_start();
583            if let Some(url) = rest.strip_prefix('=') {
584                let url = url.trim().trim_matches(|c| c == '"' || c == '\'');
585                config.registry_url = url.to_string();
586            }
587        }
588    }
589}