Skip to main content

aube_manifest/
lib.rs

1pub mod dev_engines;
2pub mod workspace;
3
4pub use dev_engines::{DevEngineDependency, DevEngines, OnFail, dev_engines_tolerant};
5pub use workspace::{JailBuildPermission, WorkspaceConfig};
6
7use serde::{Deserialize, Deserializer, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::{Path, PathBuf};
10
11/// Deserialize `engines` tolerant to legacy non-map forms, e.g.
12/// `extsprintf@1.4.1` ships `"engines": ["node >=0.6.0"]` and some
13/// old packument entries (such as `qs`) ship a bare string.
14/// Modern npm ignores that shape (engine-strict only consults the map
15/// form), so normalize to an empty map rather than failing the whole
16/// manifest — a hard error there takes down every install that touches
17/// one of these ancient packages, even when the user's target engine
18/// wouldn't have matched any constraint anyway.
19///
20/// An explicit `null` is also tolerated (same as "field absent"),
21/// matching the tolerance our other dep-map parsers apply.
22///
23/// Exposed (`pub`) so the lockfile parser can apply the same tolerance
24/// — npm v2/v3 lockfiles preserve the array shape verbatim from the
25/// originating `package.json`, so a strict map-only deserializer there
26/// trips on the same ancient packages and blocks `aube ci` outright.
27pub fn engines_tolerant<'de, D>(de: D) -> Result<BTreeMap<String, String>, D::Error>
28where
29    D: Deserializer<'de>,
30{
31    let value: Option<serde_json::Value> = Option::deserialize(de)?;
32    Ok(match value {
33        None
34        | Some(serde_json::Value::Null)
35        | Some(serde_json::Value::Array(_))
36        | Some(serde_json::Value::String(_)) => BTreeMap::new(),
37        Some(serde_json::Value::Object(m)) => m
38            .into_iter()
39            .filter_map(|(k, v)| match v {
40                serde_json::Value::String(s) => Some((k, s)),
41                _ => None,
42            })
43            .collect(),
44        Some(other) => {
45            // Null / Array / String / Object are handled above, so
46            // `other` can only be another scalar here.
47            return Err(serde::de::Error::custom(format!(
48                "engines: expected a map, got {}",
49                match other {
50                    serde_json::Value::Number(_) => "number",
51                    serde_json::Value::Bool(_) => "boolean",
52                    _ => unreachable!("engines: unexpected value variant"),
53                }
54            )));
55        }
56    })
57}
58
59/// Deserialize `scripts` tolerant to non-string values. `firefox-profile`
60/// (and a handful of other legacy packages) ships junk like
61/// `"scripts": { "blanket": { "pattern": [...] } }` — tool-specific
62/// config that npm's CLI treats as "not a runnable script" and ignores.
63/// A strict `Record<string, string>` deserialization trips on the object
64/// entry and fails the whole install. Drop non-string entries so the
65/// real scripts still round-trip.
66pub fn scripts_tolerant<'de, D>(de: D) -> Result<BTreeMap<String, String>, D::Error>
67where
68    D: Deserializer<'de>,
69{
70    let value: Option<serde_json::Value> = Option::deserialize(de)?;
71    Ok(match value {
72        None | Some(serde_json::Value::Null) => BTreeMap::new(),
73        Some(serde_json::Value::Object(m)) => m
74            .into_iter()
75            .filter_map(|(k, v)| match v {
76                serde_json::Value::String(s) => Some((k, s)),
77                _ => None,
78            })
79            .collect(),
80        Some(_) => BTreeMap::new(),
81    })
82}
83
84/// Tolerant dep-map deserializer. Same shape as scripts_tolerant
85/// but used for dependencies / devDependencies / peerDependencies /
86/// optionalDependencies. Real world manifests written by tools
87/// sometimes emit `"peerDependencies": null` when a package has
88/// none, and strict Record<string, string> deserialization rejects
89/// that. npm and pnpm both tolerate null. Drop non-string values
90/// (numbers, arrays, objects) silently since nothing sensible maps
91/// those to a version range.
92pub fn deps_tolerant<'de, D>(de: D) -> Result<BTreeMap<String, String>, D::Error>
93where
94    D: Deserializer<'de>,
95{
96    let value: Option<serde_json::Value> = Option::deserialize(de)?;
97    Ok(match value {
98        None | Some(serde_json::Value::Null) => BTreeMap::new(),
99        Some(serde_json::Value::Object(m)) => m
100            .into_iter()
101            .filter_map(|(k, v)| match v {
102                serde_json::Value::String(s) => Some((k, s)),
103                _ => None,
104            })
105            .collect(),
106        Some(_) => BTreeMap::new(),
107    })
108}
109
110#[derive(Debug, Clone, Default, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct UpdateConfig {
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub ignore_dependencies: Vec<String>,
115}
116
117/// Parsed `package.json`.
118///
119/// Deserializes via [`PackageJsonRaw`] (`#[serde(from = ...)]`) so a
120/// manifest carrying *both* `bundledDependencies` and the deprecated
121/// `bundleDependencies` alias parses without tripping serde's duplicate
122/// field check. Some real-world publishes (e.g. `@lingui/message-utils`)
123/// ship both.
124#[derive(Debug, Clone, Default, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase", from = "PackageJsonRaw")]
126pub struct PackageJson {
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub name: Option<String>,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub version: Option<String>,
131    #[serde(
132        default,
133        deserialize_with = "deps_tolerant",
134        skip_serializing_if = "BTreeMap::is_empty"
135    )]
136    pub dependencies: BTreeMap<String, String>,
137    #[serde(
138        default,
139        deserialize_with = "deps_tolerant",
140        skip_serializing_if = "BTreeMap::is_empty"
141    )]
142    pub dev_dependencies: BTreeMap<String, String>,
143    #[serde(
144        default,
145        deserialize_with = "deps_tolerant",
146        skip_serializing_if = "BTreeMap::is_empty"
147    )]
148    pub peer_dependencies: BTreeMap<String, String>,
149    #[serde(
150        default,
151        deserialize_with = "deps_tolerant",
152        skip_serializing_if = "BTreeMap::is_empty"
153    )]
154    pub optional_dependencies: BTreeMap<String, String>,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub update_config: Option<UpdateConfig>,
157    /// `bundledDependencies` from package.json. Names listed here are
158    /// shipped *inside* the package tarball itself, under the package's
159    /// own `node_modules/`. The resolver must not recurse into them, and
160    /// Node's directory walk serves them straight out of the extracted
161    /// tree. On deserialize we also accept the deprecated
162    /// `bundleDependencies` spelling and prefer the canonical when both
163    /// are present (handled in [`PackageJsonRaw`]).
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub bundled_dependencies: Option<BundledDependencies>,
166    #[serde(
167        default,
168        deserialize_with = "scripts_tolerant",
169        skip_serializing_if = "BTreeMap::is_empty"
170    )]
171    pub scripts: BTreeMap<String, String>,
172    /// `engines` field — declared runtime version constraints, e.g.
173    /// `{"node": ">=18.0.0"}`. Checked against the current runtime during
174    /// `aube install`; a mismatch warns by default and fails under
175    /// `engine-strict`. See `engines_tolerant` for the legacy-shape
176    /// handling.
177    #[serde(
178        default,
179        deserialize_with = "engines_tolerant",
180        skip_serializing_if = "BTreeMap::is_empty"
181    )]
182    pub engines: BTreeMap<String, String>,
183    /// `devEngines` field — development-environment requirements per
184    /// the OpenJS spec. aube acts on `devEngines.runtime` entries named
185    /// `node` (version switching); see [`dev_engines`] for the shape
186    /// and tolerance rules.
187    #[serde(
188        default,
189        deserialize_with = "dev_engines_tolerant",
190        skip_serializing_if = "Option::is_none"
191    )]
192    pub dev_engines: Option<DevEngines>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub workspaces: Option<Workspaces>,
195    #[serde(flatten)]
196    pub extra: BTreeMap<String, serde_json::Value>,
197}
198
199/// Deserialize-only mirror of [`PackageJson`] that splits the
200/// `bundled_dependencies` field into two name-distinct slots so a
201/// manifest carrying *both* `bundledDependencies` and `bundleDependencies`
202/// doesn't trip serde's duplicate field check the way `#[serde(alias)]`
203/// does. The canonical spelling wins on merge.
204///
205/// **Maintenance invariant:** every non-`bundled_dependencies` field
206/// here must mirror its counterpart on [`PackageJson`] *byte-for-byte*
207/// in serde attributes (`rename`, `deserialize_with`, `default`,
208/// `flatten`, etc.). The `From` impl below catches missing fields at
209/// compile time, but **attribute drift is silent** — e.g. forgetting
210/// `deserialize_with = "deps_tolerant"` here would make the deserialize
211/// path strict on dep-map shapes the public type silently tolerates.
212/// When adding or modifying a field on `PackageJson`, update this
213/// struct in lockstep.
214#[derive(Debug, Default, Deserialize)]
215#[serde(rename_all = "camelCase")]
216struct PackageJsonRaw {
217    name: Option<String>,
218    version: Option<String>,
219    #[serde(default, deserialize_with = "deps_tolerant")]
220    dependencies: BTreeMap<String, String>,
221    #[serde(default, deserialize_with = "deps_tolerant")]
222    dev_dependencies: BTreeMap<String, String>,
223    #[serde(default, deserialize_with = "deps_tolerant")]
224    peer_dependencies: BTreeMap<String, String>,
225    #[serde(default, deserialize_with = "deps_tolerant")]
226    optional_dependencies: BTreeMap<String, String>,
227    #[serde(default)]
228    update_config: Option<UpdateConfig>,
229    #[serde(default, rename = "bundledDependencies")]
230    bundled_dependencies: Option<BundledDependencies>,
231    #[serde(default, rename = "bundleDependencies")]
232    bundle_dependencies_alias: Option<BundledDependencies>,
233    #[serde(default, deserialize_with = "scripts_tolerant")]
234    scripts: BTreeMap<String, String>,
235    #[serde(default, deserialize_with = "engines_tolerant")]
236    engines: BTreeMap<String, String>,
237    #[serde(default, deserialize_with = "dev_engines_tolerant")]
238    dev_engines: Option<DevEngines>,
239    #[serde(default)]
240    workspaces: Option<Workspaces>,
241    #[serde(flatten)]
242    extra: BTreeMap<String, serde_json::Value>,
243}
244
245impl From<PackageJsonRaw> for PackageJson {
246    fn from(raw: PackageJsonRaw) -> Self {
247        Self {
248            name: raw.name,
249            version: raw.version,
250            dependencies: raw.dependencies,
251            dev_dependencies: raw.dev_dependencies,
252            peer_dependencies: raw.peer_dependencies,
253            optional_dependencies: raw.optional_dependencies,
254            update_config: raw.update_config,
255            bundled_dependencies: raw.bundled_dependencies.or(raw.bundle_dependencies_alias),
256            scripts: raw.scripts,
257            engines: raw.engines,
258            dev_engines: raw.dev_engines,
259            workspaces: raw.workspaces,
260            extra: raw.extra,
261        }
262    }
263}
264
265/// `bundledDependencies` shape from package.json. npm/pnpm accept
266/// either an array of dep names or a boolean (`true` meaning "bundle
267/// everything in `dependencies`"). We preserve both so the resolver
268/// can compute the exact name set.
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
270#[serde(untagged)]
271pub enum BundledDependencies {
272    List(Vec<String>),
273    All(bool),
274}
275
276impl BundledDependencies {
277    /// The set of dep names that should be treated as bundled, given
278    /// the package's own `dependencies` map (needed for the `true`
279    /// form, which means "bundle every production dep").
280    pub fn names<'a>(&'a self, dependencies: &'a BTreeMap<String, String>) -> Vec<&'a str> {
281        match self {
282            BundledDependencies::List(v) => v.iter().map(String::as_str).collect(),
283            BundledDependencies::All(true) => dependencies.keys().map(String::as_str).collect(),
284            BundledDependencies::All(false) => Vec::new(),
285        }
286    }
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(untagged)]
291pub enum Workspaces {
292    /// Bare single-pattern form. npm accepts
293    /// `"workspaces": "packages/*"` even though the docs only show
294    /// the array form. Some bun projects in the wild use it too.
295    /// Without this, those manifests fail to parse and user gets a
296    /// cryptic serde error pointing at the string.
297    String(String),
298    Array(Vec<String>),
299    Object {
300        // `packages` stays required (no `#[serde(default)]`) so that a
301        // typo like `"pacakges"` fails deserialization instead of
302        // silently producing an empty vec. Bun's object form always
303        // includes `packages`, so this doesn't lock out the catalog use
304        // case.
305        packages: Vec<String>,
306        #[serde(default)]
307        nohoist: Vec<String>,
308        /// Bun-style default catalog nested under `workspaces.catalog`.
309        /// Aube reads it in addition to `pnpm-workspace.yaml`'s `catalog:`
310        /// so bun projects that migrated config into package.json keep
311        /// working.
312        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
313        catalog: BTreeMap<String, String>,
314        /// Bun-style named catalogs nested under `workspaces.catalogs`.
315        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
316        catalogs: BTreeMap<String, BTreeMap<String, String>>,
317    },
318}
319
320impl Workspaces {
321    pub fn patterns(&self) -> &[String] {
322        match self {
323            Workspaces::String(s) => std::slice::from_ref(s),
324            Workspaces::Array(v) => v,
325            Workspaces::Object { packages, .. } => packages,
326        }
327    }
328
329    /// Bun-style default catalog (`workspaces.catalog`). Empty when the
330    /// `workspaces` field is an array or the object form has no catalog.
331    pub fn catalog(&self) -> &BTreeMap<String, String> {
332        static EMPTY: std::sync::OnceLock<BTreeMap<String, String>> = std::sync::OnceLock::new();
333        match self {
334            Workspaces::String(_) | Workspaces::Array(_) => EMPTY.get_or_init(BTreeMap::new),
335            Workspaces::Object { catalog, .. } => catalog,
336        }
337    }
338
339    /// Bun-style named catalogs (`workspaces.catalogs`).
340    pub fn catalogs(&self) -> &BTreeMap<String, BTreeMap<String, String>> {
341        static EMPTY: std::sync::OnceLock<BTreeMap<String, BTreeMap<String, String>>> =
342            std::sync::OnceLock::new();
343        match self {
344            Workspaces::String(_) | Workspaces::Array(_) => EMPTY.get_or_init(BTreeMap::new),
345            Workspaces::Object { catalogs, .. } => catalogs,
346        }
347    }
348}
349
350/// Process-wide cache of parsed `package.json` files keyed by absolute
351/// path. Hit by `aube run` 2-3 times per invocation (prompt path, type
352/// parse, External catch-all). Miss falls through to a fresh read +
353/// parse and inserts into the cache.
354static PACKAGE_JSON_CACHE: aube_util::cache::ProcessCache<PathBuf, PackageJson> =
355    aube_util::cache::ProcessCache::new();
356
357impl PackageJson {
358    pub fn from_path(path: &Path) -> Result<Self, Error> {
359        let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Manifest, "from_path")
360            .with_meta_fn(|| {
361                // Emit only the file name to avoid leaking absolute paths
362                // (and by extension home directory layout / employer-internal
363                // build roots) into traces shared in bug reports.
364                let display = path
365                    .file_name()
366                    .map(|n| n.to_string_lossy().into_owned())
367                    .unwrap_or_else(|| "package.json".to_string());
368                format!(r#"{{"path":{}}}"#, aube_util::diag::jstr(&display))
369            });
370        let content =
371            std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e))?;
372        Self::parse(path, content)
373    }
374
375    /// Cached variant of [`Self::from_path`]. First caller per-path
376    /// pays the read + parse; later callers receive an `Arc` clone.
377    /// Errors are NOT cached (the next caller retries).
378    pub fn from_path_cached(path: &Path) -> Result<std::sync::Arc<Self>, Error> {
379        let key = path.to_path_buf();
380        if let Some(hit) = PACKAGE_JSON_CACHE.get(&key) {
381            return Ok(hit);
382        }
383        let parsed = Self::from_path(path)?;
384        Ok(PACKAGE_JSON_CACHE.get_or_compute(key, || parsed))
385    }
386
387    /// Parse an in-memory `package.json` string. On failure, produces a
388    /// [`Error::Parse`] with the source content and a span so `miette`'s
389    /// `fancy` handler renders a pointer at the offending byte.
390    pub fn parse(path: &Path, content: String) -> Result<Self, Error> {
391        parse_json(path, content)
392    }
393
394    /// Iterate over the `pnpm` and `aube` config objects in
395    /// `package.json`, yielding whichever are present in precedence
396    /// order (pnpm first, aube last). Callers that merge into a map
397    /// with later-wins semantics get `aube.*` overriding `pnpm.*` on
398    /// key conflict; callers that union lists get both sources
399    /// included. Aube mirrors every `pnpm.*` config key under an
400    /// `aube.*` alias so projects can declare aube-native config
401    /// without piggy-backing on the pnpm namespace.
402    fn pnpm_aube_objects(
403        &self,
404    ) -> impl Iterator<Item = &serde_json::Map<String, serde_json::Value>> {
405        // Compatible namespaces first (lower precedence), then this tool's
406        // own namespace (last = wins on key conflict). Standalone aube:
407        // `["pnpm", "aube"]`. An empty `manifest_namespace` (root) carries
408        // no object key and is skipped.
409        let id = aube_util::embedder();
410        let self_ns = (!id.manifest_namespace.is_empty()).then_some(id.manifest_namespace);
411        id.compatible_names
412            .iter()
413            .copied()
414            .chain(self_ns)
415            .filter_map(|k| self.extra.get(k).and_then(|v| v.as_object()))
416    }
417
418    /// Extract the `pnpm.allowBuilds` / `aube.allowBuilds` object from
419    /// the raw `package.json` payload, if present. Returns a map keyed
420    /// by the raw pattern string (e.g. `"esbuild"`,
421    /// `"@swc/core@1.3.0"`) with `bool` values preserved as `bool` and
422    /// any other shape captured verbatim so the caller can warn about
423    /// it. `aube.*` wins over `pnpm.*` on key conflict.
424    ///
425    /// The key is held in `extra` rather than as a named field because
426    /// it's nested under a `pnpm`/`aube` object.
427    pub fn pnpm_allow_builds(&self) -> BTreeMap<String, AllowBuildRaw> {
428        let mut out = BTreeMap::new();
429        for ns in self.pnpm_aube_objects() {
430            if let Some(map) = ns.get("allowBuilds").and_then(|v| v.as_object()) {
431                for (k, v) in map {
432                    out.insert(k.clone(), AllowBuildRaw::from_json(v));
433                }
434            }
435        }
436        out
437    }
438
439    /// Extract `pnpm.onlyBuiltDependencies` / `aube.onlyBuiltDependencies`
440    /// as a flat list of package names allowed to run lifecycle
441    /// scripts. This is pnpm's canonical allowlist key (used by nearly
442    /// every real-world pnpm project) and coexists with `allowBuilds`
443    /// — all sources merge into the same `BuildPolicy`. Non-string
444    /// entries are dropped silently to match pnpm's tolerance for
445    /// malformed configs. Entries from `aube.*` are appended after
446    /// `pnpm.*` and deduped while preserving insertion order.
447    pub fn pnpm_only_built_dependencies(&self) -> Vec<String> {
448        let mut out = Vec::new();
449        for ns in self.pnpm_aube_objects() {
450            if let Some(arr) = ns.get("onlyBuiltDependencies").and_then(|v| v.as_array()) {
451                push_unique_strs(&mut out, arr);
452            }
453        }
454        out
455    }
456
457    /// Extract `pnpm.neverBuiltDependencies` /
458    /// `aube.neverBuiltDependencies` — the canonical denylist for
459    /// lifecycle scripts. Entries override any allowlist match in
460    /// `onlyBuiltDependencies` / `allowBuilds` since explicit denies
461    /// always win in `BuildPolicy::decide`. Entries union across both
462    /// namespaces with insertion order preserved.
463    pub fn pnpm_never_built_dependencies(&self) -> Vec<String> {
464        let mut out = Vec::new();
465        for ns in self.pnpm_aube_objects() {
466            if let Some(arr) = ns.get("neverBuiltDependencies").and_then(|v| v.as_array()) {
467                push_unique_strs(&mut out, arr);
468            }
469        }
470        out
471    }
472
473    /// Extract the top-level `trustedDependencies` array — Bun's
474    /// allowlist for lifecycle scripts. Treated as an additional
475    /// allow-source alongside `pnpm.onlyBuiltDependencies`, so bun
476    /// projects migrating to aube do not have to rewrite their manifest
477    /// to get scripts running. Non-string entries are dropped; a denylist
478    /// match in `neverBuiltDependencies` still wins at `decide()` time.
479    pub fn trusted_dependencies(&self) -> Vec<String> {
480        let mut out = Vec::new();
481        if let Some(arr) = self
482            .extra
483            .get("trustedDependencies")
484            .and_then(|v| v.as_array())
485        {
486            push_unique_strs(&mut out, arr);
487        }
488        out
489    }
490
491    /// pnpm-compatible `npm_package_*` environment pairs for lifecycle
492    /// scripts. Mirrors what pnpm exports from a manifest: `name`,
493    /// `version`, plus the deep-flattened `engines`, `config`, and
494    /// `bin` fields (string `bin` → unsuffixed `npm_package_bin`).
495    /// Other fields (`description`, `main`, `license`, `author`, …)
496    /// are intentionally **not** exported — pnpm dropped whole-manifest
497    /// flattening, and matching its exact allowlist keeps scripts that
498    /// sniff these vars (`husky`, `node-pre-gyp`, …) behaving
499    /// identically under aube.
500    ///
501    /// The `npm_package_json` path is set by the caller, which knows
502    /// where the manifest lives on disk.
503    ///
504    /// Keys are "envified" the npm way: every character outside
505    /// `[A-Za-z0-9_]` becomes `_`, so `config.my-key` →
506    /// `npm_package_config_my_key`. Returned in a stable order
507    /// (name, version, then engines/config/bin in key order) so the
508    /// emitted environment is deterministic.
509    pub fn npm_package_env(&self) -> Vec<(String, String)> {
510        let mut out = Vec::new();
511        if let Some(name) = &self.name {
512            out.push(("npm_package_name".to_string(), name.clone()));
513        }
514        if let Some(version) = &self.version {
515            out.push(("npm_package_version".to_string(), version.clone()));
516        }
517        for (key, value) in &self.engines {
518            out.push((
519                format!("npm_package_engines_{}", envify_env_key(key)),
520                value.clone(),
521            ));
522        }
523        if let Some(config) = self.extra.get("config") {
524            flatten_json_env("npm_package_config", config, &mut out);
525        }
526        if let Some(bin) = self.extra.get("bin") {
527            // Generic flatten, same as `config`: a string `bin` →
528            // unsuffixed `npm_package_bin`, an object → `npm_package_bin_<key>`.
529            // This mirrors pnpm exactly (verified against 11.5); npm instead
530            // normalizes a string `bin` to the package's unscoped name first.
531            flatten_json_env("npm_package_bin", bin, &mut out);
532        }
533        out
534    }
535
536    /// Extract `pnpm.catalog` / `aube.catalog` — a default catalog
537    /// defined inline in package.json under the `pnpm`/`aube` object.
538    /// pnpm itself reads catalogs only from `pnpm-workspace.yaml`, but
539    /// aube also honors this location so single-package projects can
540    /// declare catalogs without maintaining a separate workspace
541    /// file. `aube.catalog` wins over `pnpm.catalog` on key conflict.
542    pub fn pnpm_catalog(&self) -> BTreeMap<String, String> {
543        let mut out = BTreeMap::new();
544        for ns in self.pnpm_aube_objects() {
545            if let Some(map) = ns.get("catalog").and_then(|v| v.as_object()) {
546                for (k, v) in map {
547                    if let Some(s) = v.as_str() {
548                        out.insert(k.clone(), s.to_string());
549                    }
550                }
551            }
552        }
553        out
554    }
555
556    /// Extract `pnpm.catalogs` / `aube.catalogs` — named catalogs
557    /// nested under the `pnpm`/`aube` object. Pairs with
558    /// [`pnpm_catalog`] for a fully-package.json-local catalog
559    /// declaration. Named catalogs merge per-key across namespaces
560    /// (same rule as `pnpm_catalog`): `aube.catalogs.<name>.<pkg>`
561    /// wins over `pnpm.catalogs.<name>.<pkg>`, while entries declared
562    /// only on one side are preserved.
563    pub fn pnpm_catalogs(&self) -> BTreeMap<String, BTreeMap<String, String>> {
564        let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
565        for ns in self.pnpm_aube_objects() {
566            if let Some(outer) = ns.get("catalogs").and_then(|v| v.as_object()) {
567                for (name, inner) in outer {
568                    let Some(inner) = inner.as_object() else {
569                        continue;
570                    };
571                    let catalog = out.entry(name.clone()).or_default();
572                    for (k, v) in inner {
573                        if let Some(s) = v.as_str() {
574                            catalog.insert(k.clone(), s.to_string());
575                        }
576                    }
577                }
578            }
579        }
580        out
581    }
582
583    /// Extract `pnpm.ignoredOptionalDependencies` /
584    /// `aube.ignoredOptionalDependencies` — a list of dep names that
585    /// should be stripped from every manifest's `optionalDependencies`
586    /// before resolution. Mirrors pnpm's read-package hook at
587    /// `@pnpm/hooks.read-package-hook::createOptionalDependenciesRemover`.
588    /// Non-string entries are ignored. Entries from both namespaces
589    /// union into the returned set.
590    pub fn pnpm_ignored_optional_dependencies(&self) -> BTreeSet<String> {
591        let mut out = BTreeSet::new();
592        for ns in self.pnpm_aube_objects() {
593            if let Some(arr) = ns
594                .get("ignoredOptionalDependencies")
595                .and_then(|v| v.as_array())
596            {
597                out.extend(arr.iter().filter_map(|v| v.as_str().map(String::from)));
598            }
599        }
600        out
601    }
602
603    /// Extract Bun's top-level `patchedDependencies` as a map of
604    /// `name@version` -> patch file path (relative to the project root).
605    /// Empty when the field is missing or malformed.
606    pub fn bun_patched_dependencies(&self) -> BTreeMap<String, String> {
607        let mut out = BTreeMap::new();
608        if let Some(map) = self
609            .extra
610            .get("patchedDependencies")
611            .and_then(|v| v.as_object())
612        {
613            for (k, v) in map {
614                if let Some(s) = v.as_str() {
615                    out.insert(k.clone(), s.to_string());
616                }
617            }
618        }
619        out
620    }
621
622    /// Extract `pnpm.patchedDependencies` / `aube.patchedDependencies`
623    /// as a map of `name@version` -> patch file path (relative to the
624    /// project root). Empty when the field is missing or malformed.
625    /// `aube.*` wins over `pnpm.*` on key conflict.
626    pub fn pnpm_patched_dependencies(&self) -> BTreeMap<String, String> {
627        let mut out = BTreeMap::new();
628        for ns in self.pnpm_aube_objects() {
629            if let Some(map) = ns.get("patchedDependencies").and_then(|v| v.as_object()) {
630                for (k, v) in map {
631                    if let Some(s) = v.as_str() {
632                        out.insert(k.clone(), s.to_string());
633                    }
634                }
635            }
636        }
637        out
638    }
639
640    /// Return the set of dependency names marked
641    /// `dependenciesMeta.<name>.injected = true`. When present, pnpm
642    /// installs a hard copy of the resolved package (typically a
643    /// workspace sibling) instead of a symlink, so the consumer sees
644    /// the packed form — peer deps resolve against the consumer's
645    /// tree rather than the source package's devDependencies. Aube's
646    /// injection step reads this set after linking and rewrites each
647    /// top-level symlink to point at a freshly materialized copy
648    /// under `.aube/<name>@<version>+inject_<hash>/node_modules/<name>`.
649    pub fn dependencies_meta_injected(&self) -> BTreeSet<String> {
650        let Some(meta) = self
651            .extra
652            .get("dependenciesMeta")
653            .and_then(|v| v.as_object())
654        else {
655            return BTreeSet::new();
656        };
657        meta.iter()
658            .filter_map(|(k, v)| {
659                let injected = v.get("injected").and_then(|b| b.as_bool()).unwrap_or(false);
660                injected.then(|| k.clone())
661            })
662            .collect()
663    }
664
665    /// Return `{pnpm,aube}.supportedArchitectures.{os,cpu,libc}` as
666    /// three string arrays. Missing fields become empty vecs. Used by
667    /// the resolver to widen the set of platforms considered
668    /// installable for optional dependencies — e.g. resolving a
669    /// lockfile for a different target than the host running `aube
670    /// install`. Entries from `aube.*` are appended after `pnpm.*` and
671    /// deduped while preserving insertion order.
672    pub fn pnpm_supported_architectures(&self) -> (Vec<String>, Vec<String>, Vec<String>) {
673        let mut os = Vec::new();
674        let mut cpu = Vec::new();
675        let mut libc = Vec::new();
676        for ns in self.pnpm_aube_objects() {
677            let Some(sa) = ns.get("supportedArchitectures").and_then(|v| v.as_object()) else {
678                continue;
679            };
680            if let Some(arr) = sa.get("os").and_then(|v| v.as_array()) {
681                push_unique_strs(&mut os, arr);
682            }
683            if let Some(arr) = sa.get("cpu").and_then(|v| v.as_array()) {
684                push_unique_strs(&mut cpu, arr);
685            }
686            if let Some(arr) = sa.get("libc").and_then(|v| v.as_array()) {
687                push_unique_strs(&mut libc, arr);
688            }
689        }
690        (os, cpu, libc)
691    }
692
693    /// Collect dependency overrides from every supported source on the
694    /// root manifest, merged in precedence order: yarn-style
695    /// `resolutions` (lowest), then `pnpm.overrides`, then
696    /// `aube.overrides`, then top-level `overrides` (highest). Keys
697    /// round-trip as their raw selector strings: bare name (`foo`),
698    /// parent-chain (`parent>foo`), version-suffixed (`foo@<2`,
699    /// `parent@1>foo`), and yarn wildcards (`**/foo`, `parent/foo`).
700    /// Structural validation lives in `aube_resolver::override_rule`;
701    /// this layer just filters out malformed keys and non-string
702    /// values. Workspace-level overrides from `pnpm-workspace.yaml`
703    /// are merged on top of this map by the caller.
704    pub fn overrides_map(&self) -> BTreeMap<String, String> {
705        let mut out: BTreeMap<String, String> = BTreeMap::new();
706        let insert = |out: &mut BTreeMap<String, String>,
707                      obj: &serde_json::Map<String, serde_json::Value>| {
708            for (k, v) in obj {
709                if let Some(s) = v.as_str()
710                    && is_valid_selector_key(k)
711                {
712                    out.insert(k.clone(), s.to_string());
713                }
714            }
715        };
716
717        // yarn `resolutions` (lowest priority)
718        if let Some(obj) = self.extra.get("resolutions").and_then(|v| v.as_object()) {
719            insert(&mut out, obj);
720        }
721
722        // `pnpm.overrides` then `aube.overrides` (later wins)
723        for ns in self.pnpm_aube_objects() {
724            if let Some(obj) = ns.get("overrides").and_then(|v| v.as_object()) {
725                insert(&mut out, obj);
726            }
727        }
728
729        // Top-level `overrides` (npm / pnpm) — highest priority
730        if let Some(obj) = self.extra.get("overrides").and_then(|v| v.as_object()) {
731            insert(&mut out, obj);
732        }
733
734        out
735    }
736
737    /// Look up a package name in `dependencies`, then `devDependencies`,
738    /// then `optionalDependencies`, returning the declared version range.
739    /// Mirrors the lookup order pnpm/npm use for `$name` override
740    /// references. `peerDependencies` is intentionally excluded — a peer
741    /// range isn't a dependency the root pins and reusing it as an
742    /// override target would confuse rather than help.
743    pub fn direct_dependency_range(&self, name: &str) -> Option<&str> {
744        self.dependencies
745            .get(name)
746            .or_else(|| self.dev_dependencies.get(name))
747            .or_else(|| self.optional_dependencies.get(name))
748            .map(String::as_str)
749    }
750
751    /// Resolve `$name` override values in place against this manifest's
752    /// direct dependencies, per pnpm/npm's documented sibling-reference
753    /// syntax. Entries whose `$name` target isn't declared in
754    /// `dependencies` / `devDependencies` / `optionalDependencies` are
755    /// removed from the map; their raw selector keys are returned so the
756    /// caller can surface a diagnostic. Non-`$` values pass through
757    /// unchanged.
758    pub fn resolve_override_refs(&self, overrides: &mut BTreeMap<String, String>) -> Vec<String> {
759        let mut unresolved = Vec::new();
760        overrides.retain(|key, value| {
761            let Some(name) = value.strip_prefix('$') else {
762                return true;
763            };
764            match self.direct_dependency_range(name) {
765                Some(range) => {
766                    *value = range.to_owned();
767                    true
768                }
769                None => {
770                    unresolved.push(key.clone());
771                    false
772                }
773            }
774        });
775        unresolved
776    }
777
778    /// Extract `packageExtensions` from root package.json. Supports
779    /// top-level `packageExtensions`, `pnpm.packageExtensions`, and
780    /// `aube.packageExtensions`. Precedence (low → high):
781    /// `pnpm.packageExtensions`, `aube.packageExtensions`, top-level
782    /// `packageExtensions` — later writes win for duplicate selectors.
783    pub fn package_extensions(&self) -> BTreeMap<String, serde_json::Value> {
784        let mut out = BTreeMap::new();
785        for ns in self.pnpm_aube_objects() {
786            if let Some(obj) = ns.get("packageExtensions").and_then(|v| v.as_object()) {
787                for (k, v) in obj {
788                    out.insert(k.clone(), v.clone());
789                }
790            }
791        }
792        if let Some(obj) = self
793            .extra
794            .get("packageExtensions")
795            .and_then(|v| v.as_object())
796        {
797            for (k, v) in obj {
798                out.insert(k.clone(), v.clone());
799            }
800        }
801        out
802    }
803
804    /// Extract package deprecation mute ranges. Supports top-level
805    /// `allowedDeprecatedVersions`, `pnpm.allowedDeprecatedVersions`,
806    /// and `aube.allowedDeprecatedVersions`; later sources win for
807    /// duplicate keys. Non-string values are ignored.
808    pub fn allowed_deprecated_versions(&self) -> BTreeMap<String, String> {
809        let mut out = BTreeMap::new();
810        let insert = |out: &mut BTreeMap<String, String>,
811                      obj: &serde_json::Map<String, serde_json::Value>| {
812            for (k, v) in obj {
813                if let Some(s) = v.as_str() {
814                    out.insert(k.clone(), s.to_string());
815                }
816            }
817        };
818        for ns in self.pnpm_aube_objects() {
819            if let Some(obj) = ns
820                .get("allowedDeprecatedVersions")
821                .and_then(|v| v.as_object())
822            {
823                insert(&mut out, obj);
824            }
825        }
826        if let Some(obj) = self
827            .extra
828            .get("allowedDeprecatedVersions")
829            .and_then(|v| v.as_object())
830        {
831            insert(&mut out, obj);
832        }
833        out
834    }
835
836    /// Extract `{pnpm,aube}.peerDependencyRules.ignoreMissing` as a
837    /// flat list of glob patterns. Non-string entries are dropped.
838    /// Mirrors pnpm's `peerDependencyRules` escape hatch — patterns
839    /// silence "missing required peer dependency" warnings when the
840    /// peer name matches. Entries from both namespaces union in the
841    /// returned list.
842    pub fn pnpm_peer_dependency_rules_ignore_missing(&self) -> Vec<String> {
843        self.pnpm_peer_dependency_rules_string_list("ignoreMissing")
844    }
845
846    /// Extract `{pnpm,aube}.peerDependencyRules.allowAny` as a flat
847    /// list of glob patterns. Peers whose name matches a pattern have
848    /// their semver check bypassed — any resolved version is accepted.
849    pub fn pnpm_peer_dependency_rules_allow_any(&self) -> Vec<String> {
850        self.pnpm_peer_dependency_rules_string_list("allowAny")
851    }
852
853    /// Extract `{pnpm,aube}.peerDependencyRules.allowedVersions` as a
854    /// map of selector -> additional semver range. Selectors are
855    /// either a bare peer name (e.g. `react`) meaning "applies to
856    /// every consumer of this peer", or `parent>peer` (e.g.
857    /// `styled-components>react`) meaning "only when declared by this
858    /// parent". Values widen the declared peer range: a peer resolving
859    /// inside *either* the declared range or this override is treated
860    /// as satisfied. Non-string entries are ignored. `aube.*` wins
861    /// over `pnpm.*` on key conflict.
862    pub fn pnpm_peer_dependency_rules_allowed_versions(&self) -> BTreeMap<String, String> {
863        let mut out = BTreeMap::new();
864        for ns in self.pnpm_aube_objects() {
865            let Some(rules) = ns.get("peerDependencyRules").and_then(|v| v.as_object()) else {
866                continue;
867            };
868            let Some(obj) = rules.get("allowedVersions").and_then(|v| v.as_object()) else {
869                continue;
870            };
871            for (k, v) in obj {
872                if let Some(s) = v.as_str() {
873                    out.insert(k.clone(), s.to_string());
874                }
875            }
876        }
877        out
878    }
879
880    fn pnpm_peer_dependency_rules_string_list(&self, field: &str) -> Vec<String> {
881        let mut out = Vec::new();
882        for ns in self.pnpm_aube_objects() {
883            let Some(rules) = ns.get("peerDependencyRules").and_then(|v| v.as_object()) else {
884                continue;
885            };
886            let Some(arr) = rules.get(field).and_then(|v| v.as_array()) else {
887                continue;
888            };
889            push_unique_strs(&mut out, arr);
890        }
891        out
892    }
893
894    /// Extract `updateConfig.ignoreDependencies` from package.json
895    /// across all supported locations: top-level `updateConfig`,
896    /// `pnpm.updateConfig.ignoreDependencies`, and
897    /// `aube.updateConfig.ignoreDependencies`. All entries are merged
898    /// and deduped.
899    pub fn update_ignore_dependencies(&self) -> Vec<String> {
900        let mut out = Vec::new();
901        for ns in self.pnpm_aube_objects() {
902            if let Some(arr) = ns
903                .get("updateConfig")
904                .and_then(|v| v.as_object())
905                .and_then(|u| u.get("ignoreDependencies"))
906                .and_then(|v| v.as_array())
907            {
908                out.extend(arr.iter().filter_map(|v| v.as_str().map(String::from)));
909            }
910        }
911        if let Some(update_config) = &self.update_config {
912            out.extend(update_config.ignore_dependencies.iter().cloned());
913        }
914        out.sort();
915        out.dedup();
916        out
917    }
918
919    pub fn all_dependencies(&self) -> impl Iterator<Item = (&str, &str)> {
920        self.dependencies
921            .iter()
922            .chain(self.dev_dependencies.iter())
923            .map(|(k, v)| (k.as_str(), v.as_str()))
924    }
925
926    pub fn production_dependencies(&self) -> impl Iterator<Item = (&str, &str)> {
927        self.dependencies
928            .iter()
929            .map(|(k, v)| (k.as_str(), v.as_str()))
930    }
931}
932
933/// Raw value shape for a single `allowBuilds` entry, preserved as-is
934/// from the source JSON/YAML. Interpretation (allow / deny / warn
935/// about unsupported shape) lives in `aube-scripts::policy`, keeping
936/// this crate purely about parsing.
937#[derive(Debug, Clone, PartialEq, Eq)]
938pub enum AllowBuildRaw {
939    Bool(bool),
940    Other(String),
941}
942
943impl AllowBuildRaw {
944    fn from_json(v: &serde_json::Value) -> Self {
945        match v {
946            serde_json::Value::Bool(b) => Self::Bool(*b),
947            // Strings are stored verbatim — without `as_str` we'd get
948            // `Value::to_string`'s JSON-encoded form (`"foo"` with the
949            // outer quotes baked into the payload), which would defeat
950            // a downstream equality check against a known placeholder
951            // and would also make any warning surface show extra
952            // quotes the user didn't write.
953            serde_json::Value::String(s) => Self::Other(s.clone()),
954            other => Self::Other(other.to_string()),
955        }
956    }
957}
958
959/// Surface-level structural check on an override key. We accept any
960/// non-empty key that isn't obviously a JSON typo — the resolver's
961/// `override_rule` parser does the real work and silently drops keys
962/// it can't interpret. Keeping the manifest filter loose means a pnpm
963/// user with an unfamiliar-but-valid selector (e.g. `a@1>b@<2`)
964/// reaches the resolver unchanged.
965fn is_valid_selector_key(k: &str) -> bool {
966    !k.is_empty()
967}
968
969/// Append the string entries of `arr` to `dst`, skipping duplicates
970/// already present and dropping non-string values. Preserves the
971/// insertion order of first appearance — callers rely on this to keep
972/// `pnpm.*` entries ahead of `aube.*` entries when both namespaces
973/// contribute to the same list.
974fn push_unique_strs(dst: &mut Vec<String>, arr: &[serde_json::Value]) {
975    for v in arr {
976        if let Some(s) = v.as_str()
977            && !dst.iter().any(|existing| existing == s)
978        {
979            dst.push(s.to_string());
980        }
981    }
982}
983
984/// npm/pnpm "envify": replace every character that is not
985/// `[A-Za-z0-9_]` with `_`, leaving case untouched. Used to build the
986/// `npm_package_*` env-var keys from manifest field names.
987fn envify_env_key(key: &str) -> String {
988    key.chars()
989        .map(|c| {
990            if c.is_ascii_alphanumeric() || c == '_' {
991                c
992            } else {
993                '_'
994            }
995        })
996        .collect()
997}
998
999/// Deep-flatten a JSON value into `prefix`-rooted `npm_package_*`
1000/// pairs, npm-style: objects recurse with `_`-joined keys, arrays
1001/// index with `_<i>`, scalars stringify, `null` is skipped.
1002fn flatten_json_env(prefix: &str, value: &serde_json::Value, out: &mut Vec<(String, String)>) {
1003    match value {
1004        serde_json::Value::Object(map) => {
1005            for (k, v) in map {
1006                flatten_json_env(&format!("{prefix}_{}", envify_env_key(k)), v, out);
1007            }
1008        }
1009        serde_json::Value::Array(arr) => {
1010            for (i, v) in arr.iter().enumerate() {
1011                flatten_json_env(&format!("{prefix}_{i}"), v, out);
1012            }
1013        }
1014        serde_json::Value::String(s) => out.push((prefix.to_string(), s.clone())),
1015        serde_json::Value::Number(n) => out.push((prefix.to_string(), n.to_string())),
1016        serde_json::Value::Bool(b) => out.push((prefix.to_string(), b.to_string())),
1017        serde_json::Value::Null => {}
1018    }
1019}
1020
1021/// Union of `package.json`'s `{pnpm,aube}.supportedArchitectures.*` and
1022/// `pnpm-workspace.yaml`'s `supportedArchitectures.*`. pnpm v10 treats
1023/// the workspace yaml as the canonical home for shared platform
1024/// widening — a team generating a cross-platform lockfile on Linux CI
1025/// sets it there once rather than in every importer's manifest.
1026/// Insertion order: manifest first, workspace appended, duplicates
1027/// dropped (same dedupe rule `pnpm_supported_architectures` already
1028/// uses between the `pnpm.*` and `aube.*` namespaces).
1029pub fn effective_supported_architectures(
1030    manifest: &PackageJson,
1031    workspace: &workspace::WorkspaceConfig,
1032) -> (Vec<String>, Vec<String>, Vec<String>) {
1033    let (mut os, mut cpu, mut libc) = manifest.pnpm_supported_architectures();
1034    if let Some(ws) = &workspace.supported_architectures {
1035        let extend_unique = |dst: &mut Vec<String>, src: &[String]| {
1036            for s in src {
1037                if !dst.iter().any(|existing| existing == s) {
1038                    dst.push(s.clone());
1039                }
1040            }
1041        };
1042        extend_unique(&mut os, &ws.os);
1043        extend_unique(&mut cpu, &ws.cpu);
1044        extend_unique(&mut libc, &ws.libc);
1045    }
1046    (os, cpu, libc)
1047}
1048
1049/// Union of `package.json`'s `{pnpm,aube}.ignoredOptionalDependencies`
1050/// and `pnpm-workspace.yaml`'s `ignoredOptionalDependencies`. Same
1051/// layering rule as [`effective_supported_architectures`]: workspace
1052/// yaml is pnpm v10's canonical location for shared settings, so the
1053/// two sources union rather than override.
1054pub fn effective_ignored_optional_dependencies(
1055    manifest: &PackageJson,
1056    workspace: &workspace::WorkspaceConfig,
1057) -> BTreeSet<String> {
1058    let mut out = manifest.pnpm_ignored_optional_dependencies();
1059    out.extend(workspace.ignored_optional_dependencies.iter().cloned());
1060    out
1061}
1062
1063#[derive(Debug, thiserror::Error, miette::Diagnostic)]
1064pub enum Error {
1065    #[error("failed to read {0}: {1}")]
1066    Io(std::path::PathBuf, std::io::Error),
1067    #[error(transparent)]
1068    #[diagnostic(transparent)]
1069    Parse(Box<ParseError>),
1070    #[error("failed to parse {0}: {1}")]
1071    #[diagnostic(code(ERR_AUBE_MANIFEST_YAML_PARSE))]
1072    YamlParse(std::path::PathBuf, String),
1073}
1074
1075/// JSON parse failure with enough info for `miette`'s `fancy` handler to
1076/// render a pointer at the offending byte. Boxed into [`Error::Parse`] so
1077/// the enum's `Err` size stays small (clippy's `result_large_err`).
1078///
1079/// `Diagnostic` is implemented by hand rather than via `miette::Diagnostic`
1080/// derive because `miette-derive` 7.6 expands into a destructuring that
1081/// triggers `unused_assignments` under `RUSTFLAGS=-D warnings` on rustc
1082/// 1.93 (our MSRV).
1083#[derive(Debug, thiserror::Error)]
1084#[error("failed to parse {path}: {message}")]
1085pub struct ParseError {
1086    pub path: std::path::PathBuf,
1087    pub message: String,
1088    pub src: miette::NamedSource<String>,
1089    pub span: miette::SourceSpan,
1090}
1091
1092impl miette::Diagnostic for ParseError {
1093    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
1094        Some(Box::new(aube_codes::errors::ERR_AUBE_MANIFEST_PARSE))
1095    }
1096
1097    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
1098        Some(&self.src)
1099    }
1100
1101    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
1102        Some(Box::new(std::iter::once(
1103            miette::LabeledSpan::new_with_span(Some(self.message.clone()), self.span),
1104        )))
1105    }
1106}
1107
1108impl ParseError {
1109    /// Build a `ParseError` from a `serde_json::Error`, computing the
1110    /// byte offset so miette can render a pointer into `content`.
1111    /// Shared across crates (see `aube_lockfile::Error::parse_json_err`)
1112    /// so there's a single implementation of the line/col → byte-offset
1113    /// conversion and span clamping.
1114    pub fn from_json_err(path: &Path, content: String, err: &serde_json::Error) -> Self {
1115        let offset = line_col_to_byte_offset(&content, err.line(), err.column());
1116        // Clamp the span length so it never extends past the content
1117        // end. A trailing-newline-EOF error reports a position at or
1118        // past `content.len()`; a fixed length of 1 would push the
1119        // range one byte past the source and miette's renderer would
1120        // fail to slice it. A zero-length span at `content.len()` is
1121        // what miette expects for "end-of-input" labels.
1122        let len = if offset >= content.len() { 0 } else { 1 };
1123        Self::new(path, content, err.to_string(), offset, len)
1124    }
1125
1126    /// Build a `ParseError` from a `yaml_serde::Error`.
1127    /// `yaml_serde::Location::index` is already a byte offset, so no
1128    /// line/col conversion is needed. Errors without a location
1129    /// (notably those bubbling from `yaml_serde::from_value`) collapse
1130    /// to an empty span at offset 0 — miette still renders the file
1131    /// name + message, just without a pointer.
1132    pub fn from_yaml_err(path: &Path, content: String, err: &yaml_serde::Error) -> Self {
1133        let (offset, len) = match err.location() {
1134            Some(loc) => {
1135                let idx = loc.index().min(content.len());
1136                let len = if idx >= content.len() { 0 } else { 1 };
1137                (idx, len)
1138            }
1139            None => (0, 0),
1140        };
1141        Self::new(path, content, err.to_string(), offset, len)
1142    }
1143
1144    fn new(path: &Path, content: String, message: String, offset: usize, len: usize) -> Self {
1145        ParseError {
1146            path: path.to_path_buf(),
1147            message,
1148            src: miette::NamedSource::new(path.display().to_string(), content),
1149            span: miette::SourceSpan::new(offset.into(), len),
1150        }
1151    }
1152}
1153
1154/// Parse a JSON document from `content`, returning an [`Error::Parse`] on
1155/// failure with the source content + span attached so miette's fancy
1156/// handler can render a pointer into the offending file.
1157pub fn parse_json<T: serde::de::DeserializeOwned>(
1158    path: &Path,
1159    content: String,
1160) -> Result<T, Error> {
1161    // Strip leading UTF-8 BOM (U+FEFF, bytes EF BB BF). Notepad on
1162    // Windows writes BOM by default. VS Code can be configured to do
1163    // the same. serde_json does not tolerate BOM, errors at "line 1
1164    // column 1". npm and pnpm both tolerate it. Without this strip,
1165    // opening package.json in Notepad, saving, then running aube
1166    // returns a cryptic parse error. Cheap fix, no downside.
1167    let content = if let Some(stripped) = content.strip_prefix('\u{FEFF}') {
1168        stripped.to_owned()
1169    } else {
1170        content
1171    };
1172    if let Ok(v) = sonic_rs::from_slice(content.as_bytes()) {
1173        return Ok(v);
1174    }
1175    match serde_json::from_str(&content) {
1176        Ok(v) => Ok(v),
1177        Err(e) => {
1178            let trimmed = content.trim_start();
1179            if trimmed.starts_with("//") || trimmed.starts_with("/*") {
1180                return Err(Error::parse_msg(
1181                    path,
1182                    content,
1183                    "package.json cannot contain JSON comments. \
1184                     Remove any `//` or `/* */` lines. aube does not support JSONC for package.json"
1185                        .to_string(),
1186                ));
1187            }
1188            Err(Error::parse(path, content, &e))
1189        }
1190    }
1191}
1192
1193/// Parse a YAML document from `content`, returning an [`Error::Parse`] on
1194/// failure with the source content + span attached. `yaml_serde` reports
1195/// errors with a `Location { index, line, column }` we can feed straight
1196/// into a miette span; type-mismatch errors raised after `from_str`
1197/// succeeds (e.g. via `from_value`) have no location and render without
1198/// a pointer but still carry the file name.
1199pub fn parse_yaml<T: serde::de::DeserializeOwned>(
1200    path: &Path,
1201    content: String,
1202) -> Result<T, Error> {
1203    match yaml_serde::from_str(&content) {
1204        Ok(v) => Ok(v),
1205        Err(e) => Err(Error::parse_yaml_err(path, content, &e)),
1206    }
1207}
1208
1209impl Error {
1210    /// Build an [`Error::Parse`] from a `serde_json::Error`. Delegates
1211    /// to [`ParseError::from_json_err`] — the crate-shared constructor
1212    /// other crates (`aube-lockfile`) also reuse for their JSON parse
1213    /// paths.
1214    pub fn parse(path: &Path, content: String, err: &serde_json::Error) -> Self {
1215        Error::Parse(Box::new(ParseError::from_json_err(path, content, err)))
1216    }
1217
1218    /// Build an [`Error::Parse`] from a `yaml_serde::Error`. Delegates
1219    /// to [`ParseError::from_yaml_err`].
1220    pub fn parse_yaml_err(path: &Path, content: String, err: &yaml_serde::Error) -> Self {
1221        Error::Parse(Box::new(ParseError::from_yaml_err(path, content, err)))
1222    }
1223
1224    /// Build an [`Error::Parse`] with a plain message and a span
1225    /// pointing at the start of the file. Used for hand-crafted
1226    /// pre-parse diagnostics like the JSONC comment detector,
1227    /// where we want a clear message without serde's usual
1228    /// cryptic one.
1229    pub fn parse_msg(path: &Path, content: String, message: String) -> Self {
1230        let len = content.len();
1231        let src = miette::NamedSource::new(path.display().to_string(), content);
1232        let span = miette::SourceSpan::new(0.into(), len.min(1));
1233        Error::Parse(Box::new(ParseError {
1234            path: path.to_path_buf(),
1235            message,
1236            src,
1237            span,
1238        }))
1239    }
1240}
1241
1242/// Convert serde_json's 1-based line/column into a byte offset into
1243/// `content`. Out-of-range values clamp to the end so we never panic
1244/// on a degenerate error position.
1245fn line_col_to_byte_offset(content: &str, line: usize, column: usize) -> usize {
1246    if line == 0 {
1247        return 0;
1248    }
1249    let mut offset = 0usize;
1250    for (i, l) in content.split_inclusive('\n').enumerate() {
1251        if i + 1 == line {
1252            return (offset + column.saturating_sub(1)).min(content.len());
1253        }
1254        offset += l.len();
1255    }
1256    content.len()
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261    use super::*;
1262
1263    fn parse(json: &str) -> PackageJson {
1264        serde_json::from_str(json).unwrap()
1265    }
1266
1267    /// `npm_package_env` mirrors pnpm's exact flattening: name, version,
1268    /// and deep `engines`/`config`/`bin` — and nothing else.
1269    #[test]
1270    fn npm_package_env_matches_pnpm_allowlist() {
1271        let pkg = parse(
1272            r#"{
1273                "name": "@scope/envprobe",
1274                "version": "4.5.6",
1275                "description": "ignored",
1276                "main": "index.js",
1277                "type": "module",
1278                "private": true,
1279                "os": ["darwin"],
1280                "engines": { "node": ">=18", "npm": ">=9" },
1281                "config": { "port": "8080", "nested": { "deep-key": "x" } },
1282                "bin": { "probe": "./cli.js" }
1283            }"#,
1284        );
1285        let env: std::collections::BTreeMap<String, String> =
1286            pkg.npm_package_env().into_iter().collect();
1287
1288        assert_eq!(
1289            env.get("npm_package_name").map(String::as_str),
1290            Some("@scope/envprobe")
1291        );
1292        assert_eq!(
1293            env.get("npm_package_version").map(String::as_str),
1294            Some("4.5.6")
1295        );
1296        assert_eq!(
1297            env.get("npm_package_engines_node").map(String::as_str),
1298            Some(">=18")
1299        );
1300        assert_eq!(
1301            env.get("npm_package_engines_npm").map(String::as_str),
1302            Some(">=9")
1303        );
1304        assert_eq!(
1305            env.get("npm_package_config_port").map(String::as_str),
1306            Some("8080")
1307        );
1308        // Nested objects flatten with `_`-joined, envified keys.
1309        assert_eq!(
1310            env.get("npm_package_config_nested_deep_key")
1311                .map(String::as_str),
1312            Some("x")
1313        );
1314        assert_eq!(
1315            env.get("npm_package_bin_probe").map(String::as_str),
1316            Some("./cli.js")
1317        );
1318        // Fields pnpm does not export must be absent.
1319        for absent in [
1320            "npm_package_description",
1321            "npm_package_main",
1322            "npm_package_type",
1323            "npm_package_private",
1324            "npm_package_os_0",
1325        ] {
1326            assert!(!env.contains_key(absent), "{absent} should not be exported");
1327        }
1328    }
1329
1330    /// A bare-string `bin` flattens to the unsuffixed `npm_package_bin`
1331    /// (pnpm verified against 11.5) — the same generic flattening as
1332    /// `config`, not npm's normalize-to-unscoped-name behavior.
1333    #[test]
1334    fn npm_package_env_string_bin_is_unsuffixed() {
1335        let pkg = parse(r#"{ "name": "@scope/tool", "version": "1.0.0", "bin": "./cli.js" }"#);
1336        let env: std::collections::BTreeMap<String, String> =
1337            pkg.npm_package_env().into_iter().collect();
1338        assert_eq!(
1339            env.get("npm_package_bin").map(String::as_str),
1340            Some("./cli.js")
1341        );
1342        assert!(
1343            !env.keys().any(|k| k.starts_with("npm_package_bin_")),
1344            "string bin must stay unsuffixed: {env:?}"
1345        );
1346    }
1347
1348    /// Pre-npm-2.x publishes (e.g. `extsprintf@1.4.1`, `coffee-script@1.3.3`)
1349    /// ship `"engines": ["node >=0.6.0"]` as an array rather than a map.
1350    /// Modern npm ignores the legacy shape; we do the same rather than
1351    /// fail the whole manifest and take down every install that touches
1352    /// one of these ancient packages.
1353    #[test]
1354    fn engines_legacy_array_form_parses_as_empty_map() {
1355        let p = parse(r#"{"name":"x","engines":["node >=0.6.0"]}"#);
1356        assert!(p.engines.is_empty());
1357    }
1358
1359    /// Some old npm packument entries (e.g. `qs`) ship `engines` as a
1360    /// bare string. There is no reliable key to preserve, so treat it
1361    /// like the legacy array form and ignore it.
1362    #[test]
1363    fn engines_legacy_string_form_parses_as_empty_map() {
1364        let p = parse(r#"{"name":"x","engines":"node >=0.6.0"}"#);
1365        assert!(p.engines.is_empty());
1366    }
1367
1368    #[test]
1369    fn engines_null_is_treated_as_empty() {
1370        let p = parse(r#"{"name":"x","engines":null}"#);
1371        assert!(p.engines.is_empty());
1372    }
1373
1374    #[test]
1375    fn engines_modern_map_form_still_parses() {
1376        let p = parse(r#"{"name":"x","engines":{"node":">=18.0.0","npm":">=9"}}"#);
1377        assert_eq!(p.engines.get("node").unwrap(), ">=18.0.0");
1378        assert_eq!(p.engines.get("npm").unwrap(), ">=9");
1379    }
1380
1381    #[test]
1382    fn engines_missing_field_is_empty() {
1383        let p = parse(r#"{"name":"x"}"#);
1384        assert!(p.engines.is_empty());
1385    }
1386
1387    /// Sanity-check the line/column → byte-offset conversion. An
1388    /// off-by-one here silently slides miette's pointer to the wrong
1389    /// byte, defeating the whole reason the source span exists.
1390    #[test]
1391    fn line_col_offset_single_line_col_one() {
1392        assert_eq!(line_col_to_byte_offset("{}", 1, 1), 0);
1393    }
1394
1395    #[test]
1396    fn line_col_offset_multiline_line_two() {
1397        // "a\nbc\n" — line 2, column 1 is the 'b' at byte 2.
1398        assert_eq!(line_col_to_byte_offset("a\nbc\n", 2, 1), 2);
1399        assert_eq!(line_col_to_byte_offset("a\nbc\n", 2, 2), 3);
1400    }
1401
1402    /// `line == 0` can happen if serde_json hits EOF before any input;
1403    /// treat as "beginning of file" rather than panic.
1404    #[test]
1405    fn line_col_offset_line_zero_returns_zero() {
1406        assert_eq!(line_col_to_byte_offset("any", 0, 5), 0);
1407    }
1408
1409    /// A column past the end of its line (or past EOF) clamps to the
1410    /// last valid offset so we never build a SourceSpan that would
1411    /// crash miette's renderer.
1412    #[test]
1413    fn line_col_offset_column_past_end_clamps() {
1414        let s = "ab";
1415        assert_eq!(line_col_to_byte_offset(s, 1, 999), s.len());
1416    }
1417
1418    /// A line past the last line falls through the loop and clamps to
1419    /// the file end.
1420    #[test]
1421    fn line_col_offset_line_past_end_clamps() {
1422        let s = "a\nb";
1423        assert_eq!(line_col_to_byte_offset(s, 10, 1), s.len());
1424    }
1425
1426    /// A file whose last line has no trailing `\n` is the common case;
1427    /// make sure columns on that final line still resolve correctly.
1428    #[test]
1429    fn line_col_offset_no_trailing_newline() {
1430        let s = "a\nbc";
1431        assert_eq!(line_col_to_byte_offset(s, 2, 2), 3);
1432    }
1433
1434    /// `serde_json` reports "EOF while parsing" with a position at or
1435    /// past `content.len()` (e.g. `{"name":` → column 8 on a 8-byte
1436    /// buffer). The span must never extend past the end of source or
1437    /// `miette`'s renderer chokes trying to slice it — clamp the span
1438    /// length to 0 at EOF.
1439    #[test]
1440    fn parse_error_eof_span_stays_in_bounds() {
1441        let path = Path::new("pkg.json");
1442        let content = r#"{"name":"#.to_string();
1443        let json_err: serde_json::Error = serde_json::from_str::<serde_json::Value>(&content)
1444            .expect_err("truncated JSON must fail");
1445        let Error::Parse(pe) = Error::parse(path, content.clone(), &json_err) else {
1446            panic!("Error::parse must produce Parse variant");
1447        };
1448        let offset: usize = pe.span.offset();
1449        let len: usize = pe.span.len();
1450        assert!(
1451            offset + len <= content.len(),
1452            "span [{offset}, {}) exceeds content.len() {}",
1453            offset + len,
1454            content.len()
1455        );
1456    }
1457
1458    /// A malformed YAML document should surface through `parse_yaml`
1459    /// as an `Error::Parse` carrying a `NamedSource` pointed at the
1460    /// supplied path and a span inside the content buffer.
1461    #[test]
1462    fn parse_yaml_attaches_source_span() {
1463        let path = Path::new("pnpm-workspace.yaml");
1464        // Tab as the first indent char is a spec-level YAML error;
1465        // yaml_serde reports a location for it.
1466        let content = "packages:\n\t- pkg\n".to_string();
1467        let res: Result<yaml_serde::Value, Error> = parse_yaml(path, content.clone());
1468        let Err(Error::Parse(pe)) = res else {
1469            panic!("parse_yaml must produce Parse variant on malformed input");
1470        };
1471        let offset: usize = pe.span.offset();
1472        let len: usize = pe.span.len();
1473        assert!(offset + len <= content.len());
1474        assert_eq!(pe.path, path);
1475    }
1476
1477    /// `yaml_serde::from_value` errors have no `location()`. The helper
1478    /// should still produce an `Error::Parse` (with a zero-length span)
1479    /// so the file name survives into miette's render.
1480    #[test]
1481    fn parse_yaml_err_without_location_falls_back_to_empty_span() {
1482        let path = Path::new("pnpm-workspace.yaml");
1483        let content = String::new();
1484        let yaml_err: yaml_serde::Error =
1485            yaml_serde::from_value::<BTreeMap<String, String>>(yaml_serde::Value::Bool(true))
1486                .expect_err("bool cannot coerce to a map");
1487        assert!(yaml_err.location().is_none());
1488        let Error::Parse(pe) = Error::parse_yaml_err(path, content, &yaml_err) else {
1489            panic!("parse_yaml_err must produce Parse variant");
1490        };
1491        assert_eq!(pe.span.offset(), 0);
1492        assert_eq!(pe.span.len(), 0);
1493    }
1494
1495    #[test]
1496    fn engines_map_drops_non_string_values() {
1497        // Stay consistent with how our dep-map parsers treat redacted
1498        // / non-string entries — drop, not fail.
1499        let p = parse(r#"{"name":"x","engines":{"node":">=18","weird":null,"n":42}}"#);
1500        assert_eq!(p.engines.get("node").unwrap(), ">=18");
1501        assert!(!p.engines.contains_key("weird"));
1502        assert!(!p.engines.contains_key("n"));
1503    }
1504
1505    /// `firefox-profile@4.7.0` (and other legacy packages) ship tool
1506    /// config nested under `scripts`, e.g. `scripts.blanket = {...}`.
1507    /// npm ignores non-string entries instead of failing the install,
1508    /// and so do we — drop them and keep the real scripts.
1509    #[test]
1510    fn scripts_non_string_entries_are_dropped() {
1511        let p = parse(
1512            r#"{
1513                "name":"firefox-profile",
1514                "scripts": {
1515                    "test": "grunt travis",
1516                    "blanket": { "pattern": ["/lib/firefox_profile"] }
1517                }
1518            }"#,
1519        );
1520        assert_eq!(
1521            p.scripts.get("test").map(String::as_str),
1522            Some("grunt travis")
1523        );
1524        assert!(!p.scripts.contains_key("blanket"));
1525    }
1526
1527    #[test]
1528    fn scripts_null_is_treated_as_empty() {
1529        let p = parse(r#"{"name":"x","scripts":null}"#);
1530        assert!(p.scripts.is_empty());
1531    }
1532
1533    #[test]
1534    fn scripts_non_object_value_is_treated_as_empty() {
1535        // Mirrors `engines_tolerant`'s legacy-shape handling: if the
1536        // field exists but isn't a map, treat it as absent rather than
1537        // failing the parse.
1538        let p = parse(r#"{"name":"x","scripts":"oops"}"#);
1539        assert!(p.scripts.is_empty());
1540    }
1541
1542    #[test]
1543    fn selector_key_filter_accepts_valid_forms() {
1544        assert!(is_valid_selector_key("lodash"));
1545        assert!(is_valid_selector_key("@babel/core"));
1546        assert!(is_valid_selector_key("foo>bar"));
1547        assert!(is_valid_selector_key("**/foo"));
1548        assert!(is_valid_selector_key("lodash@<4.17.21"));
1549        assert!(is_valid_selector_key("a@1>b@<2"));
1550    }
1551
1552    #[test]
1553    fn selector_key_filter_rejects_empty() {
1554        assert!(!is_valid_selector_key(""));
1555    }
1556
1557    #[test]
1558    fn overrides_map_collects_top_level() {
1559        let p = parse(r#"{"overrides": {"lodash": "4.17.21"}}"#);
1560        assert_eq!(p.overrides_map().get("lodash").unwrap(), "4.17.21");
1561    }
1562
1563    #[test]
1564    fn overrides_map_top_level_wins_over_pnpm_and_resolutions() {
1565        let p = parse(
1566            r#"{
1567                "resolutions": {"lodash": "1.0.0"},
1568                "pnpm": {"overrides": {"lodash": "2.0.0"}},
1569                "overrides": {"lodash": "3.0.0"}
1570            }"#,
1571        );
1572        assert_eq!(p.overrides_map().get("lodash").unwrap(), "3.0.0");
1573    }
1574
1575    #[test]
1576    fn overrides_map_merges_disjoint_keys() {
1577        let p = parse(
1578            r#"{
1579                "resolutions": {"a": "1"},
1580                "pnpm": {"overrides": {"b": "2"}},
1581                "overrides": {"c": "3"}
1582            }"#,
1583        );
1584        let m = p.overrides_map();
1585        assert_eq!(m.get("a").unwrap(), "1");
1586        assert_eq!(m.get("b").unwrap(), "2");
1587        assert_eq!(m.get("c").unwrap(), "3");
1588    }
1589
1590    #[test]
1591    fn overrides_map_preserves_advanced_selector_keys() {
1592        // Advanced selectors round-trip as raw keys; the resolver
1593        // parses them later.
1594        let p = parse(
1595            r#"{
1596                "overrides": {
1597                    "lodash": "4.17.21",
1598                    "foo>bar": "1.0.0",
1599                    "**/baz": "1.0.0",
1600                    "qux@<2": "1.0.0"
1601                }
1602            }"#,
1603        );
1604        let m = p.overrides_map();
1605        assert_eq!(m.len(), 4);
1606        assert!(m.contains_key("lodash"));
1607        assert!(m.contains_key("foo>bar"));
1608        assert!(m.contains_key("**/baz"));
1609        assert!(m.contains_key("qux@<2"));
1610    }
1611
1612    #[test]
1613    fn overrides_map_supports_npm_alias_value() {
1614        let p = parse(r#"{"overrides": {"foo": "npm:bar@^2"}}"#);
1615        assert_eq!(p.overrides_map().get("foo").unwrap(), "npm:bar@^2");
1616    }
1617
1618    #[test]
1619    fn package_extensions_top_level_wins_over_pnpm() {
1620        let p = parse(
1621            r#"{
1622                "pnpm": {"packageExtensions": {"foo": {"dependencies": {"a": "1"}}}},
1623                "packageExtensions": {"foo": {"dependencies": {"a": "2"}}}
1624            }"#,
1625        );
1626        assert_eq!(
1627            p.package_extensions()
1628                .get("foo")
1629                .and_then(|v| v.pointer("/dependencies/a"))
1630                .and_then(|v| v.as_str()),
1631            Some("2")
1632        );
1633    }
1634
1635    #[test]
1636    fn update_ignore_dependencies_merges_top_level_and_pnpm() {
1637        let p = parse(
1638            r#"{
1639                "pnpm": {"updateConfig": {"ignoreDependencies": ["a"]}},
1640                "updateConfig": {"ignoreDependencies": ["b"]}
1641            }"#,
1642        );
1643        assert_eq!(p.update_ignore_dependencies(), vec!["a", "b"]);
1644    }
1645
1646    #[test]
1647    fn overrides_map_skips_object_values() {
1648        // npm allows nested override objects; we don't support those yet,
1649        // so they should be silently dropped rather than panicking.
1650        let p = parse(r#"{"overrides": {"foo": {"bar": "1.0.0"}}}"#);
1651        assert!(p.overrides_map().is_empty());
1652    }
1653
1654    #[test]
1655    fn resolve_override_refs_substitutes_from_dependencies() {
1656        let p = parse(
1657            r#"{
1658                "dependencies": {"semver": "^7.5.2"},
1659                "overrides": {"semver@<7.5.2": "$semver"}
1660            }"#,
1661        );
1662        let mut m = p.overrides_map();
1663        let unresolved = p.resolve_override_refs(&mut m);
1664        assert!(unresolved.is_empty());
1665        assert_eq!(m.get("semver@<7.5.2").unwrap(), "^7.5.2");
1666    }
1667
1668    #[test]
1669    fn resolve_override_refs_checks_dev_and_optional() {
1670        let p = parse(
1671            r#"{
1672                "devDependencies": {"a": "1.0.0"},
1673                "optionalDependencies": {"b": "2.0.0"},
1674                "overrides": {"a": "$a", "b": "$b"}
1675            }"#,
1676        );
1677        let mut m = p.overrides_map();
1678        let unresolved = p.resolve_override_refs(&mut m);
1679        assert!(unresolved.is_empty());
1680        assert_eq!(m.get("a").unwrap(), "1.0.0");
1681        assert_eq!(m.get("b").unwrap(), "2.0.0");
1682    }
1683
1684    #[test]
1685    fn resolve_override_refs_drops_unresolved() {
1686        let p = parse(
1687            r#"{
1688                "dependencies": {"semver": "^7.5.2"},
1689                "overrides": {
1690                    "semver@<7.5.2": "$semver",
1691                    "cacheable-request@<10": "$cacheable-request"
1692                }
1693            }"#,
1694        );
1695        let mut m = p.overrides_map();
1696        let unresolved = p.resolve_override_refs(&mut m);
1697        assert_eq!(unresolved, vec!["cacheable-request@<10".to_string()]);
1698        assert_eq!(m.get("semver@<7.5.2").unwrap(), "^7.5.2");
1699        assert!(!m.contains_key("cacheable-request@<10"));
1700    }
1701
1702    #[test]
1703    fn resolve_override_refs_passes_non_dollar_through() {
1704        let p = parse(
1705            r#"{
1706                "dependencies": {"foo": "1.0.0"},
1707                "overrides": {"foo": "2.0.0", "bar": "3.0.0"}
1708            }"#,
1709        );
1710        let mut m = p.overrides_map();
1711        let unresolved = p.resolve_override_refs(&mut m);
1712        assert!(unresolved.is_empty());
1713        assert_eq!(m.get("foo").unwrap(), "2.0.0");
1714        assert_eq!(m.get("bar").unwrap(), "3.0.0");
1715    }
1716
1717    #[test]
1718    fn resolve_override_refs_ignores_peer_dependencies() {
1719        // npm/pnpm resolve `$name` against direct deps only. Peer
1720        // dependency ranges are contracts, not pins, so they shouldn't
1721        // silently flow into override values.
1722        let p = parse(
1723            r#"{
1724                "peerDependencies": {"react": "^18"},
1725                "overrides": {"react": "$react"}
1726            }"#,
1727        );
1728        let mut m = p.overrides_map();
1729        let unresolved = p.resolve_override_refs(&mut m);
1730        assert_eq!(unresolved, vec!["react".to_string()]);
1731        assert!(m.is_empty());
1732    }
1733
1734    #[test]
1735    fn parses_bundled_dependencies_list() {
1736        let p = parse(r#"{"name":"x","bundledDependencies":["foo","bar"]}"#);
1737        let deps = BTreeMap::new();
1738        let names = p.bundled_dependencies.as_ref().unwrap().names(&deps);
1739        assert_eq!(names, vec!["foo", "bar"]);
1740    }
1741
1742    #[test]
1743    fn accepts_legacy_bundle_dependencies_alias() {
1744        let p = parse(r#"{"name":"x","bundleDependencies":["foo"]}"#);
1745        assert!(matches!(
1746            p.bundled_dependencies,
1747            Some(BundledDependencies::List(_))
1748        ));
1749    }
1750
1751    /// Regression: some publishes (e.g. `@lingui/message-utils@5.2.0`+)
1752    /// ship both `bundledDependencies` and the deprecated
1753    /// `bundleDependencies` alias in the same object. serde's default
1754    /// `alias` rejects that as a duplicate field; we accept it and
1755    /// prefer the canonical spelling.
1756    #[test]
1757    fn accepts_both_bundle_and_bundled_dependencies() {
1758        let p = parse(
1759            r#"{"name":"x","bundledDependencies":["canonical"],"bundleDependencies":["legacy"]}"#,
1760        );
1761        let deps = BTreeMap::new();
1762        let names = p.bundled_dependencies.as_ref().unwrap().names(&deps);
1763        assert_eq!(names, vec!["canonical"]);
1764    }
1765
1766    /// Same regression with the keys in the other order, since serde
1767    /// field-collection is order-sensitive when alias collisions are
1768    /// involved.
1769    #[test]
1770    fn accepts_both_bundle_and_bundled_dependencies_reverse_order() {
1771        let p = parse(
1772            r#"{"name":"x","bundleDependencies":["legacy"],"bundledDependencies":["canonical"]}"#,
1773        );
1774        let deps = BTreeMap::new();
1775        let names = p.bundled_dependencies.as_ref().unwrap().names(&deps);
1776        assert_eq!(names, vec!["canonical"]);
1777    }
1778
1779    #[test]
1780    fn bundle_true_means_all_production_deps() {
1781        let p =
1782            parse(r#"{"name":"x","dependencies":{"a":"1","b":"2"},"bundledDependencies":true}"#);
1783        let names = p
1784            .bundled_dependencies
1785            .as_ref()
1786            .unwrap()
1787            .names(&p.dependencies);
1788        assert_eq!(names, vec!["a", "b"]);
1789    }
1790
1791    #[test]
1792    fn peer_dependency_rules_accessors_read_nested_pnpm_block() {
1793        let p = parse(
1794            r#"{
1795                "name":"x",
1796                "pnpm": {
1797                    "peerDependencyRules": {
1798                        "ignoreMissing": ["react", "react-dom"],
1799                        "allowAny": ["@types/*"],
1800                        "allowedVersions": {
1801                            "react": "^18.0.0",
1802                            "styled-components>react": "^17.0.0",
1803                            "ignored": 42
1804                        }
1805                    }
1806                }
1807            }"#,
1808        );
1809        assert_eq!(
1810            p.pnpm_peer_dependency_rules_ignore_missing(),
1811            vec!["react".to_string(), "react-dom".to_string()],
1812        );
1813        assert_eq!(
1814            p.pnpm_peer_dependency_rules_allow_any(),
1815            vec!["@types/*".to_string()],
1816        );
1817        let allowed = p.pnpm_peer_dependency_rules_allowed_versions();
1818        assert_eq!(allowed.get("react").map(String::as_str), Some("^18.0.0"));
1819        assert_eq!(
1820            allowed.get("styled-components>react").map(String::as_str),
1821            Some("^17.0.0"),
1822        );
1823        assert!(!allowed.contains_key("ignored"));
1824    }
1825
1826    #[test]
1827    fn peer_dependency_rules_accessors_empty_when_missing() {
1828        let p = parse(r#"{"name":"x"}"#);
1829        assert!(p.pnpm_peer_dependency_rules_ignore_missing().is_empty());
1830        assert!(p.pnpm_peer_dependency_rules_allow_any().is_empty());
1831        assert!(p.pnpm_peer_dependency_rules_allowed_versions().is_empty());
1832    }
1833
1834    // --- aube.* namespace parity --------------------------------------
1835
1836    #[test]
1837    fn aube_namespace_read_when_pnpm_missing() {
1838        let p = parse(
1839            r#"{
1840                "aube": {
1841                    "onlyBuiltDependencies": ["esbuild"],
1842                    "neverBuiltDependencies": ["sharp"],
1843                    "ignoredOptionalDependencies": ["fsevents"],
1844                    "patchedDependencies": {"lodash@4.17.21": "patches/lodash.patch"},
1845                    "catalog": {"react": "^18.0.0"},
1846                    "catalogs": {"legacy": {"react": "^17.0.0"}},
1847                    "supportedArchitectures": {"os": ["linux", "win32"], "cpu": ["x64"]},
1848                    "overrides": {"lodash": "4.17.21"},
1849                    "packageExtensions": {"foo": {"dependencies": {"a": "1"}}},
1850                    "allowedDeprecatedVersions": {"request": "*"},
1851                    "peerDependencyRules": {
1852                        "ignoreMissing": ["react-native"],
1853                        "allowAny": ["@types/*"],
1854                        "allowedVersions": {"react": "^18.0.0"}
1855                    },
1856                    "updateConfig": {"ignoreDependencies": ["typescript"]},
1857                    "allowBuilds": {"esbuild": true}
1858                }
1859            }"#,
1860        );
1861        assert_eq!(p.pnpm_only_built_dependencies(), vec!["esbuild"]);
1862        assert_eq!(p.pnpm_never_built_dependencies(), vec!["sharp"]);
1863        assert!(p.pnpm_ignored_optional_dependencies().contains("fsevents"));
1864        assert_eq!(
1865            p.pnpm_patched_dependencies().get("lodash@4.17.21").unwrap(),
1866            "patches/lodash.patch",
1867        );
1868        assert_eq!(p.pnpm_catalog().get("react").unwrap(), "^18.0.0");
1869        assert_eq!(
1870            p.pnpm_catalogs()
1871                .get("legacy")
1872                .and_then(|c| c.get("react"))
1873                .unwrap(),
1874            "^17.0.0",
1875        );
1876        let (os, cpu, libc) = p.pnpm_supported_architectures();
1877        assert_eq!(os, vec!["linux", "win32"]);
1878        assert_eq!(cpu, vec!["x64"]);
1879        assert!(libc.is_empty());
1880        assert_eq!(p.overrides_map().get("lodash").unwrap(), "4.17.21");
1881        assert!(p.package_extensions().contains_key("foo"));
1882        assert_eq!(p.allowed_deprecated_versions().get("request").unwrap(), "*",);
1883        assert_eq!(
1884            p.pnpm_peer_dependency_rules_ignore_missing(),
1885            vec!["react-native".to_string()],
1886        );
1887        assert_eq!(
1888            p.pnpm_peer_dependency_rules_allow_any(),
1889            vec!["@types/*".to_string()],
1890        );
1891        assert_eq!(
1892            p.pnpm_peer_dependency_rules_allowed_versions()
1893                .get("react")
1894                .unwrap(),
1895            "^18.0.0",
1896        );
1897        assert_eq!(p.update_ignore_dependencies(), vec!["typescript"]);
1898        assert!(matches!(
1899            p.pnpm_allow_builds().get("esbuild"),
1900            Some(AllowBuildRaw::Bool(true)),
1901        ));
1902    }
1903
1904    #[test]
1905    fn pnpm_allow_builds_round_trips_string_values_unwrapped() {
1906        // Regression: `serde_json::Value::to_string()` on a string value
1907        // produces JSON-encoded output (`"\"foo\""` with the outer
1908        // quotes baked in). `AllowBuildRaw::from_json` must store the
1909        // inner string verbatim so a downstream equality check against
1910        // a known placeholder works and any user-visible warning shows
1911        // the value the user actually wrote — not a re-quoted form.
1912        let p = parse(
1913            r#"{
1914                "pnpm": {
1915                    "allowBuilds": {
1916                        "esbuild": "set this to true or false"
1917                    }
1918                }
1919            }"#,
1920        );
1921        let map = p.pnpm_allow_builds();
1922        assert_eq!(
1923            map.get("esbuild"),
1924            Some(&AllowBuildRaw::Other(
1925                "set this to true or false".to_string()
1926            )),
1927        );
1928    }
1929
1930    #[test]
1931    fn trusted_dependencies_reads_top_level_bun_format() {
1932        let p = parse(
1933            r#"{
1934                "trustedDependencies": ["esbuild", "sharp", "esbuild"]
1935            }"#,
1936        );
1937        assert_eq!(p.trusted_dependencies(), vec!["esbuild", "sharp"]);
1938    }
1939
1940    #[test]
1941    fn trusted_dependencies_absent_returns_empty() {
1942        let p = parse(r#"{}"#);
1943        assert!(p.trusted_dependencies().is_empty());
1944    }
1945
1946    #[test]
1947    fn trusted_dependencies_wrong_shape_returns_empty() {
1948        let p = parse(r#"{"trustedDependencies": {"esbuild": true}}"#);
1949        assert!(p.trusted_dependencies().is_empty());
1950    }
1951
1952    #[test]
1953    fn bun_patched_dependencies_reads_top_level_field() {
1954        let p = parse(
1955            r#"{
1956                "patchedDependencies": {
1957                    "is-number@7.0.0": "patches/is-number.patch",
1958                    "ignored@1.0.0": false
1959                }
1960            }"#,
1961        );
1962        let got = p.bun_patched_dependencies();
1963        assert_eq!(
1964            got.get("is-number@7.0.0").unwrap(),
1965            "patches/is-number.patch"
1966        );
1967        assert!(!got.contains_key("ignored@1.0.0"));
1968    }
1969
1970    #[test]
1971    fn aube_overrides_pnpm_on_key_conflict() {
1972        // For map-valued configs, `aube.*` wins on key conflict while
1973        // disjoint keys from either namespace merge.
1974        let p = parse(
1975            r#"{
1976                "pnpm": {
1977                    "catalog": {"react": "^17.0.0", "lodash": "^4.0.0"},
1978                    "patchedDependencies": {"foo@1": "pnpm.patch"},
1979                    "allowedDeprecatedVersions": {"request": "^2.0.0"},
1980                    "overrides": {"lodash": "pnpm-value"}
1981                },
1982                "aube": {
1983                    "catalog": {"react": "^18.0.0"},
1984                    "patchedDependencies": {"foo@1": "aube.patch"},
1985                    "allowedDeprecatedVersions": {"request": "^3.0.0"},
1986                    "overrides": {"lodash": "aube-value"}
1987                }
1988            }"#,
1989        );
1990        let catalog = p.pnpm_catalog();
1991        assert_eq!(catalog.get("react").unwrap(), "^18.0.0");
1992        assert_eq!(catalog.get("lodash").unwrap(), "^4.0.0");
1993        assert_eq!(
1994            p.pnpm_patched_dependencies().get("foo@1").unwrap(),
1995            "aube.patch",
1996        );
1997        assert_eq!(
1998            p.allowed_deprecated_versions().get("request").unwrap(),
1999            "^3.0.0",
2000        );
2001        assert_eq!(p.overrides_map().get("lodash").unwrap(), "aube-value");
2002    }
2003
2004    #[test]
2005    fn top_level_overrides_still_beat_aube_namespace() {
2006        // Top-level `overrides` is the npm-standard surface and
2007        // remains the highest-priority source.
2008        let p = parse(
2009            r#"{
2010                "pnpm": {"overrides": {"lodash": "1"}},
2011                "aube": {"overrides": {"lodash": "2"}},
2012                "overrides": {"lodash": "3"}
2013            }"#,
2014        );
2015        assert_eq!(p.overrides_map().get("lodash").unwrap(), "3");
2016    }
2017
2018    #[test]
2019    fn aube_supported_architectures_merges_with_pnpm() {
2020        let p = parse(
2021            r#"{
2022                "pnpm": {"supportedArchitectures": {"os": ["linux"], "cpu": ["x64"]}},
2023                "aube": {"supportedArchitectures": {"os": ["win32"], "libc": ["glibc"]}}
2024            }"#,
2025        );
2026        let (os, cpu, libc) = p.pnpm_supported_architectures();
2027        assert_eq!(os, vec!["linux", "win32"]);
2028        assert_eq!(cpu, vec!["x64"]);
2029        assert_eq!(libc, vec!["glibc"]);
2030    }
2031
2032    #[test]
2033    fn aube_list_configs_union_with_pnpm() {
2034        let p = parse(
2035            r#"{
2036                "pnpm": {
2037                    "onlyBuiltDependencies": ["esbuild"],
2038                    "neverBuiltDependencies": ["sharp"],
2039                    "ignoredOptionalDependencies": ["fsevents"],
2040                    "peerDependencyRules": {
2041                        "ignoreMissing": ["react"],
2042                        "allowAny": ["@types/a"]
2043                    }
2044                },
2045                "aube": {
2046                    "onlyBuiltDependencies": ["swc"],
2047                    "neverBuiltDependencies": ["node-gyp"],
2048                    "ignoredOptionalDependencies": ["dtrace-provider"],
2049                    "peerDependencyRules": {
2050                        "ignoreMissing": ["react-native"],
2051                        "allowAny": ["@types/b"]
2052                    }
2053                }
2054            }"#,
2055        );
2056        assert_eq!(p.pnpm_only_built_dependencies(), vec!["esbuild", "swc"]);
2057        assert_eq!(p.pnpm_never_built_dependencies(), vec!["sharp", "node-gyp"]);
2058        let ignored = p.pnpm_ignored_optional_dependencies();
2059        assert!(ignored.contains("fsevents"));
2060        assert!(ignored.contains("dtrace-provider"));
2061        assert_eq!(
2062            p.pnpm_peer_dependency_rules_ignore_missing(),
2063            vec!["react".to_string(), "react-native".to_string()],
2064        );
2065        assert_eq!(
2066            p.pnpm_peer_dependency_rules_allow_any(),
2067            vec!["@types/a".to_string(), "@types/b".to_string()],
2068        );
2069    }
2070
2071    #[test]
2072    fn effective_supported_architectures_unions_manifest_and_workspace() {
2073        let p = parse(
2074            r#"{
2075                "pnpm": {
2076                    "supportedArchitectures": {
2077                        "os": ["current", "linux"],
2078                        "cpu": ["x64"]
2079                    }
2080                }
2081            }"#,
2082        );
2083        let ws: workspace::WorkspaceConfig = yaml_serde::from_str(
2084            r#"
2085supportedArchitectures:
2086  os: ["win32"]
2087  cpu: ["x64", "arm64"]
2088  libc: ["glibc"]
2089"#,
2090        )
2091        .unwrap();
2092        let (os, cpu, libc) = effective_supported_architectures(&p, &ws);
2093        // Manifest first, workspace appended, duplicates dropped.
2094        assert_eq!(os, vec!["current", "linux", "win32"]);
2095        assert_eq!(cpu, vec!["x64", "arm64"]);
2096        assert_eq!(libc, vec!["glibc"]);
2097    }
2098
2099    #[test]
2100    fn effective_supported_architectures_works_without_either_source() {
2101        let p = parse(r#"{}"#);
2102        let ws = workspace::WorkspaceConfig::default();
2103        let (os, cpu, libc) = effective_supported_architectures(&p, &ws);
2104        assert!(os.is_empty() && cpu.is_empty() && libc.is_empty());
2105    }
2106
2107    #[test]
2108    fn effective_ignored_optional_dependencies_unions_manifest_and_workspace() {
2109        let p = parse(
2110            r#"{
2111                "pnpm": { "ignoredOptionalDependencies": ["fsevents"] }
2112            }"#,
2113        );
2114        let ws: workspace::WorkspaceConfig = yaml_serde::from_str(
2115            r#"
2116ignoredOptionalDependencies:
2117  - dtrace-provider
2118  - fsevents
2119"#,
2120        )
2121        .unwrap();
2122        let merged = effective_ignored_optional_dependencies(&p, &ws);
2123        assert!(merged.contains("fsevents"));
2124        assert!(merged.contains("dtrace-provider"));
2125        assert_eq!(merged.len(), 2);
2126    }
2127
2128    #[test]
2129    fn aube_catalogs_merge_per_key_within_named_catalog() {
2130        // Same semantics as `pnpm_catalog`: aube wins per-key, and
2131        // entries only declared on one side are preserved instead of
2132        // being dropped when the catalog name exists on both sides.
2133        let p = parse(
2134            r#"{
2135                "pnpm": {
2136                    "catalogs": {
2137                        "default": {"react": "^17.0.0", "lodash": "^4.0.0"},
2138                        "legacy": {"webpack": "^4.0.0"}
2139                    }
2140                },
2141                "aube": {
2142                    "catalogs": {
2143                        "default": {"react": "^18.0.0", "vite": "^5.0.0"}
2144                    }
2145                }
2146            }"#,
2147        );
2148        let cats = p.pnpm_catalogs();
2149        let default = cats.get("default").expect("default catalog present");
2150        assert_eq!(default.get("react").unwrap(), "^18.0.0");
2151        assert_eq!(default.get("lodash").unwrap(), "^4.0.0");
2152        assert_eq!(default.get("vite").unwrap(), "^5.0.0");
2153        let legacy = cats.get("legacy").expect("legacy catalog preserved");
2154        assert_eq!(legacy.get("webpack").unwrap(), "^4.0.0");
2155    }
2156
2157    #[test]
2158    fn aube_list_configs_dedupe_duplicates_across_namespaces() {
2159        // Union semantics imply dedup: a name listed in both
2160        // namespaces appears once, with first-seen ordering preserved.
2161        let p = parse(
2162            r#"{
2163                "pnpm": {
2164                    "onlyBuiltDependencies": ["esbuild", "sharp"],
2165                    "neverBuiltDependencies": ["evil"],
2166                    "peerDependencyRules": {
2167                        "ignoreMissing": ["react"],
2168                        "allowAny": ["@types/a"]
2169                    }
2170                },
2171                "aube": {
2172                    "onlyBuiltDependencies": ["esbuild", "swc"],
2173                    "neverBuiltDependencies": ["evil", "node-gyp"],
2174                    "peerDependencyRules": {
2175                        "ignoreMissing": ["react", "react-native"],
2176                        "allowAny": ["@types/a", "@types/b"]
2177                    }
2178                }
2179            }"#,
2180        );
2181        assert_eq!(
2182            p.pnpm_only_built_dependencies(),
2183            vec!["esbuild", "sharp", "swc"],
2184        );
2185        assert_eq!(p.pnpm_never_built_dependencies(), vec!["evil", "node-gyp"]);
2186        assert_eq!(
2187            p.pnpm_peer_dependency_rules_ignore_missing(),
2188            vec!["react".to_string(), "react-native".to_string()],
2189        );
2190        assert_eq!(
2191            p.pnpm_peer_dependency_rules_allow_any(),
2192            vec!["@types/a".to_string(), "@types/b".to_string()],
2193        );
2194    }
2195
2196    #[test]
2197    fn aube_update_config_merges_with_pnpm_and_top_level() {
2198        let p = parse(
2199            r#"{
2200                "pnpm": {"updateConfig": {"ignoreDependencies": ["a"]}},
2201                "aube": {"updateConfig": {"ignoreDependencies": ["b"]}},
2202                "updateConfig": {"ignoreDependencies": ["c"]}
2203            }"#,
2204        );
2205        assert_eq!(p.update_ignore_dependencies(), vec!["a", "b", "c"]);
2206    }
2207}