Skip to main content

aube_settings/
values.rs

1//! Resolve typed setting values using the [`meta`](super::meta)
2//! registry as the single source of truth for *which* keys map to
3//! which setting.
4//!
5//! The registry (generated at build time from `settings.toml`)
6//! records, for every setting:
7//!
8//!   - its canonical pnpm name
9//!   - the `sources.npmrc` keys that populate it from `.npmrc`
10//!   - the `sources.workspaceYaml` keys that populate it from
11//!     `pnpm-workspace.yaml` / `aube-workspace.yaml`
12//!   - the `sources.env` variables that populate it from the shell
13//!   - the `sources.cli` flags that populate it from clap
14//!   - the type of the value
15//!
16//! This module is the *value* side of the same registry: given a
17//! setting name and a bag of raw source inputs, it walks the metadata
18//! and returns the resolved value. Adding a new setting is then a
19//! one-place change in `settings.toml` — no corresponding edit in the
20//! `NpmConfig::apply` parser or anywhere else.
21//!
22//! Supported scalar types are `bool`, `string` (including `path` and
23//! quoted-union enum strings), `int` (as `u64`), and `list<string>`.
24//! Supported sources are `.npmrc` entries, aube's user config file, a
25//! raw `pnpm-workspace.yaml` map, captured environment variables, and
26//! parsed CLI flags.
27
28use std::sync::OnceLock;
29
30use crate::meta;
31
32/// Process-wide CLI overrides registered from generic `--config.<key>`
33/// flags. Walked by every `*_from_cli` helper *after* the per-callsite
34/// `cli` slice, so command-specific flags keep first-match priority and
35/// the generic form acts as a fallback that still wins over env / file
36/// sources.
37static GLOBAL_CLI_OVERRIDES: OnceLock<Vec<(String, String)>> = OnceLock::new();
38
39/// Register the parsed `--config.<key>[=<value>]` pairs once per
40/// process. Idempotent — second calls are silently ignored, matching
41/// the other `set_global_*` helpers in the binary crate.
42pub fn set_global_cli_overrides(overrides: Vec<(String, String)>) {
43    let _ = GLOBAL_CLI_OVERRIDES.set(overrides);
44}
45
46fn global_cli_overrides() -> &'static [(String, String)] {
47    GLOBAL_CLI_OVERRIDES.get().map(Vec::as_slice).unwrap_or(&[])
48}
49
50/// Bundle of source inputs consumed by the per-setting typed
51/// accessors in [`resolved`]. Each field is a borrowed view so
52/// callers can reuse the same owned values across many lookups
53/// without cloning.
54///
55/// File-source fields are split by scope (user vs project) so the
56/// resolver can apply the locality principle — project-scope entries
57/// outrank user-scope entries, and within a scope aube's own config
58/// outranks `.npmrc`. See the module-level docs for the full chain.
59pub struct ResolveCtx<'a> {
60    /// Project-scope aube config (`<cwd>/.config/aube/config.toml`).
61    /// Highest-precedence file source by default — a project may pin
62    /// settings here as an alternative to committing them into the
63    /// project `.npmrc` shared with npm/pnpm/yarn.
64    pub project_aube_config: &'a [(String, String)],
65    /// Project-scope `.npmrc` (`<cwd>/.npmrc`) plus any
66    /// `npmrcAuthFile` it points at, in load order.
67    pub project_npmrc: &'a [(String, String)],
68    /// User-scope aube config (`~/.config/aube/config.toml`). Aube's
69    /// authoritative store for user-level settings written via
70    /// `aube config set` — outranks `~/.npmrc` so leftover entries in
71    /// a shared `.npmrc` don't silently shadow what aube wrote.
72    pub user_aube_config: &'a [(String, String)],
73    /// User-scope `.npmrc` (`~/.npmrc` or `NPM_CONFIG_USERCONFIG`) plus
74    /// pnpm's global `auth.ini`, in load order.
75    pub user_npmrc: &'a [(String, String)],
76    /// Raw top-level map from `pnpm-workspace.yaml` /
77    /// `aube-workspace.yaml`, as returned by
78    /// `aube_manifest::workspace::load_raw`.
79    pub workspace_yaml: &'a std::collections::BTreeMap<String, yaml_serde::Value>,
80    /// Captured environment variables relevant to settings. In
81    /// production this is populated by [`capture_env`]; tests build a
82    /// literal slice. `sources.env` alias order defines priority; within
83    /// one alias, lookups iterate from the end so later entries win.
84    pub env: &'a [(String, String)],
85    /// Parsed CLI flag values for the command being executed. Each
86    /// entry is a `(flag_name, value)` pair where `flag_name` matches
87    /// a `sources.cli` alias declared in `settings.toml`. Values
88    /// should already be normalized to the raw form the type-specific
89    /// parser expects (`"true"`/`"false"` for bools, etc).
90    pub cli: &'a [(String, String)],
91    /// Lowest-priority defaults supplied by an embedder. An embedding host
92    /// (a tool that drives aube's command layer as a library) feeds setting
93    /// defaults here through the normal settings path; every user- and
94    /// project-level source overrides them. Standalone aube leaves this
95    /// empty, so the per-setting built-in defaults from `settings.toml`
96    /// apply unchanged. Keyed by the same canonical setting names as the
97    /// file sources.
98    pub embedder_defaults: &'a [(String, String)],
99}
100
101impl<'a> ResolveCtx<'a> {
102    /// Construct a context that only sees the merged-`.npmrc` and
103    /// workspace-yaml file sources. Convenience for tests and call
104    /// sites that don't need scope splitting or env/cli plumbing.
105    ///
106    /// The supplied `.npmrc` slice is treated as project-scope so its
107    /// values win over the (empty) user-scope sources — matching
108    /// the install-time precedence callers used to rely on before the
109    /// split.
110    pub fn files_only(
111        npmrc: &'a [(String, String)],
112        workspace_yaml: &'a std::collections::BTreeMap<String, yaml_serde::Value>,
113    ) -> Self {
114        Self {
115            project_aube_config: &[],
116            project_npmrc: npmrc,
117            user_aube_config: &[],
118            user_npmrc: &[],
119            workspace_yaml,
120            env: &[],
121            cli: &[],
122            // Process-global embedder defaults still apply on this reduced
123            // path so embedder-fed setting defaults reach lockfile/workspace
124            // readers that build a `files_only` ctx.
125            embedder_defaults: embedder_defaults(),
126        }
127    }
128}
129
130/// Embedder-supplied setting defaults, registered once at startup by an
131/// embedding host. Standalone aube never registers any, so this stays empty
132/// and the per-setting built-in defaults from `settings.toml` apply. Each
133/// entry is a `(canonical_setting_name, raw_value)` pair, parsed by the same
134/// type-specific readers the file sources use.
135static EMBEDDER_DEFAULTS: OnceLock<Vec<(String, String)>> = OnceLock::new();
136
137/// Register embedder default settings. Idempotent — the first registration
138/// wins. Not part of standalone aube's path; an embedder calls this (via the
139/// library entry point) before any command resolves settings.
140pub fn set_embedder_defaults(defaults: Vec<(String, String)>) {
141    let _ = EMBEDDER_DEFAULTS.set(defaults);
142}
143
144/// The registered embedder defaults, or an empty slice when none were set.
145pub fn embedder_defaults() -> &'static [(String, String)] {
146    EMBEDDER_DEFAULTS.get().map_or(&[], Vec::as_slice)
147}
148
149/// Process-wide env snapshot. Captured once on first read so every
150/// `ResolveCtx` walks the same list without repeating the
151/// `std::env::vars()` syscall storm. Subprocesses can't mutate the
152/// parent env, so a single capture is correct for the lifetime of the
153/// CLI process.
154static PROCESS_ENV: std::sync::LazyLock<Vec<(String, String)>> =
155    std::sync::LazyLock::new(|| std::env::vars().collect());
156
157/// Snapshot the process environment into a `(name, value)` list the
158/// resolver can walk. Filtering happens at lookup time against the
159/// setting's declared `env_vars` aliases, so this captures everything
160/// upfront and lets the metadata decide what's relevant.
161///
162/// First caller in the process triggers the underlying `std::env::vars()`
163/// walk; subsequent callers get a cheap `Vec` clone of the cached
164/// snapshot. The clone keeps the existing `Vec<(String, String)>` API
165/// surface; callers that want zero-alloc access can read [`process_env`]
166/// directly.
167pub fn capture_env() -> Vec<(String, String)> {
168    PROCESS_ENV.clone()
169}
170
171/// Borrowed view of the process-wide env snapshot. Callers that only
172/// need to read should prefer this over [`capture_env`] — no Vec
173/// clone, no per-entry String clone.
174pub fn process_env() -> &'static [(String, String)] {
175    PROCESS_ENV.as_slice()
176}
177
178/// Typed per-setting accessors generated at build time from
179/// `settings.toml`. One function per scalar setting (`bool`,
180/// `string`/`path`/`url`, quoted-union enum, `int`, `list<string>`). The
181/// function signature *is* the type check — `auto_install_peers`
182/// returns `bool`, `store_dir` returns `Option<String>`, and
183/// calling either on the wrong type is a compile error.
184///
185/// Default precedence, high-to-low:
186///
187/// ```text
188/// cli > env
189///     > project_aube_config (<cwd>/.config/aube/config.toml)
190///     > project_npmrc       (<cwd>/.npmrc + npmrcAuthFile)
191///     > workspace_yaml      (pnpm-workspace.yaml / aube-workspace.yaml)
192///     > user_aube_config    (~/.config/aube/config.toml)
193///     > user_npmrc          (~/.npmrc + pnpm auth.ini)
194/// ```
195///
196/// Two principles drive the file-source ordering:
197///
198/// - **Scope locality**: project-scope entries beat user-scope entries.
199///   `workspace_yaml` lives at the project root, so it ranks above
200///   every user-scope source.
201/// - **Aube authority**: within a scope, aube's own config file beats
202///   `.npmrc`. Values aube writes via `aube config set` are not
203///   silently shadowed by leftover entries in a `.npmrc` that other
204///   tools (npm, pnpm, yarn) also read.
205///
206/// The per-setting `precedence` override in `settings.toml` reorders
207/// the file-based sources but cannot demote `cli` or `env` off the
208/// top — CLI flags and environment variables always win. Bare names
209/// `npmrc` and `aubeConfig` in a `precedence` list expand to their
210/// project+user pair (project first); use the scope-qualified names
211/// `projectNpmrc`/`userNpmrc`/`projectAubeConfig`/`userAubeConfig` for
212/// fine-grained control.
213///
214/// Settings with concrete parseable defaults return the defaulted
215/// value directly; settings whose default is undefined or contextual
216/// still return `Option<T>`.
217pub mod resolved {
218    use super::ResolveCtx;
219    include!(concat!(env!("OUT_DIR"), "/settings_resolved.rs"));
220}
221
222/// Resolve a `bool` setting by walking its declared `.npmrc` source
223/// keys in reverse order (so a later `.npmrc` entry overrides an
224/// earlier one). Returns `None` if the metadata entry doesn't exist,
225/// the setting isn't a bool, or no source key was found in `entries`.
226///
227/// `entries` is one of the per-scope slices from
228/// [`crate::ResolveCtx`] (e.g. `project_npmrc` or `user_npmrc`).
229/// Within a single scope, iterating from the end gives last-write-wins
230/// over duplicate keys.
231pub(crate) fn bool_from_npmrc(setting: &str, entries: &[(String, String)]) -> Option<bool> {
232    let meta = meta::find(setting)?;
233    if meta.type_ != "bool" {
234        return None;
235    }
236    for (key, raw) in entries.iter().rev() {
237        if meta.npmrc_keys.contains(&key.as_str())
238            && let Some(v) = parse_bool(raw)
239        {
240            return Some(v);
241        }
242    }
243    None
244}
245
246/// Resolve a `string` setting by walking its declared `.npmrc` source
247/// keys in reverse order. Mirrors [`bool_from_npmrc`] but returns the
248/// raw value verbatim — trimming and further interpretation are left
249/// to the caller, since "string" settings (e.g. `nodeVersion`,
250/// registry URLs) have per-setting normalization rules.
251pub fn string_from_npmrc(setting: &str, entries: &[(String, String)]) -> Option<String> {
252    let meta = meta::find(setting)?;
253    if !is_stringish(meta.type_) {
254        return None;
255    }
256    for (key, raw) in entries.iter().rev() {
257        if meta.npmrc_keys.contains(&key.as_str()) {
258            return Some(raw.clone());
259        }
260    }
261    None
262}
263
264/// Resolve a `bool` setting from a raw `pnpm-workspace.yaml` map,
265/// walking the declared `sources.workspaceYaml` aliases. Returns
266/// `None` if no alias is present in the map, the setting isn't a
267/// bool, or the value isn't a boolean (or boolean-like string).
268///
269/// Aliases are walked in the order they appear in
270/// `workspace_yaml_keys`; pnpm files don't permit duplicate top-level
271/// keys, so precedence among aliases within one file is moot —
272/// whichever one is present wins.
273pub(crate) fn bool_from_workspace_yaml(
274    setting: &str,
275    raw: &std::collections::BTreeMap<String, yaml_serde::Value>,
276) -> Option<bool> {
277    let meta = meta::find(setting)?;
278    if meta.type_ != "bool" {
279        return None;
280    }
281    for key in meta.workspace_yaml_keys {
282        let Some(val) = workspace_yaml_value(raw, key) else {
283            continue;
284        };
285        match val {
286            yaml_serde::Value::Bool(b) => return Some(*b),
287            yaml_serde::Value::String(s) => {
288                if let Some(b) = parse_bool(s) {
289                    return Some(b);
290                }
291            }
292            _ => {}
293        }
294    }
295    None
296}
297
298/// Resolve a `string` setting from a raw `pnpm-workspace.yaml` map,
299/// walking the declared `sources.workspaceYaml` aliases. Returns
300/// `None` if no alias is present in the map, the setting isn't a
301/// string, or the value is not a YAML string/number/bool scalar.
302///
303/// Non-string scalars (numbers, booleans) are coerced to their
304/// lexical form. Complex values (sequences, mappings) return `None`
305/// rather than a bogus rendering.
306pub fn string_from_workspace_yaml(
307    setting: &str,
308    raw: &std::collections::BTreeMap<String, yaml_serde::Value>,
309) -> Option<String> {
310    let meta = meta::find(setting)?;
311    if !is_stringish(meta.type_) {
312        return None;
313    }
314    for key in meta.workspace_yaml_keys {
315        let Some(val) = workspace_yaml_value(raw, key) else {
316            continue;
317        };
318        match val {
319            yaml_serde::Value::String(s) => return Some(s.clone()),
320            yaml_serde::Value::Number(n) => return Some(n.to_string()),
321            yaml_serde::Value::Bool(b) => return Some(b.to_string()),
322            _ => {}
323        }
324    }
325    None
326}
327
328/// True if this setting's declared type is one the generic string
329/// helpers should accept: `string`, `path`, or an enum-style union
330/// literal like `"highest" | "time-based"`. Mirrors the type set the
331/// build-time generator emits as `Option<String>` accessors.
332fn is_stringish(ty: &str) -> bool {
333    matches!(ty, "string" | "path" | "url") || ty.starts_with('"')
334}
335
336/// Resolve an `int` setting from `.npmrc` entries, parsed as `u64`.
337/// Mirrors [`bool_from_npmrc`].
338pub(crate) fn u64_from_npmrc(setting: &str, entries: &[(String, String)]) -> Option<u64> {
339    let meta = meta::find(setting)?;
340    if meta.type_ != "int" {
341        return None;
342    }
343    for (key, raw) in entries.iter().rev() {
344        if meta.npmrc_keys.contains(&key.as_str())
345            && let Ok(v) = raw.trim().parse::<u64>()
346        {
347            return Some(v);
348        }
349    }
350    None
351}
352
353/// Resolve an `int` setting from a raw `pnpm-workspace.yaml` map.
354/// Accepts YAML integers and stringified numbers.
355pub(crate) fn u64_from_workspace_yaml(
356    setting: &str,
357    raw: &std::collections::BTreeMap<String, yaml_serde::Value>,
358) -> Option<u64> {
359    let meta = meta::find(setting)?;
360    if meta.type_ != "int" {
361        return None;
362    }
363    for key in meta.workspace_yaml_keys {
364        let Some(val) = workspace_yaml_value(raw, key) else {
365            continue;
366        };
367        match val {
368            yaml_serde::Value::Number(n) => {
369                if let Some(u) = n.as_u64() {
370                    return Some(u);
371                }
372            }
373            yaml_serde::Value::String(s) => {
374                if let Ok(u) = s.trim().parse::<u64>() {
375                    return Some(u);
376                }
377            }
378            _ => {}
379        }
380    }
381    None
382}
383
384/// Resolve a `list<string>` setting from `.npmrc` entries. pnpm and
385/// npm accept either a JSON-ish array (`["a","b"]`) or a
386/// comma-separated bare string (`a,b`).
387pub(crate) fn string_list_from_npmrc(
388    setting: &str,
389    entries: &[(String, String)],
390) -> Option<Vec<String>> {
391    let meta = meta::find(setting)?;
392    if meta.type_ != "list<string>" {
393        return None;
394    }
395    for (key, raw) in entries.iter().rev() {
396        if meta.npmrc_keys.contains(&key.as_str()) {
397            return Some(parse_string_list(raw));
398        }
399    }
400    None
401}
402
403/// Resolve a `list<string>` setting from a raw workspace yaml map.
404/// Accepts YAML sequences of strings, or a single string that gets
405/// parsed with [`parse_string_list`] (for pnpm-compat YAML files
406/// that stringify the list).
407pub(crate) fn string_list_from_workspace_yaml(
408    setting: &str,
409    raw: &std::collections::BTreeMap<String, yaml_serde::Value>,
410) -> Option<Vec<String>> {
411    let meta = meta::find(setting)?;
412    if meta.type_ != "list<string>" {
413        return None;
414    }
415    for key in meta.workspace_yaml_keys {
416        let Some(val) = workspace_yaml_value(raw, key) else {
417            continue;
418        };
419        match val {
420            yaml_serde::Value::Sequence(seq) => {
421                let items: Vec<String> = seq
422                    .iter()
423                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
424                    .collect();
425                return Some(items);
426            }
427            yaml_serde::Value::String(s) => return Some(parse_string_list(s)),
428            _ => {}
429        }
430    }
431    None
432}
433
434pub fn workspace_yaml_value<'a>(
435    raw: &'a std::collections::BTreeMap<String, yaml_serde::Value>,
436    key: &str,
437) -> Option<&'a yaml_serde::Value> {
438    let mut parts = key.split('.');
439    let first = parts.next()?;
440    let mut value = raw.get(first)?;
441    for part in parts {
442        let yaml_serde::Value::Mapping(map) = value else {
443            return None;
444        };
445        value = map.get(yaml_serde::Value::String(part.to_string()))?;
446    }
447    Some(value)
448}
449
450fn raw_from_env<'a>(meta: &meta::SettingMeta, env: &'a [(String, String)]) -> Option<&'a str> {
451    for alias in meta.env_vars.iter().rev() {
452        // Gate the tool-branded alias (`AUBE_<NAME>`) on the active embedder's
453        // `env_prefix`: standalone aube (`Some("AUBE")`) reads it as before, an
454        // embedder with `None` reads no branded settings env vars. The neutral
455        // `npm_config_*` / `NPM_CONFIG_*` aliases and bare external vars are
456        // never gated. `env_prefix` is the single binary switch for the whole
457        // branded-env settings surface.
458        if !aube_util::env::branded_env_alias_enabled(alias) {
459            continue;
460        }
461        for (key, raw) in env.iter().rev() {
462            if key == alias {
463                return Some(raw);
464            }
465        }
466    }
467    None
468}
469
470/// Resolve a `bool` setting from a captured environment snapshot,
471/// walking the declared `sources.env` aliases in reverse priority order.
472/// Returns `None` on unknown setting, wrong type, or unparseable value.
473pub(crate) fn bool_from_env(setting: &str, env: &[(String, String)]) -> Option<bool> {
474    let meta = meta::find(setting)?;
475    if meta.type_ != "bool" {
476        return None;
477    }
478    raw_from_env(meta, env).and_then(parse_bool)
479}
480
481/// Resolve a `string` setting from a captured environment snapshot.
482pub fn string_from_env(setting: &str, env: &[(String, String)]) -> Option<String> {
483    let meta = meta::find(setting)?;
484    if !is_stringish(meta.type_) {
485        return None;
486    }
487    raw_from_env(meta, env).map(ToOwned::to_owned)
488}
489
490/// Resolve an `int` setting from a captured environment snapshot.
491pub(crate) fn u64_from_env(setting: &str, env: &[(String, String)]) -> Option<u64> {
492    let meta = meta::find(setting)?;
493    if meta.type_ != "int" {
494        return None;
495    }
496    raw_from_env(meta, env).and_then(|raw| raw.trim().parse::<u64>().ok())
497}
498
499/// Resolve a `list<string>` setting from a captured environment
500/// snapshot. Accepts the same stringified forms as `.npmrc`.
501pub(crate) fn string_list_from_env(setting: &str, env: &[(String, String)]) -> Option<Vec<String>> {
502    let meta = meta::find(setting)?;
503    if meta.type_ != "list<string>" {
504        return None;
505    }
506    raw_from_env(meta, env).map(parse_string_list)
507}
508
509/// True if the user-supplied CLI key targets `meta`. Matches against
510/// the declared `sources.cli` aliases first (preserving exact behavior
511/// for command-specific flags) and then falls back to the canonical
512/// pnpm name in either kebab- or camelCase form so generic
513/// `--config.<key>` overrides resolve regardless of which spelling the
514/// user typed.
515fn cli_key_matches(key: &str, meta: &meta::SettingMeta) -> bool {
516    if meta.cli_flags.contains(&key) {
517        return true;
518    }
519    if key == meta.name {
520        return true;
521    }
522    let key_kebab = to_kebab_case(key);
523    if key_kebab == to_kebab_case(meta.name) {
524        return true;
525    }
526    false
527}
528
529/// Lower-case kebab form of a setting / flag identifier. Splits on
530/// `-`, `_`, dotted-path segments, and lowercase→UPPER transitions so
531/// callers can compare `strict-dep-builds`, `strictDepBuilds`,
532/// `STRICT_DEP_BUILDS`, and `strict_dep_builds` interchangeably. Dots
533/// are preserved so nested settings like
534/// `peerDependencyRules.ignoreMissing` keep their path structure.
535///
536/// Consecutive uppercase runs (e.g. `XMLConfig`) are collapsed to a
537/// single lowercase token (`xmlconfig`), matching the auto-alias
538/// generator in `aube-settings/build.rs`. No pnpm setting today contains
539/// an internal acronym, so the imperfection is invisible in practice; if
540/// one is ever added, the synthesized npmrc alias and this matcher have
541/// to evolve together.
542fn to_kebab_case(s: &str) -> String {
543    let mut out = String::with_capacity(s.len() + 4);
544    let mut prev_lower = false;
545    for c in s.chars() {
546        if c == '_' || c == '-' {
547            if !out.ends_with('-') && !out.is_empty() {
548                out.push('-');
549            }
550            prev_lower = false;
551        } else if c == '.' {
552            out.push('.');
553            prev_lower = false;
554        } else if c.is_ascii_uppercase() {
555            if prev_lower {
556                out.push('-');
557            }
558            out.push(c.to_ascii_lowercase());
559            prev_lower = false;
560        } else {
561            out.push(c);
562            prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
563        }
564    }
565    out
566}
567
568/// Walk the per-callsite `cli` slice (newest entry first), then the
569/// process-global `--config.<key>` overrides. The `accept` predicate
570/// lets typed callers (`bool`, `int`) keep scanning past an unparseable
571/// value so a later valid duplicate still wins — matching the original
572/// per-source loops. String / string-list callers pass `|_| true`; the
573/// global overrides have `'static` storage so the merged lifetime is
574/// whichever `cli` borrow the caller passed in.
575fn cli_raw_for<'a>(
576    meta: &meta::SettingMeta,
577    cli: &'a [(String, String)],
578    accept: impl Fn(&str) -> bool,
579) -> Option<&'a str> {
580    for (key, raw) in cli.iter().rev() {
581        if cli_key_matches(key, meta) && accept(raw.as_str()) {
582            return Some(raw.as_str());
583        }
584    }
585    for (key, raw) in global_cli_overrides().iter().rev() {
586        if cli_key_matches(key, meta) && accept(raw.as_str()) {
587            return Some(raw.as_str());
588        }
589    }
590    None
591}
592
593/// Resolve a `bool` setting from a parsed CLI flag bag. The bag
594/// entries are whatever each command extracts from its clap struct
595/// before building the `ResolveCtx`. Keys may be either an alias
596/// declared in `sources.cli` or the canonical setting name (in any
597/// reasonable case form), so generic `--config.<key>` overrides reach
598/// every setting without per-flag wiring. An unparseable value (e.g.
599/// `--config.strictDepBuilds=notabool`) is skipped rather than masking
600/// an earlier valid entry — caller still gets `None` if every match is
601/// invalid, matching how `bool_from_npmrc` handles the same case.
602pub(crate) fn bool_from_cli(setting: &str, cli: &[(String, String)]) -> Option<bool> {
603    let meta = meta::find(setting)?;
604    if meta.type_ != "bool" {
605        return None;
606    }
607    cli_raw_for(meta, cli, |raw| parse_bool(raw).is_some()).and_then(parse_bool)
608}
609
610/// Resolve a `string` setting from a parsed CLI flag bag.
611pub fn string_from_cli(setting: &str, cli: &[(String, String)]) -> Option<String> {
612    let meta = meta::find(setting)?;
613    if !is_stringish(meta.type_) {
614        return None;
615    }
616    cli_raw_for(meta, cli, |_| true).map(ToOwned::to_owned)
617}
618
619/// Resolve an `int` setting from a parsed CLI flag bag.
620pub(crate) fn u64_from_cli(setting: &str, cli: &[(String, String)]) -> Option<u64> {
621    let meta = meta::find(setting)?;
622    if meta.type_ != "int" {
623        return None;
624    }
625    cli_raw_for(meta, cli, |raw| raw.trim().parse::<u64>().is_ok())
626        .and_then(|raw| raw.trim().parse::<u64>().ok())
627}
628
629/// Resolve a `list<string>` setting from a parsed CLI flag bag.
630pub(crate) fn string_list_from_cli(setting: &str, cli: &[(String, String)]) -> Option<Vec<String>> {
631    let meta = meta::find(setting)?;
632    if meta.type_ != "list<string>" {
633        return None;
634    }
635    cli_raw_for(meta, cli, |_| true).map(parse_string_list)
636}
637
638/// Parse a pnpm/npm-style stringified list. Accepts a JSON-ish array
639/// `["a","b"]` or a plain comma-separated list `a,b,c`. Empty entries
640/// and surrounding whitespace/quotes are trimmed.
641fn parse_string_list(raw: &str) -> Vec<String> {
642    let trimmed = raw.trim();
643    if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
644        return inner
645            .split(',')
646            .map(|s| {
647                s.trim()
648                    .trim_matches(|c: char| c == '"' || c == '\'')
649                    .to_string()
650            })
651            .filter(|s| !s.is_empty())
652            .collect();
653    }
654    trimmed
655        .split(',')
656        .map(|s| s.trim().to_string())
657        .filter(|s| !s.is_empty())
658        .collect()
659}
660
661/// Parse a `.npmrc`-style boolean. npm/pnpm accept `true`/`false` and
662/// the shell-style `"1"` / `"0"`. Anything else returns `None` so the
663/// caller's default takes over.
664///
665/// Public so `aube-registry` and any other crate that hand-parses
666/// `.npmrc` scalar values can share the same accept-set — a future
667/// tweak (e.g. accepting `yes`/`no`) lands in one place.
668pub fn parse_bool(s: &str) -> Option<bool> {
669    match s.trim().to_ascii_lowercase().as_str() {
670        "true" | "1" => Some(true),
671        "false" | "0" => Some(false),
672        _ => None,
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679    use std::collections::BTreeMap;
680
681    fn entries(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
682        pairs
683            .iter()
684            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
685            .collect()
686    }
687
688    #[test]
689    fn workspace_yaml_value_resolves_dotted_paths() {
690        let raw: BTreeMap<String, yaml_serde::Value> =
691            yaml_serde::from_str("outer:\n  inner:\n    key: value\n").unwrap();
692
693        assert_eq!(
694            workspace_yaml_value(&raw, "outer.inner.key").and_then(|v| v.as_str()),
695            Some("value")
696        );
697        assert!(workspace_yaml_value(&raw, "outer.missing.key").is_none());
698    }
699
700    #[test]
701    fn resolves_auto_install_peers_kebab_case() {
702        let e = entries(&[("auto-install-peers", "false")]);
703        assert_eq!(bool_from_npmrc("autoInstallPeers", &e), Some(false));
704    }
705
706    #[test]
707    fn resolves_auto_install_peers_camel_case() {
708        // settings.toml lists both spellings in sources.npmrc.
709        let e = entries(&[("autoInstallPeers", "true")]);
710        assert_eq!(bool_from_npmrc("autoInstallPeers", &e), Some(true));
711    }
712
713    #[test]
714    fn resolves_package_manager_strict_kebab_case() {
715        // pnpm's `.npmrc` convention is kebab-case. Real-world yarn/npm
716        // projects that want to bypass the guardrail need the kebab
717        // spelling to work. `packageManagerStrict` is a tri-state
718        // (`off` | `warn` | `error`) with bool spellings accepted for
719        // back-compat, so the accessor returns a raw string.
720        let e = entries(&[("package-manager-strict", "false")]);
721        assert_eq!(
722            string_from_npmrc("packageManagerStrict", &e),
723            Some("false".to_string())
724        );
725    }
726
727    #[test]
728    fn resolves_package_manager_strict_camel_case() {
729        let e = entries(&[("packageManagerStrict", "warn")]);
730        assert_eq!(
731            string_from_npmrc("packageManagerStrict", &e),
732            Some("warn".to_string())
733        );
734    }
735
736    #[test]
737    fn resolves_package_manager_strict_version_kebab_case() {
738        let e = entries(&[("package-manager-strict-version", "true")]);
739        assert_eq!(
740            bool_from_npmrc("packageManagerStrictVersion", &e),
741            Some(true)
742        );
743    }
744
745    #[test]
746    fn resolves_git_shallow_hosts_kebab_case() {
747        // pnpm's `.npmrc` convention is kebab-case; settings.toml
748        // must list both spellings so projects copied from a pnpm
749        // setup keep working without a rename.
750        let e = entries(&[("git-shallow-hosts", "[example.invalid, other.test]")]);
751        assert_eq!(
752            string_list_from_npmrc("gitShallowHosts", &e),
753            Some(vec![
754                "example.invalid".to_string(),
755                "other.test".to_string(),
756            ])
757        );
758    }
759
760    #[test]
761    fn resolves_git_shallow_hosts_camel_case() {
762        let e = entries(&[("gitShallowHosts", "example.invalid")]);
763        assert_eq!(
764            string_list_from_npmrc("gitShallowHosts", &e),
765            Some(vec!["example.invalid".to_string()])
766        );
767    }
768
769    #[test]
770    fn returns_none_when_no_key_matches() {
771        let e = entries(&[("registry", "https://x.test/")]);
772        assert_eq!(bool_from_npmrc("autoInstallPeers", &e), None);
773    }
774
775    #[test]
776    fn returns_none_for_unknown_setting() {
777        let e = entries(&[("auto-install-peers", "false")]);
778        assert_eq!(
779            bool_from_npmrc("totally-fake-setting", &e),
780            None,
781            "unknown setting must return None without crashing"
782        );
783    }
784
785    #[test]
786    fn parses_numeric_shell_booleans() {
787        assert_eq!(
788            bool_from_npmrc("autoInstallPeers", &entries(&[("auto-install-peers", "1")])),
789            Some(true)
790        );
791        assert_eq!(
792            bool_from_npmrc("autoInstallPeers", &entries(&[("auto-install-peers", "0")])),
793            Some(false)
794        );
795    }
796
797    #[test]
798    fn later_entries_win_over_earlier_ones() {
799        // user .npmrc sets false, project .npmrc overrides to true.
800        // load_npmrc_entries returns user-first then project-later, so
801        // iterating from the end gives the project value.
802        let e = entries(&[
803            ("auto-install-peers", "false"),
804            ("auto-install-peers", "true"),
805        ]);
806        assert_eq!(bool_from_npmrc("autoInstallPeers", &e), Some(true));
807    }
808
809    #[test]
810    fn ignores_unparseable_value_and_falls_back() {
811        // A garbage value should not poison the lookup — we should
812        // fall through to an earlier valid entry.
813        let e = entries(&[
814            ("auto-install-peers", "false"),
815            ("auto-install-peers", "maybe"),
816        ]);
817        assert_eq!(bool_from_npmrc("autoInstallPeers", &e), Some(false));
818    }
819
820    fn raw_yaml(src: &str) -> std::collections::BTreeMap<String, yaml_serde::Value> {
821        yaml_serde::from_str(src).expect("test fixture is valid yaml")
822    }
823
824    #[test]
825    fn workspace_yaml_resolves_bool_field() {
826        let m = raw_yaml("autoInstallPeers: false\n");
827        assert_eq!(
828            bool_from_workspace_yaml("autoInstallPeers", &m),
829            Some(false)
830        );
831    }
832
833    #[test]
834    fn workspace_yaml_returns_none_when_absent() {
835        let m = raw_yaml("packages:\n  - 'pkgs/*'\n");
836        assert_eq!(bool_from_workspace_yaml("autoInstallPeers", &m), None);
837    }
838
839    #[test]
840    fn workspace_yaml_accepts_stringified_bool() {
841        // YAML normally parses bare `true`/`false` as booleans, but a
842        // quoted string should still resolve via `parse_bool`.
843        let m = raw_yaml("autoInstallPeers: \"false\"\n");
844        assert_eq!(
845            bool_from_workspace_yaml("autoInstallPeers", &m),
846            Some(false)
847        );
848    }
849
850    #[test]
851    fn workspace_yaml_ignores_non_bool_setting() {
852        // storeDir is a string setting — the bool helper refuses it.
853        let m = raw_yaml("storeDir: /tmp/x\n");
854        assert_eq!(bool_from_workspace_yaml("storeDir", &m), None);
855    }
856
857    #[test]
858    fn workspace_yaml_resolves_string_field() {
859        let m = raw_yaml("storeDir: /tmp/my-store\n");
860        assert_eq!(
861            string_from_workspace_yaml("storeDir", &m),
862            Some("/tmp/my-store".to_string())
863        );
864    }
865
866    #[test]
867    fn workspace_yaml_string_ignores_bool_setting() {
868        let m = raw_yaml("autoInstallPeers: false\n");
869        assert_eq!(string_from_workspace_yaml("autoInstallPeers", &m), None);
870    }
871
872    #[test]
873    fn workspace_yaml_resolves_nested_string_list_field() {
874        let m = raw_yaml("updateConfig:\n  ignoreDependencies:\n    - is-odd\n    - is-even\n");
875        assert_eq!(
876            string_list_from_workspace_yaml("updateConfig.ignoreDependencies", &m),
877            Some(vec!["is-odd".to_string(), "is-even".to_string()])
878        );
879    }
880
881    #[test]
882    fn generated_accessor_walks_npmrc_then_workspace_yaml() {
883        // `.npmrc` wins over workspace.yaml.
884        let npmrc = entries(&[("auto-install-peers", "false")]);
885        let ws = raw_yaml("autoInstallPeers: true\n");
886        let ctx = ResolveCtx::files_only(&npmrc, &ws);
887        assert!(!resolved::auto_install_peers(&ctx));
888    }
889
890    #[test]
891    fn generated_accessor_falls_through_to_workspace_yaml() {
892        let npmrc: Vec<(String, String)> = Vec::new();
893        let ws = raw_yaml("autoInstallPeers: false\n");
894        let ctx = ResolveCtx::files_only(&npmrc, &ws);
895        assert!(!resolved::auto_install_peers(&ctx));
896    }
897
898    #[test]
899    fn generated_accessor_returns_declared_default_when_no_source_matches() {
900        let npmrc: Vec<(String, String)> = Vec::new();
901        let ws: std::collections::BTreeMap<String, yaml_serde::Value> =
902            std::collections::BTreeMap::new();
903        let ctx = ResolveCtx::files_only(&npmrc, &ws);
904        assert!(resolved::auto_install_peers(&ctx));
905    }
906
907    #[test]
908    fn env_resolves_auto_install_peers_via_declared_aliases() {
909        // `settings.toml` declares both npm-compatible env spellings.
910        // This test guards that the metadata-driven env resolver honors
911        // them without any generated alias synthesis.
912        let env_lower = vec![(
913            "npm_config_auto_install_peers".to_string(),
914            "false".to_string(),
915        )];
916        assert_eq!(bool_from_env("autoInstallPeers", &env_lower), Some(false));
917        let env_upper = vec![(
918            "NPM_CONFIG_AUTO_INSTALL_PEERS".to_string(),
919            "true".to_string(),
920        )];
921        assert_eq!(bool_from_env("autoInstallPeers", &env_upper), Some(true));
922    }
923
924    #[test]
925    fn cli_bag_resolves_resolution_mode_string() {
926        // `resolutionMode` is a quoted-union (string) setting with a
927        // `sources.cli = ["resolution-mode"]` declaration.
928        let cli = vec![("resolution-mode".to_string(), "time-based".to_string())];
929        assert_eq!(
930            string_from_cli("resolutionMode", &cli),
931            Some("time-based".to_string())
932        );
933    }
934
935    #[test]
936    fn cli_bag_matches_canonical_name_for_settings_without_declared_cli_alias() {
937        // `strictDepBuilds` declares `sources.cli = []`, but generic
938        // `--config.<key>` overrides should still reach it via the
939        // canonical name in any reasonable case form.
940        let kebab = vec![("strict-dep-builds".to_string(), "true".to_string())];
941        assert_eq!(bool_from_cli("strictDepBuilds", &kebab), Some(true));
942
943        let camel = vec![("strictDepBuilds".to_string(), "true".to_string())];
944        assert_eq!(bool_from_cli("strictDepBuilds", &camel), Some(true));
945
946        let screaming = vec![("STRICT_DEP_BUILDS".to_string(), "false".to_string())];
947        assert_eq!(bool_from_cli("strictDepBuilds", &screaming), Some(false));
948    }
949
950    #[test]
951    fn cli_bag_keeps_existing_alias_match_for_declared_settings() {
952        // `verifyStoreIntegrity` declares `sources.cli = ["verify-store-integrity"]`.
953        // The exact alias must keep working unchanged.
954        let cli = vec![("verify-store-integrity".to_string(), "true".to_string())];
955        assert_eq!(bool_from_cli("verifyStoreIntegrity", &cli), Some(true));
956    }
957
958    #[test]
959    fn cli_bag_falls_through_unparseable_values_to_earlier_valid_entry() {
960        // Regression: an unparseable `--config.<key>=garbage` must not
961        // mask an earlier valid entry for the same setting. Iteration
962        // is reverse, so the later (garbage) entry is visited first;
963        // the helper has to keep scanning rather than commit to it.
964        let cli = vec![
965            ("strictDepBuilds".to_string(), "true".to_string()),
966            ("strictDepBuilds".to_string(), "notabool".to_string()),
967        ];
968        assert_eq!(bool_from_cli("strictDepBuilds", &cli), Some(true));
969
970        let cli = vec![
971            ("network-concurrency".to_string(), "8".to_string()),
972            ("network-concurrency".to_string(), "garbage".to_string()),
973        ];
974        assert_eq!(u64_from_cli("networkConcurrency", &cli), Some(8));
975    }
976
977    #[test]
978    fn cli_beats_env_beats_npmrc_beats_workspace_yaml() {
979        // CLI and env always win over file sources. This test hits
980        // every layer (cli, env, project npmrc, workspace yaml) by
981        // setting a unique value at each and asserting the generated
982        // accessor returns the CLI value.
983        let npmrc = entries(&[("auto-install-peers", "false")]);
984        let ws = raw_yaml("autoInstallPeers: false\n");
985        let env = vec![(
986            "npm_config_auto_install_peers".to_string(),
987            "false".to_string(),
988        )];
989        let cli = vec![("auto-install-peers".to_string(), "true".to_string())];
990        let ctx = ResolveCtx {
991            project_aube_config: &[],
992            project_npmrc: &npmrc,
993            user_aube_config: &[],
994            user_npmrc: &[],
995            workspace_yaml: &ws,
996            env: &env,
997            cli: &cli,
998            embedder_defaults: &[],
999        };
1000        assert!(resolved::auto_install_peers(&ctx));
1001    }
1002
1003    #[test]
1004    fn env_wins_over_file_sources_when_cli_empty() {
1005        let npmrc = entries(&[("auto-install-peers", "false")]);
1006        let aube_config = entries(&[("autoInstallPeers", "false")]);
1007        let ws = raw_yaml("autoInstallPeers: false\n");
1008        let env = vec![(
1009            "npm_config_auto_install_peers".to_string(),
1010            "true".to_string(),
1011        )];
1012        let ctx = ResolveCtx {
1013            project_aube_config: &aube_config,
1014            project_npmrc: &npmrc,
1015            user_aube_config: &aube_config,
1016            user_npmrc: &npmrc,
1017            workspace_yaml: &ws,
1018            env: &env,
1019            cli: &[],
1020            embedder_defaults: &[],
1021        };
1022        assert!(resolved::auto_install_peers(&ctx));
1023    }
1024
1025    #[test]
1026    fn minimum_release_age_honors_per_setting_precedence_override() {
1027        // `minimumReleaseAge` overrides the default file precedence to
1028        // `["workspaceYaml", "npmrc"]`. With `aubeConfig` appended at
1029        // the tail, the effective order is workspaceYaml > npmrc >
1030        // aubeConfig — workspace YAML wins when present, and
1031        // `config.toml` is consulted only as a last resort.
1032        let aube_config = entries(&[("minimumReleaseAge", "2880")]);
1033        let ws = raw_yaml("minimumReleaseAge: 1440\n");
1034        let ctx = ResolveCtx {
1035            project_aube_config: &[],
1036            project_npmrc: &[],
1037            user_aube_config: &aube_config,
1038            user_npmrc: &[],
1039            workspace_yaml: &ws,
1040            env: &[],
1041            cli: &[],
1042            embedder_defaults: &[],
1043        };
1044        assert_eq!(resolved::minimum_release_age(&ctx), 1440);
1045
1046        let ws = BTreeMap::new();
1047        let ctx = ResolveCtx {
1048            project_aube_config: &[],
1049            project_npmrc: &[],
1050            user_aube_config: &aube_config,
1051            user_npmrc: &[],
1052            workspace_yaml: &ws,
1053            env: &[],
1054            cli: &[],
1055            embedder_defaults: &[],
1056        };
1057        assert_eq!(resolved::minimum_release_age(&ctx), 2880);
1058    }
1059
1060    #[test]
1061    fn user_aube_config_wins_over_user_npmrc_by_default() {
1062        // Within user-scope, `~/.config/aube/config.toml` outranks
1063        // `~/.npmrc` so values aube wrote via `aube config set` are
1064        // authoritative — a leftover entry in `~/.npmrc` (which other
1065        // tools like npm/pnpm/yarn also read) does not silently shadow
1066        // them. `autoInstallPeers` has no per-setting precedence
1067        // override, so it follows the default.
1068        let user_npmrc = entries(&[("auto-install-peers", "false")]);
1069        let user_aube_config = entries(&[("autoInstallPeers", "true")]);
1070        let ws = BTreeMap::new();
1071        let ctx = ResolveCtx {
1072            project_aube_config: &[],
1073            project_npmrc: &[],
1074            user_aube_config: &user_aube_config,
1075            user_npmrc: &user_npmrc,
1076            workspace_yaml: &ws,
1077            env: &[],
1078            cli: &[],
1079            embedder_defaults: &[],
1080        };
1081        assert!(
1082            resolved::auto_install_peers(&ctx),
1083            "user aube_config=true should win over user npmrc=false"
1084        );
1085    }
1086
1087    #[test]
1088    fn project_npmrc_wins_over_user_aube_config_by_default() {
1089        // Locality principle: a project `.npmrc` outranks user-scope
1090        // `~/.config/aube/config.toml`. A repo-specific override should
1091        // not be silently shadowed by a user-level aube preference.
1092        let project_npmrc = entries(&[("auto-install-peers", "false")]);
1093        let user_aube_config = entries(&[("autoInstallPeers", "true")]);
1094        let ws = BTreeMap::new();
1095        let ctx = ResolveCtx {
1096            project_aube_config: &[],
1097            project_npmrc: &project_npmrc,
1098            user_aube_config: &user_aube_config,
1099            user_npmrc: &[],
1100            workspace_yaml: &ws,
1101            env: &[],
1102            cli: &[],
1103            embedder_defaults: &[],
1104        };
1105        assert!(
1106            !resolved::auto_install_peers(&ctx),
1107            "project npmrc=false should win over user aube_config=true"
1108        );
1109    }
1110
1111    #[test]
1112    fn project_aube_config_wins_over_project_npmrc_by_default() {
1113        // Within project-scope, `<cwd>/.config/aube/config.toml`
1114        // outranks `<cwd>/.npmrc` — same authority principle as the
1115        // user-scope pair.
1116        let project_npmrc = entries(&[("auto-install-peers", "false")]);
1117        let project_aube_config = entries(&[("autoInstallPeers", "true")]);
1118        let ws = BTreeMap::new();
1119        let ctx = ResolveCtx {
1120            project_aube_config: &project_aube_config,
1121            project_npmrc: &project_npmrc,
1122            user_aube_config: &[],
1123            user_npmrc: &[],
1124            workspace_yaml: &ws,
1125            env: &[],
1126            cli: &[],
1127            embedder_defaults: &[],
1128        };
1129        assert!(
1130            resolved::auto_install_peers(&ctx),
1131            "project aube_config=true should win over project npmrc=false"
1132        );
1133    }
1134
1135    #[test]
1136    fn workspace_yaml_wins_over_user_sources_by_default() {
1137        // `pnpm-workspace.yaml` / `aube-workspace.yaml` live at the
1138        // project root, so by the scope-locality principle they must
1139        // outrank both user `.npmrc` and user `config.toml`. Without
1140        // this, project-scope writes routed to the workspace yaml
1141        // would be silently shadowed by anything the user has at
1142        // `~/.config/aube/config.toml` or `~/.npmrc`.
1143        let user_npmrc = entries(&[("auto-install-peers", "true")]);
1144        let user_aube_config = entries(&[("autoInstallPeers", "true")]);
1145        let ws = raw_yaml("autoInstallPeers: false\n");
1146        let ctx = ResolveCtx {
1147            project_aube_config: &[],
1148            project_npmrc: &[],
1149            user_aube_config: &user_aube_config,
1150            user_npmrc: &user_npmrc,
1151            workspace_yaml: &ws,
1152            env: &[],
1153            cli: &[],
1154            embedder_defaults: &[],
1155        };
1156        assert!(
1157            !resolved::auto_install_peers(&ctx),
1158            "workspace yaml should win over user-scope sources"
1159        );
1160    }
1161
1162    #[test]
1163    fn env_alias_order_defines_priority() {
1164        let env = entries(&[
1165            ("CI", "true"),
1166            ("NPM_CONFIG_CI", "false"),
1167            ("npm_config_no_proxy", ".internal"),
1168        ]);
1169        assert_eq!(bool_from_env("ci", &env), Some(true));
1170        assert_eq!(
1171            string_from_env("noProxy", &env),
1172            Some(".internal".to_string())
1173        );
1174    }
1175
1176    #[test]
1177    fn generated_enum_accessor_returns_typed_variant() {
1178        // `resolutionMode` is an enum-style union with a concrete
1179        // default. The generator should emit `resolved::ResolutionMode`
1180        // and a non-optional accessor instead of the old `Option<String>`.
1181        // Callers match on the variant rather than hand-parsing the raw
1182        // string.
1183        let npmrc = entries(&[("resolutionMode", "time-based")]);
1184        let ws: std::collections::BTreeMap<String, yaml_serde::Value> =
1185            std::collections::BTreeMap::new();
1186        let ctx = ResolveCtx::files_only(&npmrc, &ws);
1187        assert_eq!(
1188            resolved::resolution_mode(&ctx),
1189            resolved::ResolutionMode::TimeBased
1190        );
1191    }
1192
1193    #[test]
1194    fn generated_enum_accessor_uses_default_for_unknown_variant() {
1195        // An unrecognized value should not pollute the result — the
1196        // accessor falls back to the declared default when it has one.
1197        let npmrc = entries(&[("nodeLinker", "totally-fake")]);
1198        let ws: std::collections::BTreeMap<String, yaml_serde::Value> =
1199            std::collections::BTreeMap::new();
1200        let ctx = ResolveCtx::files_only(&npmrc, &ws);
1201        assert_eq!(resolved::node_linker(&ctx), resolved::NodeLinker::Isolated);
1202    }
1203
1204    #[test]
1205    fn generated_enum_accessor_preserves_strict_precedence_on_unknown_value() {
1206        // Regression: an unrecognized value in a higher-precedence
1207        // source must NOT fall through to a lower-precedence source.
1208        // The generator used to apply `from_str_normalized` per-source
1209        // via `.and_then`, which silently skipped the typo and let the
1210        // lower source win — a strict precedence violation.
1211        let npmrc = entries(&[("nodeLinker", "totally-fake")]);
1212        let ws = raw_yaml("nodeLinker: hoisted\n");
1213        let ctx = ResolveCtx::files_only(&npmrc, &ws);
1214        assert_eq!(
1215            resolved::node_linker(&ctx),
1216            resolved::NodeLinker::Isolated,
1217            ".npmrc had a raw value, even if unparseable — it must win \
1218             over pnpm-workspace.yaml and fall back to the generated \
1219             default"
1220        );
1221    }
1222
1223    #[test]
1224    fn generated_enum_accessor_is_case_insensitive() {
1225        // pnpm normalizes enum values before matching; the generated
1226        // `from_str_normalized` mirrors that.
1227        let npmrc = entries(&[("nodeLinker", "Hoisted")]);
1228        let ws: std::collections::BTreeMap<String, yaml_serde::Value> =
1229            std::collections::BTreeMap::new();
1230        let ctx = ResolveCtx::files_only(&npmrc, &ws);
1231        assert_eq!(resolved::node_linker(&ctx), resolved::NodeLinker::Hoisted);
1232    }
1233
1234    #[test]
1235    fn generated_enum_accessor_reads_kebab_case_npmrc_alias() {
1236        // pnpm's `.npmrc` docs use `node-linker=hoisted` (kebab-case).
1237        // aube must accept it alongside the camelCase `nodeLinker` form —
1238        // otherwise the setting is silently ignored for anyone copying
1239        // from pnpm docs.
1240        let npmrc = entries(&[("node-linker", "hoisted")]);
1241        let ws: std::collections::BTreeMap<String, yaml_serde::Value> =
1242            std::collections::BTreeMap::new();
1243        let ctx = ResolveCtx::files_only(&npmrc, &ws);
1244        assert_eq!(resolved::node_linker(&ctx), resolved::NodeLinker::Hoisted);
1245    }
1246
1247    #[test]
1248    fn link_workspace_packages_accepts_deep_from_workspace_yaml() {
1249        let npmrc: Vec<(String, String)> = Vec::new();
1250        let ws = raw_yaml("linkWorkspacePackages: deep\n");
1251        let ctx = ResolveCtx::files_only(&npmrc, &ws);
1252        assert_eq!(
1253            resolved::link_workspace_packages(&ctx),
1254            resolved::LinkWorkspacePackages::Deep
1255        );
1256    }
1257
1258    #[test]
1259    fn link_workspace_packages_accepts_yaml_bool_values() {
1260        let npmrc: Vec<(String, String)> = Vec::new();
1261        let ws = raw_yaml("linkWorkspacePackages: true\n");
1262        let ctx = ResolveCtx::files_only(&npmrc, &ws);
1263        assert_eq!(
1264            resolved::link_workspace_packages(&ctx),
1265            resolved::LinkWorkspacePackages::True
1266        );
1267    }
1268
1269    #[test]
1270    fn link_workspace_packages_accepts_deep_from_npmrc() {
1271        let npmrc = entries(&[("link-workspace-packages", "deep")]);
1272        let ws: std::collections::BTreeMap<String, yaml_serde::Value> =
1273            std::collections::BTreeMap::new();
1274        let ctx = ResolveCtx::files_only(&npmrc, &ws);
1275        assert_eq!(
1276            resolved::link_workspace_packages(&ctx),
1277            resolved::LinkWorkspacePackages::Deep
1278        );
1279    }
1280
1281    #[test]
1282    fn npmrc_accepts_kebab_alias_for_camel_only_setting() {
1283        // `virtualStoreDirMaxLength` is declared in settings.toml
1284        // with the single npmrc key `virtualStoreDirMaxLength`. The
1285        // generator must auto-synthesize the kebab alias
1286        // `virtual-store-dir-max-length` so users copying from pnpm's
1287        // `.npmrc` docs get the expected behaviour.
1288        let npmrc = entries(&[("virtual-store-dir-max-length", "40")]);
1289        let ws: std::collections::BTreeMap<String, yaml_serde::Value> =
1290            std::collections::BTreeMap::new();
1291        let ctx = ResolveCtx::files_only(&npmrc, &ws);
1292        assert_eq!(resolved::virtual_store_dir_max_length(&ctx), Some(40));
1293    }
1294
1295    #[test]
1296    fn npmrc_accepts_camel_alias_for_kebab_only_setting() {
1297        // Mirror case: `prefer-frozen-lockfile` was declared only in
1298        // kebab form, so authors writing `preferFrozenLockfile` in
1299        // `.npmrc` (the pnpm-workspace.yaml spelling) were silently
1300        // ignored. Auto-synth fills in the camelCase alias too.
1301        let npmrc = entries(&[("preferFrozenLockfile", "false")]);
1302        let ws: std::collections::BTreeMap<String, yaml_serde::Value> =
1303            std::collections::BTreeMap::new();
1304        let ctx = ResolveCtx::files_only(&npmrc, &ws);
1305        assert_eq!(resolved::prefer_frozen_lockfile(&ctx), Some(false));
1306    }
1307
1308    #[test]
1309    fn generated_string_accessor_reads_workspace_yaml() {
1310        // `storeDir` is a string setting with a workspaceYaml source.
1311        // Before the generator learned about `string_from_workspace_yaml`,
1312        // this returned `None` — the test guards the fix.
1313        let npmrc: Vec<(String, String)> = Vec::new();
1314        let ws = raw_yaml("storeDir: /tmp/from-ws\n");
1315        let ctx = ResolveCtx::files_only(&npmrc, &ws);
1316        assert_eq!(resolved::store_dir(&ctx), Some("/tmp/from-ws".to_string()));
1317    }
1318}