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 `exports` field, kept raw because of its many shapes (string,
135    /// conditions object, subpath map); referenced files are entry points and
136    /// the map drives subpath resolution.
137    #[serde(default)]
138    pub exports: Option<serde_json::Value>,
139    /// The `imports` field (`#`-prefixed internal subpath map), kept raw and
140    /// consumed by the module resolver.
141    #[serde(default)]
142    pub imports: Option<serde_json::Value>,
143    /// The `dependencies` map (package name to version range), the base set
144    /// for unused/unlisted-dependency accounting.
145    #[serde(default)]
146    pub dependencies: Option<StdHashMap<String, String>>,
147    /// The `devDependencies` map, checked by `unused-dev-dependencies` and
148    /// `dev-dependency-in-production`.
149    #[serde(default, rename = "devDependencies")]
150    pub dev_dependencies: Option<StdHashMap<String, String>>,
151    /// The `peerDependencies` map. Peers are provided by the consumer, so
152    /// they are exempt from unused-dependency reporting.
153    #[serde(default, rename = "peerDependencies")]
154    pub peer_dependencies: Option<StdHashMap<String, String>>,
155    /// The `peerDependenciesMeta` map, marking which peers are optional.
156    #[serde(default, rename = "peerDependenciesMeta")]
157    pub peer_dependencies_meta: Option<StdHashMap<String, PeerDependencyMeta>>,
158    /// The `optionalDependencies` map, checked by
159    /// `unused-optional-dependencies`.
160    #[serde(default, rename = "optionalDependencies")]
161    pub optional_dependencies: Option<StdHashMap<String, String>>,
162    /// The `scripts` map, scanned so packages invoked from scripts count as
163    /// used tooling dependencies.
164    #[serde(default)]
165    pub scripts: Option<StdHashMap<String, String>>,
166    /// The NAPI-RS `napi` block; see [`NapiConfig`]. Non-object values become
167    /// `None` rather than failing manifest parsing.
168    #[serde(default, deserialize_with = "deserialize_optional_napi_config")]
169    pub napi: Option<NapiConfig>,
170    /// The `workspaces` field, kept raw because it is either a pattern array
171    /// or an object with a `packages` array; drives workspace discovery.
172    #[serde(default)]
173    pub workspaces: Option<serde_json::Value>,
174    /// The `packageManager` field (e.g. `"pnpm@9.1.0"`), used to determine
175    /// the canonical package manager for the project.
176    #[serde(
177        default,
178        rename = "packageManager",
179        deserialize_with = "deserialize_optional_string_lenient"
180    )]
181    pub package_manager: Option<String>,
182}
183
184impl PackageJson {
185    /// Load from a package.json file.
186    ///
187    /// # Errors
188    ///
189    /// Returns an error string when the file cannot be read or parsed as JSON.
190    pub fn load(path: &std::path::Path) -> Result<Self, String> {
191        let content = std::fs::read_to_string(path)
192            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
193        let content = content.trim_start_matches('\u{FEFF}');
194        serde_json::from_str(content)
195            .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
196    }
197
198    /// Get all dependency names (production + dev + peer + optional).
199    #[must_use]
200    pub fn all_dependency_names(&self) -> Vec<String> {
201        let mut deps = Vec::new();
202        if let Some(d) = &self.dependencies {
203            deps.extend(d.keys().cloned());
204        }
205        if let Some(d) = &self.dev_dependencies {
206            deps.extend(d.keys().cloned());
207        }
208        if let Some(d) = &self.peer_dependencies {
209            deps.extend(d.keys().cloned());
210        }
211        if let Some(d) = &self.optional_dependencies {
212            deps.extend(d.keys().cloned());
213        }
214        deps
215    }
216
217    /// Get production dependency names only.
218    #[must_use]
219    pub fn production_dependency_names(&self) -> Vec<String> {
220        self.dependencies
221            .as_ref()
222            .map(|d| d.keys().cloned().collect())
223            .unwrap_or_default()
224    }
225
226    /// Get dev dependency names only.
227    #[must_use]
228    pub fn dev_dependency_names(&self) -> Vec<String> {
229        self.dev_dependencies
230            .as_ref()
231            .map(|d| d.keys().cloned().collect())
232            .unwrap_or_default()
233    }
234
235    /// Get optional dependency names only.
236    #[must_use]
237    pub fn optional_dependency_names(&self) -> Vec<String> {
238        self.optional_dependencies
239            .as_ref()
240            .map(|d| d.keys().cloned().collect())
241            .unwrap_or_default()
242    }
243
244    /// Get required peer dependency names only.
245    #[must_use]
246    pub fn required_peer_dependency_names(&self) -> Vec<String> {
247        self.peer_dependencies
248            .as_ref()
249            .map(|deps| {
250                deps.keys()
251                    .filter(|dep| !self.peer_dependency_is_optional(dep))
252                    .cloned()
253                    .collect()
254            })
255            .unwrap_or_default()
256    }
257
258    fn peer_dependency_is_optional(&self, dep: &str) -> bool {
259        self.peer_dependencies_meta
260            .as_ref()
261            .and_then(|meta| meta.get(dep))
262            .is_some_and(|meta| meta.optional)
263    }
264
265    /// Extract entry points from package.json fields.
266    #[must_use]
267    pub fn entry_points(&self) -> Vec<String> {
268        let mut entries = Vec::new();
269
270        if let Some(main) = &self.main {
271            entries.push(main.clone());
272        }
273        if let Some(module) = &self.module {
274            entries.push(module.clone());
275        }
276        if let Some(types) = &self.types {
277            entries.push(types.clone());
278        }
279        if let Some(typings) = &self.typings {
280            entries.push(typings.clone());
281        }
282        if let Some(source) = &self.source {
283            entries.push(source.clone());
284        }
285
286        if let Some(browser) = &self.browser {
287            match browser {
288                serde_json::Value::String(s) => entries.push(s.clone()),
289                serde_json::Value::Object(map) => {
290                    for v in map.values() {
291                        if let serde_json::Value::String(s) = v
292                            && (s.starts_with("./") || s.starts_with("../"))
293                        {
294                            entries.push(s.clone());
295                        }
296                    }
297                }
298                _ => {}
299            }
300        }
301
302        if let Some(bin) = &self.bin {
303            match bin {
304                serde_json::Value::String(s) => entries.push(s.clone()),
305                serde_json::Value::Object(map) => {
306                    for v in map.values() {
307                        if let serde_json::Value::String(s) = v {
308                            entries.push(s.clone());
309                        }
310                    }
311                }
312                _ => {}
313            }
314        }
315
316        if let Some(exports) = &self.exports {
317            extract_exports_entries(exports, &mut entries);
318        }
319
320        entries
321    }
322
323    /// Extract unique subdirectory names referenced by the `exports` field keys.
324    ///
325    /// For exports like `"./compat": { ... }`, `"./hooks/client": { ... }`,
326    /// this returns `["compat", "hooks"]`. Used to discover sub-packages in
327    /// projects that define their internal package structure via the exports map
328    /// (e.g., preact with `compat/`, `hooks/`, `debug/` sub-packages).
329    #[must_use]
330    pub fn exports_subdirectories(&self) -> Vec<String> {
331        self.exports
332            .as_ref()
333            .map_or_else(Vec::new, extract_exports_subdirectories)
334    }
335
336    /// Extract workspace patterns from package.json.
337    #[must_use]
338    pub fn workspace_patterns(&self) -> Vec<String> {
339        match &self.workspaces {
340            Some(serde_json::Value::Array(arr)) => arr
341                .iter()
342                .filter_map(|v| v.as_str().map(String::from))
343                .collect(),
344            Some(serde_json::Value::Object(obj)) => obj
345                .get("packages")
346                .and_then(|v| v.as_array())
347                .map(|arr| {
348                    arr.iter()
349                        .filter_map(|v| v.as_str().map(String::from))
350                        .collect()
351                })
352                .unwrap_or_default(),
353            _ => Vec::new(),
354        }
355    }
356}
357
358/// Extract unique subdirectory names referenced by the `exports` field keys.
359///
360/// For exports like `"./compat": { ... }`, `"./hooks/client": { ... }`,
361/// this returns `["compat", "hooks"]`. Used to discover sub-packages in
362/// projects that use the exports map to define their internal package structure
363/// (e.g., preact with `compat/`, `hooks/`, `debug/` sub-packages).
364fn extract_exports_subdirectories(exports: &serde_json::Value) -> Vec<String> {
365    let Some(map) = exports.as_object() else {
366        return Vec::new();
367    };
368
369    let skip_dirs = ["dist", "build", "out", "esm", "cjs", "lib", "node_modules"];
370    let mut dirs = rustc_hash::FxHashSet::default();
371
372    for key in map.keys() {
373        let stripped = key.strip_prefix("./").unwrap_or(key);
374        if let Some(first_segment) = stripped.split('/').next()
375            && !first_segment.is_empty()
376            && first_segment != "."
377            && first_segment != "package.json"
378            && !skip_dirs.contains(&first_segment)
379        {
380            dirs.insert(first_segment.to_owned());
381        }
382    }
383
384    dirs.into_iter().collect()
385}
386
387/// Recursively extract file paths from package.json exports field.
388fn extract_exports_entries(value: &serde_json::Value, entries: &mut Vec<String>) {
389    match value {
390        serde_json::Value::String(s) if s.starts_with("./") || s.starts_with("../") => {
391            entries.push(s.clone());
392        }
393        serde_json::Value::Object(map) => {
394            for v in map.values() {
395                extract_exports_entries(v, entries);
396            }
397        }
398        serde_json::Value::Array(arr) => {
399            for v in arr {
400                extract_exports_entries(v, entries);
401            }
402        }
403        _ => {}
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn package_json_workspace_patterns_array() {
413        let pkg: PackageJson =
414            serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();
415        let patterns = pkg.workspace_patterns();
416        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
417    }
418
419    #[test]
420    fn package_json_workspace_patterns_object() {
421        let pkg: PackageJson =
422            serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();
423        let patterns = pkg.workspace_patterns();
424        assert_eq!(patterns, vec!["packages/*"]);
425    }
426
427    #[test]
428    fn package_json_workspace_patterns_none() {
429        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
430        let patterns = pkg.workspace_patterns();
431        assert!(patterns.is_empty());
432    }
433
434    #[test]
435    fn package_json_workspace_patterns_empty_array() {
436        let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": []}"#).unwrap();
437        let patterns = pkg.workspace_patterns();
438        assert!(patterns.is_empty());
439    }
440
441    #[test]
442    fn package_json_load_valid() {
443        let temp_dir = std::env::temp_dir().join("fallow-test-pkg-json");
444        let _ = std::fs::create_dir_all(&temp_dir);
445        let pkg_path = temp_dir.join("package.json");
446        std::fs::write(&pkg_path, r#"{"name": "test", "main": "index.js"}"#).unwrap();
447
448        let pkg = PackageJson::load(&pkg_path).unwrap();
449        assert_eq!(pkg.name, Some("test".to_string()));
450        assert_eq!(pkg.main, Some("index.js".to_string()));
451
452        let _ = std::fs::remove_dir_all(&temp_dir);
453    }
454
455    #[test]
456    fn package_json_private_non_bool_values_are_ignored() {
457        for raw in [
458            r#"{"private": "true"}"#,
459            r#"{"private": 1}"#,
460            r#"{"private": null}"#,
461        ] {
462            let pkg: PackageJson = serde_json::from_str(raw).unwrap();
463            assert_eq!(pkg.private, None);
464        }
465
466        let pkg: PackageJson = serde_json::from_str(r#"{"private": true}"#).unwrap();
467        assert_eq!(pkg.private, Some(true));
468
469        let pkg: PackageJson = serde_json::from_str(r#"{"private": false}"#).unwrap();
470        assert_eq!(pkg.private, Some(false));
471    }
472
473    #[test]
474    fn package_json_files_preserves_string_array_values() {
475        let pkg: PackageJson =
476            serde_json::from_str(r#"{"files": ["index.js", "template-*", "dist"]}"#).unwrap();
477        assert_eq!(pkg.files, vec!["index.js", "template-*", "dist"]);
478    }
479
480    #[test]
481    fn package_json_files_missing_or_unexpected_shapes_are_ignored() {
482        let pkg: PackageJson = serde_json::from_str(r#"{"name": "pkg"}"#).unwrap();
483        assert!(pkg.files.is_empty());
484
485        let pkg: PackageJson = serde_json::from_str(r#"{"files": "dist"}"#).unwrap();
486        assert!(pkg.files.is_empty());
487
488        let pkg: PackageJson =
489            serde_json::from_str(r#"{"files": ["template-*", 42, false, null]}"#).unwrap();
490        assert_eq!(pkg.files, vec!["template-*"]);
491    }
492
493    #[test]
494    fn package_json_napi_config_preserves_current_string_fields() {
495        let pkg: PackageJson = serde_json::from_str(
496            r#"{
497                "napi": {
498                    "binaryName": "srcmap-codec",
499                    "packageName": "@srcmap/codec",
500                    "targets": [
501                        "aarch64-apple-darwin",
502                        "x86_64-unknown-linux-gnu"
503                    ]
504                }
505            }"#,
506        )
507        .unwrap();
508
509        let napi = pkg.napi.expect("napi config");
510        assert_eq!(napi.binary_name.as_deref(), Some("srcmap-codec"));
511        assert_eq!(napi.package_name.as_deref(), Some("@srcmap/codec"));
512        assert_eq!(
513            napi.targets,
514            vec![
515                "aarch64-apple-darwin".to_string(),
516                "x86_64-unknown-linux-gnu".to_string()
517            ]
518        );
519    }
520
521    #[test]
522    fn package_json_napi_config_ignores_unexpected_shapes() {
523        let pkg: PackageJson = serde_json::from_str(r#"{"napi": "enabled"}"#).unwrap();
524        assert!(pkg.napi.is_none());
525
526        let pkg: PackageJson = serde_json::from_str(
527            r#"{
528                "napi": {
529                    "binaryName": 42,
530                    "packageName": "",
531                    "targets": ["x86_64-apple-darwin", false, null]
532                }
533            }"#,
534        )
535        .unwrap();
536
537        let napi = pkg.napi.expect("napi config");
538        assert!(napi.binary_name.is_none());
539        assert!(napi.package_name.is_none());
540        assert_eq!(napi.targets, vec!["x86_64-apple-darwin"]);
541    }
542
543    #[test]
544    fn package_json_load_missing_file() {
545        let result = PackageJson::load(std::path::Path::new("/nonexistent/package.json"));
546        assert!(result.is_err());
547    }
548
549    #[test]
550    fn package_json_entry_points_combined() {
551        let pkg: PackageJson = serde_json::from_str(
552            r#"{
553            "main": "dist/index.js",
554            "module": "dist/index.mjs",
555            "types": "dist/index.d.ts",
556            "typings": "dist/types.d.ts"
557        }"#,
558        )
559        .unwrap();
560        let entries = pkg.entry_points();
561        assert_eq!(entries.len(), 4);
562        assert!(entries.contains(&"dist/index.js".to_string()));
563        assert!(entries.contains(&"dist/index.mjs".to_string()));
564        assert!(entries.contains(&"dist/index.d.ts".to_string()));
565        assert!(entries.contains(&"dist/types.d.ts".to_string()));
566    }
567
568    #[test]
569    fn package_json_exports_nested() {
570        let pkg: PackageJson = serde_json::from_str(
571            r#"{
572            "exports": {
573                ".": {
574                    "import": "./dist/index.mjs",
575                    "require": "./dist/index.cjs"
576                },
577                "./utils": {
578                    "import": "./dist/utils.mjs"
579                }
580            }
581        }"#,
582        )
583        .unwrap();
584        let entries = pkg.entry_points();
585        assert!(entries.contains(&"./dist/index.mjs".to_string()));
586        assert!(entries.contains(&"./dist/index.cjs".to_string()));
587        assert!(entries.contains(&"./dist/utils.mjs".to_string()));
588    }
589
590    #[test]
591    fn package_json_exports_array() {
592        let pkg: PackageJson = serde_json::from_str(
593            r#"{
594            "exports": {
595                ".": ["./dist/index.mjs", "./dist/index.cjs"]
596            }
597        }"#,
598        )
599        .unwrap();
600        let entries = pkg.entry_points();
601        assert!(entries.contains(&"./dist/index.mjs".to_string()));
602        assert!(entries.contains(&"./dist/index.cjs".to_string()));
603    }
604
605    #[test]
606    fn extract_exports_ignores_non_relative() {
607        let pkg: PackageJson = serde_json::from_str(
608            r#"{
609            "exports": {
610                ".": "not-a-relative-path"
611            }
612        }"#,
613        )
614        .unwrap();
615        let entries = pkg.entry_points();
616        assert!(entries.is_empty());
617    }
618
619    #[test]
620    fn package_json_source_field() {
621        let pkg: PackageJson = serde_json::from_str(
622            r#"{
623            "main": "dist/index.js",
624            "source": "src/index.ts"
625        }"#,
626        )
627        .unwrap();
628        let entries = pkg.entry_points();
629        assert!(entries.contains(&"src/index.ts".to_string()));
630        assert!(entries.contains(&"dist/index.js".to_string()));
631    }
632
633    #[test]
634    fn package_json_browser_field_string() {
635        let pkg: PackageJson = serde_json::from_str(
636            r#"{
637            "browser": "./dist/browser.js"
638        }"#,
639        )
640        .unwrap();
641        let entries = pkg.entry_points();
642        assert!(entries.contains(&"./dist/browser.js".to_string()));
643    }
644
645    #[test]
646    fn package_json_browser_field_object() {
647        let pkg: PackageJson = serde_json::from_str(
648            r#"{
649            "browser": {
650                "./server.js": "./browser.js",
651                "module-name": false
652            }
653        }"#,
654        )
655        .unwrap();
656        let entries = pkg.entry_points();
657        assert!(entries.contains(&"./browser.js".to_string()));
658        assert_eq!(entries.len(), 1);
659    }
660
661    #[test]
662    fn package_json_exports_string() {
663        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
664        let entries = pkg.entry_points();
665        assert_eq!(entries, vec!["./dist/index.js"]);
666    }
667
668    #[test]
669    fn package_json_workspace_patterns_object_with_nohoist() {
670        let pkg: PackageJson = serde_json::from_str(
671            r#"{
672            "workspaces": {
673                "packages": ["packages/*", "apps/*"],
674                "nohoist": ["**/react-native"]
675            }
676        }"#,
677        )
678        .unwrap();
679        let patterns = pkg.workspace_patterns();
680        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
681    }
682
683    #[test]
684    fn package_json_missing_optional_fields() {
685        let pkg: PackageJson = serde_json::from_str(r"{}").unwrap();
686        assert!(pkg.name.is_none());
687        assert!(pkg.main.is_none());
688        assert!(pkg.module.is_none());
689        assert!(pkg.types.is_none());
690        assert!(pkg.typings.is_none());
691        assert!(pkg.source.is_none());
692        assert!(pkg.browser.is_none());
693        assert!(pkg.bin.is_none());
694        assert!(pkg.exports.is_none());
695        assert!(pkg.dependencies.is_none());
696        assert!(pkg.dev_dependencies.is_none());
697        assert!(pkg.peer_dependencies.is_none());
698        assert!(pkg.peer_dependencies_meta.is_none());
699        assert!(pkg.optional_dependencies.is_none());
700        assert!(pkg.scripts.is_none());
701        assert!(pkg.workspaces.is_none());
702        assert!(pkg.entry_points().is_empty());
703        assert!(pkg.workspace_patterns().is_empty());
704        assert!(pkg.all_dependency_names().is_empty());
705    }
706
707    #[test]
708    fn package_json_all_dependency_names() {
709        let pkg: PackageJson = serde_json::from_str(
710            r#"{
711            "dependencies": {"react": "^18", "react-dom": "^18"},
712            "devDependencies": {"typescript": "^5"},
713            "peerDependencies": {"node": ">=18"},
714            "optionalDependencies": {"fsevents": "^2"}
715        }"#,
716        )
717        .unwrap();
718        let deps = pkg.all_dependency_names();
719        assert_eq!(deps.len(), 5);
720        assert!(deps.contains(&"react".to_string()));
721        assert!(deps.contains(&"react-dom".to_string()));
722        assert!(deps.contains(&"typescript".to_string()));
723        assert!(deps.contains(&"node".to_string()));
724        assert!(deps.contains(&"fsevents".to_string()));
725    }
726
727    #[test]
728    fn package_json_production_dependency_names() {
729        let pkg: PackageJson = serde_json::from_str(
730            r#"{
731            "dependencies": {"react": "^18"},
732            "devDependencies": {"typescript": "^5"}
733        }"#,
734        )
735        .unwrap();
736        let prod = pkg.production_dependency_names();
737        assert_eq!(prod, vec!["react"]);
738        let dev = pkg.dev_dependency_names();
739        assert_eq!(dev, vec!["typescript"]);
740    }
741
742    #[test]
743    fn package_json_bin_field_string() {
744        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "./cli.js"}"#).unwrap();
745        let entries = pkg.entry_points();
746        assert!(entries.contains(&"./cli.js".to_string()));
747    }
748
749    #[test]
750    fn package_json_bin_field_object() {
751        let pkg: PackageJson = serde_json::from_str(
752            r#"{"bin": {"my-cli": "./bin/cli.js", "my-tool": "./bin/tool.js"}}"#,
753        )
754        .unwrap();
755        let entries = pkg.entry_points();
756        assert!(entries.contains(&"./bin/cli.js".to_string()));
757        assert!(entries.contains(&"./bin/tool.js".to_string()));
758    }
759
760    #[test]
761    fn package_json_exports_deeply_nested() {
762        let pkg: PackageJson = serde_json::from_str(
763            r#"{
764            "exports": {
765                ".": {
766                    "node": {
767                        "import": "./dist/node.mjs",
768                        "require": "./dist/node.cjs"
769                    },
770                    "browser": {
771                        "import": "./dist/browser.mjs"
772                    }
773                }
774            }
775        }"#,
776        )
777        .unwrap();
778        let entries = pkg.entry_points();
779        assert_eq!(entries.len(), 3);
780        assert!(entries.contains(&"./dist/node.mjs".to_string()));
781        assert!(entries.contains(&"./dist/node.cjs".to_string()));
782        assert!(entries.contains(&"./dist/browser.mjs".to_string()));
783    }
784
785    #[test]
786    fn package_json_peer_deps_only() {
787        let pkg: PackageJson =
788            serde_json::from_str(r#"{"peerDependencies": {"react": "^18", "react-dom": "^18"}}"#)
789                .unwrap();
790        let all = pkg.all_dependency_names();
791        assert_eq!(all.len(), 2);
792        assert!(all.contains(&"react".to_string()));
793        assert!(all.contains(&"react-dom".to_string()));
794
795        assert!(pkg.production_dependency_names().is_empty());
796        assert!(pkg.dev_dependency_names().is_empty());
797    }
798
799    #[test]
800    fn package_json_required_peer_dependency_names_excludes_optional_peers() {
801        let pkg: PackageJson = serde_json::from_str(
802            r#"{
803            "peerDependencies": {"react": "^18", "typescript": "^5"},
804            "peerDependenciesMeta": {"typescript": {"optional": true}}
805        }"#,
806        )
807        .unwrap();
808        assert_eq!(pkg.required_peer_dependency_names(), vec!["react"]);
809    }
810
811    #[test]
812    fn package_json_optional_deps_in_all_names() {
813        let pkg: PackageJson =
814            serde_json::from_str(r#"{"optionalDependencies": {"fsevents": "^2"}}"#).unwrap();
815        let all = pkg.all_dependency_names();
816        assert!(all.contains(&"fsevents".to_string()));
817    }
818
819    #[test]
820    fn package_json_browser_array_ignored() {
821        let pkg: PackageJson =
822            serde_json::from_str(r#"{"browser": ["./a.js", "./b.js"]}"#).unwrap();
823        let entries = pkg.entry_points();
824        assert!(
825            entries.is_empty(),
826            "array browser field should not produce entries"
827        );
828    }
829
830    #[test]
831    fn package_json_browser_object_non_relative_skipped() {
832        let pkg: PackageJson = serde_json::from_str(
833            r#"{"browser": {"crypto": false, "./local.js": "./browser-local.js"}}"#,
834        )
835        .unwrap();
836        let entries = pkg.entry_points();
837        assert_eq!(entries.len(), 1);
838        assert!(entries.contains(&"./browser-local.js".to_string()));
839    }
840
841    #[test]
842    fn package_json_exports_null_value() {
843        let pkg: PackageJson =
844            serde_json::from_str(r#"{"exports": {".": "./dist/index.js", "./internal": null}}"#)
845                .unwrap();
846        let entries = pkg.entry_points();
847        assert_eq!(entries.len(), 1);
848        assert!(entries.contains(&"./dist/index.js".to_string()));
849    }
850
851    #[test]
852    fn package_json_exports_empty_object() {
853        let pkg: PackageJson = serde_json::from_str(r#"{"exports": {}}"#).unwrap();
854        let entries = pkg.entry_points();
855        assert!(entries.is_empty());
856    }
857
858    #[test]
859    fn package_json_workspace_patterns_string_value_ignored() {
860        let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": "packages/*"}"#).unwrap();
861        let patterns = pkg.workspace_patterns();
862        assert!(patterns.is_empty());
863    }
864
865    #[test]
866    fn package_json_workspace_patterns_object_missing_packages() {
867        let pkg: PackageJson =
868            serde_json::from_str(r#"{"workspaces": {"nohoist": ["**/react-native"]}}"#).unwrap();
869        let patterns = pkg.workspace_patterns();
870        assert!(patterns.is_empty());
871    }
872
873    #[test]
874    fn package_json_load_invalid_json() {
875        let temp_dir = std::env::temp_dir().join("fallow-test-invalid-pkg-json");
876        let _ = std::fs::create_dir_all(&temp_dir);
877        let pkg_path = temp_dir.join("package.json");
878        std::fs::write(&pkg_path, "{ not valid json }").unwrap();
879
880        let result = PackageJson::load(&pkg_path);
881        assert!(result.is_err());
882        let err = result.unwrap_err();
883        assert!(err.contains("Failed to parse"), "got: {err}");
884
885        let _ = std::fs::remove_dir_all(&temp_dir);
886    }
887
888    #[test]
889    fn package_json_bin_object_non_string_values_skipped() {
890        let pkg: PackageJson =
891            serde_json::from_str(r#"{"bin": {"cli": "./bin/cli.js", "bad": 42}}"#).unwrap();
892        let entries = pkg.entry_points();
893        assert_eq!(entries.len(), 1);
894        assert!(entries.contains(&"./bin/cli.js".to_string()));
895    }
896
897    #[test]
898    fn package_json_default() {
899        let pkg = PackageJson::default();
900        assert!(pkg.name.is_none());
901        assert!(pkg.main.is_none());
902        assert!(pkg.entry_points().is_empty());
903        assert!(pkg.all_dependency_names().is_empty());
904        assert!(pkg.workspace_patterns().is_empty());
905    }
906
907    #[test]
908    fn exports_subdirectories_preact_style() {
909        let pkg: PackageJson = serde_json::from_str(
910            r#"{
911            "exports": {
912                ".": "./dist/index.js",
913                "./compat": { "import": "./compat/dist/compat.mjs" },
914                "./hooks": { "import": "./hooks/dist/hooks.mjs" },
915                "./debug": { "import": "./debug/dist/debug.mjs" },
916                "./jsx-runtime": { "import": "./jsx-runtime/dist/jsx.mjs" },
917                "./package.json": "./package.json"
918            }
919        }"#,
920        )
921        .unwrap();
922        let mut dirs = pkg.exports_subdirectories();
923        dirs.sort();
924        assert_eq!(dirs, vec!["compat", "debug", "hooks", "jsx-runtime"]);
925    }
926
927    #[test]
928    fn exports_subdirectories_skips_dist_dirs() {
929        let pkg: PackageJson = serde_json::from_str(
930            r#"{
931            "exports": {
932                "./dist/index.js": "./dist/index.js",
933                "./build/bundle.js": "./build/bundle.js",
934                "./lib/utils": "./lib/utils.js",
935                "./compat": "./compat/index.js"
936            }
937        }"#,
938        )
939        .unwrap();
940        let dirs = pkg.exports_subdirectories();
941        assert_eq!(dirs, vec!["compat"]);
942    }
943
944    #[test]
945    fn exports_subdirectories_no_exports() {
946        let pkg: PackageJson = serde_json::from_str(r#"{"main": "index.js"}"#).unwrap();
947        assert!(pkg.exports_subdirectories().is_empty());
948    }
949
950    #[test]
951    fn exports_subdirectories_dot_only() {
952        let pkg: PackageJson =
953            serde_json::from_str(r#"{"exports": {".": "./dist/index.js"}}"#).unwrap();
954        assert!(pkg.exports_subdirectories().is_empty());
955    }
956}