Skip to main content

fallow_config/workspace/
package_json.rs

1use serde::{Deserialize, Deserializer, Serialize, de::Error};
2
3/// Type alias for standard `HashMap` used in serde-deserialized structs.
4/// `rustc-hash` v2 does not have a `serde` feature, so fields deserialized
5/// from JSON must use `std::collections::HashMap`.
6#[expect(
7    clippy::disallowed_types,
8    reason = "rustc-hash v2 lacks serde feature — JSON deserialization needs std HashMap"
9)]
10type StdHashMap<K, V> = std::collections::HashMap<K, V>;
11
12fn deserialize_optional_bool_lenient<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
13where
14    D: Deserializer<'de>,
15{
16    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
17    Ok(match value {
18        Some(serde_json::Value::Bool(value)) => Some(value),
19        _ => None,
20    })
21}
22
23fn deserialize_string_array_lenient<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
24where
25    D: Deserializer<'de>,
26{
27    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
28    Ok(match value {
29        Some(serde_json::Value::Array(values)) => values
30            .into_iter()
31            .filter_map(|value| value.as_str().map(str::to_owned))
32            .collect(),
33        _ => Vec::new(),
34    })
35}
36
37fn deserialize_optional_string_lenient<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
38where
39    D: Deserializer<'de>,
40{
41    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
42    Ok(match value {
43        Some(serde_json::Value::String(value)) if !value.trim().is_empty() => Some(value),
44        _ => None,
45    })
46}
47
48fn deserialize_optional_napi_config<'de, D>(deserializer: D) -> Result<Option<NapiConfig>, D::Error>
49where
50    D: Deserializer<'de>,
51{
52    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
53    match value {
54        Some(serde_json::Value::Object(map)) => {
55            serde_json::from_value(serde_json::Value::Object(map))
56                .map(Some)
57                .map_err(D::Error::custom)
58        }
59        _ => Ok(None),
60    }
61}
62
63#[derive(Debug, Clone, Default, Deserialize, Serialize)]
64pub struct PeerDependencyMeta {
65    #[serde(default)]
66    pub optional: bool,
67}
68
69/// The NAPI-RS `napi` block of a `package.json`, used to recognize the
70/// per-platform artifact packages a native addon generates so they are not
71/// reported as unused dependencies. All fields deserialize leniently: a
72/// wrong-typed value becomes `None`/empty instead of failing manifest parsing.
73#[derive(Debug, Clone, Default, Deserialize, Serialize)]
74pub struct NapiConfig {
75    /// The `binaryName` field: basename of the generated `.node` binary.
76    #[serde(
77        default,
78        rename = "binaryName",
79        deserialize_with = "deserialize_optional_string_lenient"
80    )]
81    pub binary_name: Option<String>,
82    /// The `packageName` field: base npm package name from which per-platform
83    /// artifact package names (`<name>-<triple>`) are derived.
84    #[serde(
85        default,
86        rename = "packageName",
87        deserialize_with = "deserialize_optional_string_lenient"
88    )]
89    pub package_name: Option<String>,
90    /// The `targets` field: platform triples the addon is built for.
91    #[serde(default, deserialize_with = "deserialize_string_array_lenient")]
92    pub targets: Vec<String>,
93}
94
95/// Parsed package.json with fields relevant to fallow.
96#[derive(Debug, Clone, Default, Deserialize, Serialize)]
97pub struct PackageJson {
98    /// The `name` field: the package's npm name, used to identify workspace
99    /// packages and match `public_packages` config entries.
100    #[serde(default)]
101    pub name: Option<String>,
102    /// The `private` field. Non-boolean values become `None` rather than
103    /// failing manifest parsing.
104    #[serde(default, deserialize_with = "deserialize_optional_bool_lenient")]
105    pub private: Option<bool>,
106    /// The `main` field: CommonJS entry-point path, seeded as an entry point.
107    #[serde(default)]
108    pub main: Option<String>,
109    /// The `module` field: ESM entry-point path, seeded as an entry point.
110    #[serde(default)]
111    pub module: Option<String>,
112    /// The `types` field: type-declaration entry-point path.
113    #[serde(default)]
114    pub types: Option<String>,
115    /// The `typings` field: legacy spelling of `types`.
116    #[serde(default)]
117    pub typings: Option<String>,
118    /// The `source` field: unbundled source entry point used by bundler-based
119    /// packages (Parcel convention).
120    #[serde(default)]
121    pub source: Option<String>,
122    /// The `files` field: publish allowlist patterns. Non-array values become
123    /// empty rather than failing manifest parsing.
124    #[serde(default, deserialize_with = "deserialize_string_array_lenient")]
125    pub files: Vec<String>,
126    /// The `browser` field, kept raw because it is either a string entry point
127    /// or a path-remap object.
128    #[serde(default)]
129    pub browser: Option<serde_json::Value>,
130    /// The `bin` field, kept raw because it is either a string path or a
131    /// name-to-path object; every referenced file is an entry point.
132    #[serde(default)]
133    pub bin: Option<serde_json::Value>,
134    /// The `typescript` field, kept raw. Its `contentMapper.exec` command
135    /// names a script that TypeScript runs.
136    #[serde(default)]
137    pub typescript: Option<serde_json::Value>,
138    /// The `exports` field, kept raw because of its many shapes (string,
139    /// conditions object, subpath map); referenced files are entry points and
140    /// the map drives subpath resolution.
141    #[serde(default)]
142    pub exports: Option<serde_json::Value>,
143    /// The `imports` field (`#`-prefixed internal subpath map), kept raw and
144    /// consumed by the module resolver.
145    #[serde(default)]
146    pub imports: Option<serde_json::Value>,
147    /// The `dependencies` map (package name to version range), the base set
148    /// for unused/unlisted-dependency accounting.
149    #[serde(default)]
150    pub dependencies: Option<StdHashMap<String, String>>,
151    /// The `devDependencies` map, checked by `unused-dev-dependencies` and
152    /// `dev-dependency-in-production`.
153    #[serde(default, rename = "devDependencies")]
154    pub dev_dependencies: Option<StdHashMap<String, String>>,
155    /// The `peerDependencies` map. Peers are provided by the consumer, so
156    /// they are exempt from unused-dependency reporting.
157    #[serde(default, rename = "peerDependencies")]
158    pub peer_dependencies: Option<StdHashMap<String, String>>,
159    /// The `peerDependenciesMeta` map, marking which peers are optional.
160    #[serde(default, rename = "peerDependenciesMeta")]
161    pub peer_dependencies_meta: Option<StdHashMap<String, PeerDependencyMeta>>,
162    /// The `optionalDependencies` map, checked by
163    /// `unused-optional-dependencies`.
164    #[serde(default, rename = "optionalDependencies")]
165    pub optional_dependencies: Option<StdHashMap<String, String>>,
166    /// The `scripts` map, scanned so packages invoked from scripts count as
167    /// used tooling dependencies.
168    #[serde(default)]
169    pub scripts: Option<StdHashMap<String, String>>,
170    /// The NAPI-RS `napi` block; see [`NapiConfig`]. Non-object values become
171    /// `None` rather than failing manifest parsing.
172    #[serde(default, deserialize_with = "deserialize_optional_napi_config")]
173    pub napi: Option<NapiConfig>,
174    /// The `workspaces` field, kept raw because it is either a pattern array
175    /// or an object with a `packages` array; drives workspace discovery.
176    #[serde(default)]
177    pub workspaces: Option<serde_json::Value>,
178    /// The `packageManager` field (e.g. `"pnpm@9.1.0"`), used to determine
179    /// the canonical package manager for the project.
180    #[serde(
181        default,
182        rename = "packageManager",
183        deserialize_with = "deserialize_optional_string_lenient"
184    )]
185    pub package_manager: Option<String>,
186    /// The Egg.js `egg` block (for example `framework` and `tscompiler`),
187    /// kept raw so that an unexpected shape does not fail manifest parsing.
188    #[serde(default)]
189    pub egg: Option<serde_json::Value>,
190    /// The Egg.js `eggPlugin` block of a plugin package, kept raw.
191    #[serde(default, rename = "eggPlugin")]
192    pub egg_plugin: Option<serde_json::Value>,
193    /// The tegg `eggModule` block of a module package, kept raw.
194    #[serde(default, rename = "eggModule")]
195    pub egg_module: Option<serde_json::Value>,
196}
197
198impl PackageJson {
199    /// Load from a package.json file.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error string when the file cannot be read or parsed as JSON.
204    pub fn load(path: &std::path::Path) -> Result<Self, String> {
205        let content = std::fs::read_to_string(path)
206            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
207        let content = content.trim_start_matches('\u{FEFF}');
208        serde_json::from_str(content)
209            .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
210    }
211
212    /// Get all dependency names (production + dev + peer + optional).
213    #[must_use]
214    pub fn all_dependency_names(&self) -> Vec<String> {
215        let mut deps = Vec::new();
216        if let Some(d) = &self.dependencies {
217            deps.extend(d.keys().cloned());
218        }
219        if let Some(d) = &self.dev_dependencies {
220            deps.extend(d.keys().cloned());
221        }
222        if let Some(d) = &self.peer_dependencies {
223            deps.extend(d.keys().cloned());
224        }
225        if let Some(d) = &self.optional_dependencies {
226            deps.extend(d.keys().cloned());
227        }
228        deps
229    }
230
231    /// Get production dependency names only.
232    #[must_use]
233    pub fn production_dependency_names(&self) -> Vec<String> {
234        self.dependencies
235            .as_ref()
236            .map(|d| d.keys().cloned().collect())
237            .unwrap_or_default()
238    }
239
240    /// Get dev dependency names only.
241    #[must_use]
242    pub fn dev_dependency_names(&self) -> Vec<String> {
243        self.dev_dependencies
244            .as_ref()
245            .map(|d| d.keys().cloned().collect())
246            .unwrap_or_default()
247    }
248
249    /// Get optional dependency names only.
250    #[must_use]
251    pub fn optional_dependency_names(&self) -> Vec<String> {
252        self.optional_dependencies
253            .as_ref()
254            .map(|d| d.keys().cloned().collect())
255            .unwrap_or_default()
256    }
257
258    /// Get every peer dependency name, required or optional.
259    #[must_use]
260    pub fn peer_dependency_names(&self) -> Vec<String> {
261        self.peer_dependencies
262            .as_ref()
263            .map(|deps| deps.keys().cloned().collect())
264            .unwrap_or_default()
265    }
266
267    /// Whether `peerDependenciesMeta` marks the peer `dep` as optional.
268    #[must_use]
269    pub fn peer_dependency_is_optional(&self, dep: &str) -> bool {
270        self.peer_dependencies_meta
271            .as_ref()
272            .and_then(|meta| meta.get(dep))
273            .is_some_and(|meta| meta.optional)
274    }
275
276    /// Extract entry points from package.json fields.
277    #[must_use]
278    pub fn entry_points(&self) -> Vec<String> {
279        let mut entries = Vec::new();
280
281        if let Some(main) = &self.main {
282            entries.push(main.clone());
283        }
284        if let Some(module) = &self.module {
285            entries.push(module.clone());
286        }
287        if let Some(types) = &self.types {
288            entries.push(types.clone());
289        }
290        if let Some(typings) = &self.typings {
291            entries.push(typings.clone());
292        }
293        if let Some(source) = &self.source {
294            entries.push(source.clone());
295        }
296
297        if let Some(browser) = &self.browser {
298            match browser {
299                serde_json::Value::String(s) => entries.push(s.clone()),
300                serde_json::Value::Object(map) => {
301                    for v in map.values() {
302                        if let serde_json::Value::String(s) = v
303                            && (s.starts_with("./") || s.starts_with("../"))
304                        {
305                            entries.push(s.clone());
306                        }
307                    }
308                }
309                _ => {}
310            }
311        }
312
313        if let Some(bin) = &self.bin {
314            match bin {
315                serde_json::Value::String(s) => entries.push(s.clone()),
316                serde_json::Value::Object(map) => {
317                    for v in map.values() {
318                        if let serde_json::Value::String(s) = v {
319                            entries.push(s.clone());
320                        }
321                    }
322                }
323                _ => {}
324            }
325        }
326
327        if let Some(exports) = &self.exports {
328            extract_exports_entries(exports, &mut entries);
329        }
330
331        entries
332    }
333
334    /// Extract unique subdirectory names referenced by the `exports` field keys.
335    ///
336    /// For exports like `"./compat": { ... }`, `"./hooks/client": { ... }`,
337    /// this returns `["compat", "hooks"]`. Used to discover sub-packages in
338    /// projects that define their internal package structure via the exports map
339    /// (e.g., preact with `compat/`, `hooks/`, `debug/` sub-packages).
340    #[must_use]
341    pub fn exports_subdirectories(&self) -> Vec<String> {
342        self.exports
343            .as_ref()
344            .map_or_else(Vec::new, extract_exports_subdirectories)
345    }
346
347    /// Extract workspace patterns from package.json.
348    #[must_use]
349    pub fn workspace_patterns(&self) -> Vec<String> {
350        match &self.workspaces {
351            Some(serde_json::Value::Array(arr)) => arr
352                .iter()
353                .filter_map(|v| v.as_str().map(String::from))
354                .collect(),
355            Some(serde_json::Value::Object(obj)) => obj
356                .get("packages")
357                .and_then(|v| v.as_array())
358                .map(|arr| {
359                    arr.iter()
360                        .filter_map(|v| v.as_str().map(String::from))
361                        .collect()
362                })
363                .unwrap_or_default(),
364            _ => Vec::new(),
365        }
366    }
367
368    /// Dependencies with a `link:` or `file:` spec in `dependencies`,
369    /// `devDependencies` or `optionalDependencies`, as `(name, target)` pairs
370    /// sorted by name.
371    ///
372    /// The target is the path as written, relative to this manifest's
373    /// directory. The caller decides which targets are real package
374    /// directories.
375    #[must_use]
376    pub fn local_link_dependencies(&self) -> Vec<(String, String)> {
377        let mut links: Vec<(String, String)> = [
378            &self.dependencies,
379            &self.dev_dependencies,
380            &self.optional_dependencies,
381        ]
382        .into_iter()
383        .flatten()
384        .flatten()
385        .filter_map(|(name, spec)| {
386            let target = spec
387                .strip_prefix("link:")
388                .or_else(|| spec.strip_prefix("file:"))?
389                .trim();
390            (!target.is_empty()).then(|| (name.clone(), target.to_string()))
391        })
392        .collect();
393        links.sort_unstable();
394        links.dedup();
395        links
396    }
397
398    /// The workspace package that the dependency `name` is an alias of.
399    ///
400    /// pnpm installs `"vite": "workspace:@scope/core@*"` as the workspace
401    /// package `@scope/core` under the name `vite`. A `workspace:` spec with
402    /// only a range (`workspace:*`, `workspace:^1.0.0`) or a path
403    /// (`workspace:../core`) is not an alias.
404    #[must_use]
405    pub fn workspace_alias_target(&self, name: &str) -> Option<&str> {
406        [
407            &self.dependencies,
408            &self.dev_dependencies,
409            &self.optional_dependencies,
410            &self.peer_dependencies,
411        ]
412        .into_iter()
413        .flatten()
414        .find_map(|deps| deps.get(name))
415        .and_then(|spec| workspace_alias_name(spec))
416    }
417
418    /// Every dependency that the manifest installs under an npm alias, as
419    /// `(alias, aliased package)` pairs.
420    ///
421    /// `"@typescript/native": "npm:typescript@~7.0.2"` installs the package
422    /// `typescript` in `node_modules/@typescript/native`, so the alias
423    /// provides the binaries of `typescript`.
424    #[must_use]
425    pub fn npm_aliases(&self) -> Vec<(String, String)> {
426        let mut aliases: Vec<(String, String)> = [
427            &self.dependencies,
428            &self.dev_dependencies,
429            &self.optional_dependencies,
430            &self.peer_dependencies,
431        ]
432        .into_iter()
433        .flatten()
434        .flat_map(|deps| deps.iter())
435        .filter_map(|(name, spec)| {
436            let target = npm_alias_name(spec)?;
437            (target != name).then(|| (name.clone(), target.to_string()))
438        })
439        .collect();
440        aliases.sort_unstable();
441        aliases.dedup();
442        aliases
443    }
444}
445
446/// The package name in a `workspace:<name>@<range>` dependency spec.
447fn workspace_alias_name(spec: &str) -> Option<&str> {
448    let rest = spec.trim().strip_prefix("workspace:")?;
449    let separator = if let Some(scoped) = rest.strip_prefix('@') {
450        scoped.find('@')? + 1
451    } else {
452        rest.find('@')?
453    };
454    valid_package_name(&rest[..separator])
455}
456
457/// The package name in an `npm:<name>@<range>` or `npm:<name>` dependency
458/// spec.
459fn npm_alias_name(spec: &str) -> Option<&str> {
460    let rest = spec.trim().strip_prefix("npm:")?;
461    let separator = if let Some(scoped) = rest.strip_prefix('@') {
462        scoped.find('@').map_or(rest.len(), |at| at + 1)
463    } else {
464        rest.find('@').unwrap_or(rest.len())
465    };
466    valid_package_name(&rest[..separator])
467}
468
469/// `name` when it has the shape of a package name: `name` or `@scope/name`.
470fn valid_package_name(name: &str) -> Option<&str> {
471    let is_package_name = !name.is_empty()
472        && !name.starts_with('.')
473        && match name.strip_prefix('@') {
474            Some(scoped) => scoped
475                .split_once('/')
476                .is_some_and(|(scope, base)| !scope.is_empty() && !base.is_empty()),
477            None => !name.contains('/'),
478        };
479    is_package_name.then_some(name)
480}
481
482/// Extract unique subdirectory names referenced by the `exports` field keys.
483///
484/// For exports like `"./compat": { ... }`, `"./hooks/client": { ... }`,
485/// this returns `["compat", "hooks"]`. Used to discover sub-packages in
486/// projects that use the exports map to define their internal package structure
487/// (e.g., preact with `compat/`, `hooks/`, `debug/` sub-packages).
488fn extract_exports_subdirectories(exports: &serde_json::Value) -> Vec<String> {
489    let Some(map) = exports.as_object() else {
490        return Vec::new();
491    };
492
493    let skip_dirs = ["dist", "build", "out", "esm", "cjs", "lib", "node_modules"];
494    let mut dirs = rustc_hash::FxHashSet::default();
495
496    for key in map.keys() {
497        let stripped = key.strip_prefix("./").unwrap_or(key);
498        if let Some(first_segment) = stripped.split('/').next()
499            && !first_segment.is_empty()
500            && first_segment != "."
501            && first_segment != "package.json"
502            && !skip_dirs.contains(&first_segment)
503        {
504            dirs.insert(first_segment.to_owned());
505        }
506    }
507
508    dirs.into_iter().collect()
509}
510
511/// Recursively extract file paths from package.json exports field.
512fn extract_exports_entries(value: &serde_json::Value, entries: &mut Vec<String>) {
513    match value {
514        serde_json::Value::String(s) if s.starts_with("./") || s.starts_with("../") => {
515            entries.push(s.clone());
516        }
517        serde_json::Value::Object(map) => {
518            for v in map.values() {
519                extract_exports_entries(v, entries);
520            }
521        }
522        serde_json::Value::Array(arr) => {
523            for v in arr {
524                extract_exports_entries(v, entries);
525            }
526        }
527        _ => {}
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn package_json_workspace_patterns_array() {
537        let pkg: PackageJson =
538            serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();
539        let patterns = pkg.workspace_patterns();
540        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
541    }
542
543    #[test]
544    fn package_json_workspace_patterns_object() {
545        let pkg: PackageJson =
546            serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();
547        let patterns = pkg.workspace_patterns();
548        assert_eq!(patterns, vec!["packages/*"]);
549    }
550
551    #[test]
552    fn package_json_workspace_patterns_none() {
553        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
554        let patterns = pkg.workspace_patterns();
555        assert!(patterns.is_empty());
556    }
557
558    #[test]
559    fn package_json_workspace_patterns_empty_array() {
560        let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": []}"#).unwrap();
561        let patterns = pkg.workspace_patterns();
562        assert!(patterns.is_empty());
563    }
564
565    #[test]
566    fn package_json_load_valid() {
567        let temp_dir = std::env::temp_dir().join("fallow-test-pkg-json");
568        let _ = std::fs::create_dir_all(&temp_dir);
569        let pkg_path = temp_dir.join("package.json");
570        std::fs::write(&pkg_path, r#"{"name": "test", "main": "index.js"}"#).unwrap();
571
572        let pkg = PackageJson::load(&pkg_path).unwrap();
573        assert_eq!(pkg.name, Some("test".to_string()));
574        assert_eq!(pkg.main, Some("index.js".to_string()));
575
576        let _ = std::fs::remove_dir_all(&temp_dir);
577    }
578
579    #[test]
580    fn package_json_private_non_bool_values_are_ignored() {
581        for raw in [
582            r#"{"private": "true"}"#,
583            r#"{"private": 1}"#,
584            r#"{"private": null}"#,
585        ] {
586            let pkg: PackageJson = serde_json::from_str(raw).unwrap();
587            assert_eq!(pkg.private, None);
588        }
589
590        let pkg: PackageJson = serde_json::from_str(r#"{"private": true}"#).unwrap();
591        assert_eq!(pkg.private, Some(true));
592
593        let pkg: PackageJson = serde_json::from_str(r#"{"private": false}"#).unwrap();
594        assert_eq!(pkg.private, Some(false));
595    }
596
597    #[test]
598    fn package_json_files_preserves_string_array_values() {
599        let pkg: PackageJson =
600            serde_json::from_str(r#"{"files": ["index.js", "template-*", "dist"]}"#).unwrap();
601        assert_eq!(pkg.files, vec!["index.js", "template-*", "dist"]);
602    }
603
604    #[test]
605    fn package_json_files_missing_or_unexpected_shapes_are_ignored() {
606        let pkg: PackageJson = serde_json::from_str(r#"{"name": "pkg"}"#).unwrap();
607        assert!(pkg.files.is_empty());
608
609        let pkg: PackageJson = serde_json::from_str(r#"{"files": "dist"}"#).unwrap();
610        assert!(pkg.files.is_empty());
611
612        let pkg: PackageJson =
613            serde_json::from_str(r#"{"files": ["template-*", 42, false, null]}"#).unwrap();
614        assert_eq!(pkg.files, vec!["template-*"]);
615    }
616
617    #[test]
618    fn package_json_napi_config_preserves_current_string_fields() {
619        let pkg: PackageJson = serde_json::from_str(
620            r#"{
621                "napi": {
622                    "binaryName": "srcmap-codec",
623                    "packageName": "@srcmap/codec",
624                    "targets": [
625                        "aarch64-apple-darwin",
626                        "x86_64-unknown-linux-gnu"
627                    ]
628                }
629            }"#,
630        )
631        .unwrap();
632
633        let napi = pkg.napi.expect("napi config");
634        assert_eq!(napi.binary_name.as_deref(), Some("srcmap-codec"));
635        assert_eq!(napi.package_name.as_deref(), Some("@srcmap/codec"));
636        assert_eq!(
637            napi.targets,
638            vec![
639                "aarch64-apple-darwin".to_string(),
640                "x86_64-unknown-linux-gnu".to_string()
641            ]
642        );
643    }
644
645    #[test]
646    fn package_json_napi_config_ignores_unexpected_shapes() {
647        let pkg: PackageJson = serde_json::from_str(r#"{"napi": "enabled"}"#).unwrap();
648        assert!(pkg.napi.is_none());
649
650        let pkg: PackageJson = serde_json::from_str(
651            r#"{
652                "napi": {
653                    "binaryName": 42,
654                    "packageName": "",
655                    "targets": ["x86_64-apple-darwin", false, null]
656                }
657            }"#,
658        )
659        .unwrap();
660
661        let napi = pkg.napi.expect("napi config");
662        assert!(napi.binary_name.is_none());
663        assert!(napi.package_name.is_none());
664        assert_eq!(napi.targets, vec!["x86_64-apple-darwin"]);
665    }
666
667    #[test]
668    fn package_json_load_missing_file() {
669        let result = PackageJson::load(std::path::Path::new("/nonexistent/package.json"));
670        assert!(result.is_err());
671    }
672
673    #[test]
674    fn package_json_entry_points_combined() {
675        let pkg: PackageJson = serde_json::from_str(
676            r#"{
677            "main": "dist/index.js",
678            "module": "dist/index.mjs",
679            "types": "dist/index.d.ts",
680            "typings": "dist/types.d.ts"
681        }"#,
682        )
683        .unwrap();
684        let entries = pkg.entry_points();
685        assert_eq!(entries.len(), 4);
686        assert!(entries.contains(&"dist/index.js".to_string()));
687        assert!(entries.contains(&"dist/index.mjs".to_string()));
688        assert!(entries.contains(&"dist/index.d.ts".to_string()));
689        assert!(entries.contains(&"dist/types.d.ts".to_string()));
690    }
691
692    #[test]
693    fn package_json_exports_nested() {
694        let pkg: PackageJson = serde_json::from_str(
695            r#"{
696            "exports": {
697                ".": {
698                    "import": "./dist/index.mjs",
699                    "require": "./dist/index.cjs"
700                },
701                "./utils": {
702                    "import": "./dist/utils.mjs"
703                }
704            }
705        }"#,
706        )
707        .unwrap();
708        let entries = pkg.entry_points();
709        assert!(entries.contains(&"./dist/index.mjs".to_string()));
710        assert!(entries.contains(&"./dist/index.cjs".to_string()));
711        assert!(entries.contains(&"./dist/utils.mjs".to_string()));
712    }
713
714    #[test]
715    fn package_json_exports_array() {
716        let pkg: PackageJson = serde_json::from_str(
717            r#"{
718            "exports": {
719                ".": ["./dist/index.mjs", "./dist/index.cjs"]
720            }
721        }"#,
722        )
723        .unwrap();
724        let entries = pkg.entry_points();
725        assert!(entries.contains(&"./dist/index.mjs".to_string()));
726        assert!(entries.contains(&"./dist/index.cjs".to_string()));
727    }
728
729    #[test]
730    fn extract_exports_ignores_non_relative() {
731        let pkg: PackageJson = serde_json::from_str(
732            r#"{
733            "exports": {
734                ".": "not-a-relative-path"
735            }
736        }"#,
737        )
738        .unwrap();
739        let entries = pkg.entry_points();
740        assert!(entries.is_empty());
741    }
742
743    #[test]
744    fn package_json_source_field() {
745        let pkg: PackageJson = serde_json::from_str(
746            r#"{
747            "main": "dist/index.js",
748            "source": "src/index.ts"
749        }"#,
750        )
751        .unwrap();
752        let entries = pkg.entry_points();
753        assert!(entries.contains(&"src/index.ts".to_string()));
754        assert!(entries.contains(&"dist/index.js".to_string()));
755    }
756
757    #[test]
758    fn package_json_browser_field_string() {
759        let pkg: PackageJson = serde_json::from_str(
760            r#"{
761            "browser": "./dist/browser.js"
762        }"#,
763        )
764        .unwrap();
765        let entries = pkg.entry_points();
766        assert!(entries.contains(&"./dist/browser.js".to_string()));
767    }
768
769    #[test]
770    fn package_json_browser_field_object() {
771        let pkg: PackageJson = serde_json::from_str(
772            r#"{
773            "browser": {
774                "./server.js": "./browser.js",
775                "module-name": false
776            }
777        }"#,
778        )
779        .unwrap();
780        let entries = pkg.entry_points();
781        assert!(entries.contains(&"./browser.js".to_string()));
782        assert_eq!(entries.len(), 1);
783    }
784
785    #[test]
786    fn package_json_exports_string() {
787        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
788        let entries = pkg.entry_points();
789        assert_eq!(entries, vec!["./dist/index.js"]);
790    }
791
792    #[test]
793    fn package_json_workspace_patterns_object_with_nohoist() {
794        let pkg: PackageJson = serde_json::from_str(
795            r#"{
796            "workspaces": {
797                "packages": ["packages/*", "apps/*"],
798                "nohoist": ["**/react-native"]
799            }
800        }"#,
801        )
802        .unwrap();
803        let patterns = pkg.workspace_patterns();
804        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
805    }
806
807    #[test]
808    fn package_json_missing_optional_fields() {
809        let pkg: PackageJson = serde_json::from_str(r"{}").unwrap();
810        assert!(pkg.name.is_none());
811        assert!(pkg.main.is_none());
812        assert!(pkg.module.is_none());
813        assert!(pkg.types.is_none());
814        assert!(pkg.typings.is_none());
815        assert!(pkg.source.is_none());
816        assert!(pkg.browser.is_none());
817        assert!(pkg.bin.is_none());
818        assert!(pkg.exports.is_none());
819        assert!(pkg.dependencies.is_none());
820        assert!(pkg.dev_dependencies.is_none());
821        assert!(pkg.peer_dependencies.is_none());
822        assert!(pkg.peer_dependencies_meta.is_none());
823        assert!(pkg.optional_dependencies.is_none());
824        assert!(pkg.scripts.is_none());
825        assert!(pkg.workspaces.is_none());
826        assert!(pkg.entry_points().is_empty());
827        assert!(pkg.workspace_patterns().is_empty());
828        assert!(pkg.all_dependency_names().is_empty());
829    }
830
831    #[test]
832    fn package_json_all_dependency_names() {
833        let pkg: PackageJson = serde_json::from_str(
834            r#"{
835            "dependencies": {"react": "^18", "react-dom": "^18"},
836            "devDependencies": {"typescript": "^5"},
837            "peerDependencies": {"node": ">=18"},
838            "optionalDependencies": {"fsevents": "^2"}
839        }"#,
840        )
841        .unwrap();
842        let deps = pkg.all_dependency_names();
843        assert_eq!(deps.len(), 5);
844        assert!(deps.contains(&"react".to_string()));
845        assert!(deps.contains(&"react-dom".to_string()));
846        assert!(deps.contains(&"typescript".to_string()));
847        assert!(deps.contains(&"node".to_string()));
848        assert!(deps.contains(&"fsevents".to_string()));
849    }
850
851    #[test]
852    fn package_json_production_dependency_names() {
853        let pkg: PackageJson = serde_json::from_str(
854            r#"{
855            "dependencies": {"react": "^18"},
856            "devDependencies": {"typescript": "^5"}
857        }"#,
858        )
859        .unwrap();
860        let prod = pkg.production_dependency_names();
861        assert_eq!(prod, vec!["react"]);
862        let dev = pkg.dev_dependency_names();
863        assert_eq!(dev, vec!["typescript"]);
864    }
865
866    #[test]
867    fn package_json_bin_field_string() {
868        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "./cli.js"}"#).unwrap();
869        let entries = pkg.entry_points();
870        assert!(entries.contains(&"./cli.js".to_string()));
871    }
872
873    #[test]
874    fn package_json_bin_field_object() {
875        let pkg: PackageJson = serde_json::from_str(
876            r#"{"bin": {"my-cli": "./bin/cli.js", "my-tool": "./bin/tool.js"}}"#,
877        )
878        .unwrap();
879        let entries = pkg.entry_points();
880        assert!(entries.contains(&"./bin/cli.js".to_string()));
881        assert!(entries.contains(&"./bin/tool.js".to_string()));
882    }
883
884    #[test]
885    fn package_json_exports_deeply_nested() {
886        let pkg: PackageJson = serde_json::from_str(
887            r#"{
888            "exports": {
889                ".": {
890                    "node": {
891                        "import": "./dist/node.mjs",
892                        "require": "./dist/node.cjs"
893                    },
894                    "browser": {
895                        "import": "./dist/browser.mjs"
896                    }
897                }
898            }
899        }"#,
900        )
901        .unwrap();
902        let entries = pkg.entry_points();
903        assert_eq!(entries.len(), 3);
904        assert!(entries.contains(&"./dist/node.mjs".to_string()));
905        assert!(entries.contains(&"./dist/node.cjs".to_string()));
906        assert!(entries.contains(&"./dist/browser.mjs".to_string()));
907    }
908
909    #[test]
910    fn package_json_peer_deps_only() {
911        let pkg: PackageJson =
912            serde_json::from_str(r#"{"peerDependencies": {"react": "^18", "react-dom": "^18"}}"#)
913                .unwrap();
914        let all = pkg.all_dependency_names();
915        assert_eq!(all.len(), 2);
916        assert!(all.contains(&"react".to_string()));
917        assert!(all.contains(&"react-dom".to_string()));
918
919        assert!(pkg.production_dependency_names().is_empty());
920        assert!(pkg.dev_dependency_names().is_empty());
921    }
922
923    #[test]
924    fn package_json_peer_dependency_names_include_optional_peers() {
925        let pkg: PackageJson = serde_json::from_str(
926            r#"{
927            "peerDependencies": {"react": "^18", "typescript": "^5"},
928            "peerDependenciesMeta": {"typescript": {"optional": true}}
929        }"#,
930        )
931        .unwrap();
932        let mut names = pkg.peer_dependency_names();
933        names.sort_unstable();
934        assert_eq!(names, vec!["react", "typescript"]);
935    }
936
937    #[test]
938    fn package_json_optional_deps_in_all_names() {
939        let pkg: PackageJson =
940            serde_json::from_str(r#"{"optionalDependencies": {"fsevents": "^2"}}"#).unwrap();
941        let all = pkg.all_dependency_names();
942        assert!(all.contains(&"fsevents".to_string()));
943    }
944
945    #[test]
946    fn package_json_browser_array_ignored() {
947        let pkg: PackageJson =
948            serde_json::from_str(r#"{"browser": ["./a.js", "./b.js"]}"#).unwrap();
949        let entries = pkg.entry_points();
950        assert!(
951            entries.is_empty(),
952            "array browser field should not produce entries"
953        );
954    }
955
956    #[test]
957    fn package_json_browser_object_non_relative_skipped() {
958        let pkg: PackageJson = serde_json::from_str(
959            r#"{"browser": {"crypto": false, "./local.js": "./browser-local.js"}}"#,
960        )
961        .unwrap();
962        let entries = pkg.entry_points();
963        assert_eq!(entries.len(), 1);
964        assert!(entries.contains(&"./browser-local.js".to_string()));
965    }
966
967    #[test]
968    fn package_json_exports_null_value() {
969        let pkg: PackageJson =
970            serde_json::from_str(r#"{"exports": {".": "./dist/index.js", "./internal": null}}"#)
971                .unwrap();
972        let entries = pkg.entry_points();
973        assert_eq!(entries.len(), 1);
974        assert!(entries.contains(&"./dist/index.js".to_string()));
975    }
976
977    #[test]
978    fn package_json_exports_empty_object() {
979        let pkg: PackageJson = serde_json::from_str(r#"{"exports": {}}"#).unwrap();
980        let entries = pkg.entry_points();
981        assert!(entries.is_empty());
982    }
983
984    #[test]
985    fn package_json_workspace_patterns_string_value_ignored() {
986        let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": "packages/*"}"#).unwrap();
987        let patterns = pkg.workspace_patterns();
988        assert!(patterns.is_empty());
989    }
990
991    #[test]
992    fn package_json_workspace_patterns_object_missing_packages() {
993        let pkg: PackageJson =
994            serde_json::from_str(r#"{"workspaces": {"nohoist": ["**/react-native"]}}"#).unwrap();
995        let patterns = pkg.workspace_patterns();
996        assert!(patterns.is_empty());
997    }
998
999    #[test]
1000    fn package_json_load_invalid_json() {
1001        let temp_dir = std::env::temp_dir().join("fallow-test-invalid-pkg-json");
1002        let _ = std::fs::create_dir_all(&temp_dir);
1003        let pkg_path = temp_dir.join("package.json");
1004        std::fs::write(&pkg_path, "{ not valid json }").unwrap();
1005
1006        let result = PackageJson::load(&pkg_path);
1007        assert!(result.is_err());
1008        let err = result.unwrap_err();
1009        assert!(err.contains("Failed to parse"), "got: {err}");
1010
1011        let _ = std::fs::remove_dir_all(&temp_dir);
1012    }
1013
1014    #[test]
1015    fn package_json_bin_object_non_string_values_skipped() {
1016        let pkg: PackageJson =
1017            serde_json::from_str(r#"{"bin": {"cli": "./bin/cli.js", "bad": 42}}"#).unwrap();
1018        let entries = pkg.entry_points();
1019        assert_eq!(entries.len(), 1);
1020        assert!(entries.contains(&"./bin/cli.js".to_string()));
1021    }
1022
1023    #[test]
1024    fn package_json_default() {
1025        let pkg = PackageJson::default();
1026        assert!(pkg.name.is_none());
1027        assert!(pkg.main.is_none());
1028        assert!(pkg.entry_points().is_empty());
1029        assert!(pkg.all_dependency_names().is_empty());
1030        assert!(pkg.workspace_patterns().is_empty());
1031    }
1032
1033    #[test]
1034    fn exports_subdirectories_preact_style() {
1035        let pkg: PackageJson = serde_json::from_str(
1036            r#"{
1037            "exports": {
1038                ".": "./dist/index.js",
1039                "./compat": { "import": "./compat/dist/compat.mjs" },
1040                "./hooks": { "import": "./hooks/dist/hooks.mjs" },
1041                "./debug": { "import": "./debug/dist/debug.mjs" },
1042                "./jsx-runtime": { "import": "./jsx-runtime/dist/jsx.mjs" },
1043                "./package.json": "./package.json"
1044            }
1045        }"#,
1046        )
1047        .unwrap();
1048        let mut dirs = pkg.exports_subdirectories();
1049        dirs.sort();
1050        assert_eq!(dirs, vec!["compat", "debug", "hooks", "jsx-runtime"]);
1051    }
1052
1053    #[test]
1054    fn exports_subdirectories_skips_dist_dirs() {
1055        let pkg: PackageJson = serde_json::from_str(
1056            r#"{
1057            "exports": {
1058                "./dist/index.js": "./dist/index.js",
1059                "./build/bundle.js": "./build/bundle.js",
1060                "./lib/utils": "./lib/utils.js",
1061                "./compat": "./compat/index.js"
1062            }
1063        }"#,
1064        )
1065        .unwrap();
1066        let dirs = pkg.exports_subdirectories();
1067        assert_eq!(dirs, vec!["compat"]);
1068    }
1069
1070    #[test]
1071    fn exports_subdirectories_no_exports() {
1072        let pkg: PackageJson = serde_json::from_str(r#"{"main": "index.js"}"#).unwrap();
1073        assert!(pkg.exports_subdirectories().is_empty());
1074    }
1075
1076    #[test]
1077    fn exports_subdirectories_dot_only() {
1078        let pkg: PackageJson =
1079            serde_json::from_str(r#"{"exports": {".": "./dist/index.js"}}"#).unwrap();
1080        assert!(pkg.exports_subdirectories().is_empty());
1081    }
1082
1083    #[test]
1084    fn npm_aliases_read_the_aliased_package_name() {
1085        let pkg: PackageJson = serde_json::from_str(
1086            r#"{
1087                "dependencies": {"plain": "npm:left-pad@^1.3.0", "range": "^1.0.0"},
1088                "devDependencies": {
1089                    "@typescript/native": "npm:typescript@~7.0.2",
1090                    "typescript": "npm:@typescript/typescript6@^6.0.2",
1091                    "bare": "npm:eslint",
1092                    "same": "npm:same@1.0.0",
1093                    "path": "npm:./local",
1094                    "bad-scope": "npm:@scope@1.0.0",
1095                    "workspace": "workspace:@scope/core@*"
1096                }
1097            }"#,
1098        )
1099        .unwrap();
1100        assert_eq!(
1101            pkg.npm_aliases(),
1102            [
1103                ("@typescript/native", "typescript"),
1104                ("bare", "eslint"),
1105                ("plain", "left-pad"),
1106                ("typescript", "@typescript/typescript6"),
1107            ]
1108            .map(|(alias, target)| (alias.to_string(), target.to_string()))
1109        );
1110    }
1111
1112    #[test]
1113    fn workspace_alias_target_reads_the_aliased_package_name() {
1114        let pkg: PackageJson = serde_json::from_str(
1115            r#"{
1116                "dependencies": {
1117                    "vite": "workspace:@scope/core@*",
1118                    "plain": "workspace:other@^1.0.0",
1119                    "range": "workspace:*",
1120                    "caret": "workspace:^1.0.0",
1121                    "path": "workspace:../core",
1122                    "npm": "npm:@scope/core@1.0.0",
1123                    "bad-scope": "workspace:@scope@1.0.0"
1124                },
1125                "devDependencies": {"tool": "workspace:@scope/tool@workspace:*"}
1126            }"#,
1127        )
1128        .unwrap();
1129        assert_eq!(pkg.workspace_alias_target("vite"), Some("@scope/core"));
1130        assert_eq!(pkg.workspace_alias_target("plain"), Some("other"));
1131        assert_eq!(pkg.workspace_alias_target("tool"), Some("@scope/tool"));
1132        for name in ["range", "caret", "path", "npm", "bad-scope", "missing"] {
1133            assert_eq!(pkg.workspace_alias_target(name), None, "{name}");
1134        }
1135    }
1136}