Skip to main content

fallow_config/workspace/
package_json.rs

1use serde::{Deserialize, Serialize};
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
12#[derive(Debug, Clone, Default, Deserialize, Serialize)]
13pub struct PeerDependencyMeta {
14    #[serde(default)]
15    pub optional: bool,
16}
17
18/// Parsed package.json with fields relevant to fallow.
19#[derive(Debug, Clone, Default, Deserialize, Serialize)]
20pub struct PackageJson {
21    #[serde(default)]
22    pub name: Option<String>,
23    #[serde(default)]
24    pub main: Option<String>,
25    #[serde(default)]
26    pub module: Option<String>,
27    #[serde(default)]
28    pub types: Option<String>,
29    #[serde(default)]
30    pub typings: Option<String>,
31    #[serde(default)]
32    pub source: Option<String>,
33    #[serde(default)]
34    pub browser: Option<serde_json::Value>,
35    #[serde(default)]
36    pub bin: Option<serde_json::Value>,
37    #[serde(default)]
38    pub exports: Option<serde_json::Value>,
39    #[serde(default)]
40    pub dependencies: Option<StdHashMap<String, String>>,
41    #[serde(default, rename = "devDependencies")]
42    pub dev_dependencies: Option<StdHashMap<String, String>>,
43    #[serde(default, rename = "peerDependencies")]
44    pub peer_dependencies: Option<StdHashMap<String, String>>,
45    #[serde(default, rename = "peerDependenciesMeta")]
46    pub peer_dependencies_meta: Option<StdHashMap<String, PeerDependencyMeta>>,
47    #[serde(default, rename = "optionalDependencies")]
48    pub optional_dependencies: Option<StdHashMap<String, String>>,
49    #[serde(default)]
50    pub scripts: Option<StdHashMap<String, String>>,
51    #[serde(default)]
52    pub workspaces: Option<serde_json::Value>,
53}
54
55impl PackageJson {
56    /// Load from a package.json file.
57    ///
58    /// # Errors
59    ///
60    /// Returns an error string when the file cannot be read or parsed as JSON.
61    pub fn load(path: &std::path::Path) -> Result<Self, String> {
62        let content = std::fs::read_to_string(path)
63            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
64        // Strip UTF-8 BOM if present (common in Windows-authored
65        // package.json files, and a deliberate vite test fixture).
66        // `parse_tsconfig_references` already does the same; without this
67        // symmetric step, a BOM-prefixed package.json surfaces as a
68        // false-positive `malformed-package-json` diagnostic on workspaces
69        // that pnpm/npm/yarn happily install.
70        let content = content.trim_start_matches('\u{FEFF}');
71        serde_json::from_str(content)
72            .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
73    }
74
75    /// Get all dependency names (production + dev + peer + optional).
76    #[must_use]
77    pub fn all_dependency_names(&self) -> Vec<String> {
78        let mut deps = Vec::new();
79        if let Some(d) = &self.dependencies {
80            deps.extend(d.keys().cloned());
81        }
82        if let Some(d) = &self.dev_dependencies {
83            deps.extend(d.keys().cloned());
84        }
85        if let Some(d) = &self.peer_dependencies {
86            deps.extend(d.keys().cloned());
87        }
88        if let Some(d) = &self.optional_dependencies {
89            deps.extend(d.keys().cloned());
90        }
91        deps
92    }
93
94    /// Get production dependency names only.
95    #[must_use]
96    pub fn production_dependency_names(&self) -> Vec<String> {
97        self.dependencies
98            .as_ref()
99            .map(|d| d.keys().cloned().collect())
100            .unwrap_or_default()
101    }
102
103    /// Get dev dependency names only.
104    #[must_use]
105    pub fn dev_dependency_names(&self) -> Vec<String> {
106        self.dev_dependencies
107            .as_ref()
108            .map(|d| d.keys().cloned().collect())
109            .unwrap_or_default()
110    }
111
112    /// Get optional dependency names only.
113    #[must_use]
114    pub fn optional_dependency_names(&self) -> Vec<String> {
115        self.optional_dependencies
116            .as_ref()
117            .map(|d| d.keys().cloned().collect())
118            .unwrap_or_default()
119    }
120
121    /// Get required peer dependency names only.
122    #[must_use]
123    pub fn required_peer_dependency_names(&self) -> Vec<String> {
124        self.peer_dependencies
125            .as_ref()
126            .map(|deps| {
127                deps.keys()
128                    .filter(|dep| !self.peer_dependency_is_optional(dep))
129                    .cloned()
130                    .collect()
131            })
132            .unwrap_or_default()
133    }
134
135    fn peer_dependency_is_optional(&self, dep: &str) -> bool {
136        self.peer_dependencies_meta
137            .as_ref()
138            .and_then(|meta| meta.get(dep))
139            .is_some_and(|meta| meta.optional)
140    }
141
142    /// Extract entry points from package.json fields.
143    #[must_use]
144    pub fn entry_points(&self) -> Vec<String> {
145        let mut entries = Vec::new();
146
147        if let Some(main) = &self.main {
148            entries.push(main.clone());
149        }
150        if let Some(module) = &self.module {
151            entries.push(module.clone());
152        }
153        if let Some(types) = &self.types {
154            entries.push(types.clone());
155        }
156        if let Some(typings) = &self.typings {
157            entries.push(typings.clone());
158        }
159        if let Some(source) = &self.source {
160            entries.push(source.clone());
161        }
162
163        // Handle browser field (string or object with path values)
164        if let Some(browser) = &self.browser {
165            match browser {
166                serde_json::Value::String(s) => entries.push(s.clone()),
167                serde_json::Value::Object(map) => {
168                    for v in map.values() {
169                        if let serde_json::Value::String(s) = v
170                            && (s.starts_with("./") || s.starts_with("../"))
171                        {
172                            entries.push(s.clone());
173                        }
174                    }
175                }
176                _ => {}
177            }
178        }
179
180        // Handle bin field (string or object)
181        if let Some(bin) = &self.bin {
182            match bin {
183                serde_json::Value::String(s) => entries.push(s.clone()),
184                serde_json::Value::Object(map) => {
185                    for v in map.values() {
186                        if let serde_json::Value::String(s) = v {
187                            entries.push(s.clone());
188                        }
189                    }
190                }
191                _ => {}
192            }
193        }
194
195        // Handle exports field (recursive)
196        if let Some(exports) = &self.exports {
197            extract_exports_entries(exports, &mut entries);
198        }
199
200        entries
201    }
202
203    /// Extract unique subdirectory names referenced by the `exports` field keys.
204    ///
205    /// For exports like `"./compat": { ... }`, `"./hooks/client": { ... }`,
206    /// this returns `["compat", "hooks"]`. Used to discover sub-packages in
207    /// projects that define their internal package structure via the exports map
208    /// (e.g., preact with `compat/`, `hooks/`, `debug/` sub-packages).
209    #[must_use]
210    pub fn exports_subdirectories(&self) -> Vec<String> {
211        self.exports
212            .as_ref()
213            .map_or_else(Vec::new, extract_exports_subdirectories)
214    }
215
216    /// Extract workspace patterns from package.json.
217    #[must_use]
218    pub fn workspace_patterns(&self) -> Vec<String> {
219        match &self.workspaces {
220            Some(serde_json::Value::Array(arr)) => arr
221                .iter()
222                .filter_map(|v| v.as_str().map(String::from))
223                .collect(),
224            Some(serde_json::Value::Object(obj)) => obj
225                .get("packages")
226                .and_then(|v| v.as_array())
227                .map(|arr| {
228                    arr.iter()
229                        .filter_map(|v| v.as_str().map(String::from))
230                        .collect()
231                })
232                .unwrap_or_default(),
233            _ => Vec::new(),
234        }
235    }
236}
237
238/// Extract unique subdirectory names referenced by the `exports` field keys.
239///
240/// For exports like `"./compat": { ... }`, `"./hooks/client": { ... }`,
241/// this returns `["compat", "hooks"]`. Used to discover sub-packages in
242/// projects that use the exports map to define their internal package structure
243/// (e.g., preact with `compat/`, `hooks/`, `debug/` sub-packages).
244fn extract_exports_subdirectories(exports: &serde_json::Value) -> Vec<String> {
245    let Some(map) = exports.as_object() else {
246        return Vec::new();
247    };
248
249    let skip_dirs = ["dist", "build", "out", "esm", "cjs", "lib", "node_modules"];
250    let mut dirs = rustc_hash::FxHashSet::default();
251
252    for key in map.keys() {
253        // Keys are like "./compat", "./hooks/client", "."
254        let stripped = key.strip_prefix("./").unwrap_or(key);
255        if let Some(first_segment) = stripped.split('/').next()
256            && !first_segment.is_empty()
257            && first_segment != "."
258            && first_segment != "package.json"
259            && !skip_dirs.contains(&first_segment)
260        {
261            dirs.insert(first_segment.to_owned());
262        }
263    }
264
265    dirs.into_iter().collect()
266}
267
268/// Recursively extract file paths from package.json exports field.
269fn extract_exports_entries(value: &serde_json::Value, entries: &mut Vec<String>) {
270    match value {
271        serde_json::Value::String(s) if s.starts_with("./") || s.starts_with("../") => {
272            entries.push(s.clone());
273        }
274        serde_json::Value::Object(map) => {
275            for v in map.values() {
276                extract_exports_entries(v, entries);
277            }
278        }
279        serde_json::Value::Array(arr) => {
280            for v in arr {
281                extract_exports_entries(v, entries);
282            }
283        }
284        _ => {}
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn package_json_workspace_patterns_array() {
294        let pkg: PackageJson =
295            serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();
296        let patterns = pkg.workspace_patterns();
297        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
298    }
299
300    #[test]
301    fn package_json_workspace_patterns_object() {
302        let pkg: PackageJson =
303            serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();
304        let patterns = pkg.workspace_patterns();
305        assert_eq!(patterns, vec!["packages/*"]);
306    }
307
308    #[test]
309    fn package_json_workspace_patterns_none() {
310        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
311        let patterns = pkg.workspace_patterns();
312        assert!(patterns.is_empty());
313    }
314
315    #[test]
316    fn package_json_workspace_patterns_empty_array() {
317        let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": []}"#).unwrap();
318        let patterns = pkg.workspace_patterns();
319        assert!(patterns.is_empty());
320    }
321
322    #[test]
323    fn package_json_load_valid() {
324        let temp_dir = std::env::temp_dir().join("fallow-test-pkg-json");
325        let _ = std::fs::create_dir_all(&temp_dir);
326        let pkg_path = temp_dir.join("package.json");
327        std::fs::write(&pkg_path, r#"{"name": "test", "main": "index.js"}"#).unwrap();
328
329        let pkg = PackageJson::load(&pkg_path).unwrap();
330        assert_eq!(pkg.name, Some("test".to_string()));
331        assert_eq!(pkg.main, Some("index.js".to_string()));
332
333        let _ = std::fs::remove_dir_all(&temp_dir);
334    }
335
336    #[test]
337    fn package_json_load_missing_file() {
338        let result = PackageJson::load(std::path::Path::new("/nonexistent/package.json"));
339        assert!(result.is_err());
340    }
341
342    #[test]
343    fn package_json_entry_points_combined() {
344        let pkg: PackageJson = serde_json::from_str(
345            r#"{
346            "main": "dist/index.js",
347            "module": "dist/index.mjs",
348            "types": "dist/index.d.ts",
349            "typings": "dist/types.d.ts"
350        }"#,
351        )
352        .unwrap();
353        let entries = pkg.entry_points();
354        assert_eq!(entries.len(), 4);
355        assert!(entries.contains(&"dist/index.js".to_string()));
356        assert!(entries.contains(&"dist/index.mjs".to_string()));
357        assert!(entries.contains(&"dist/index.d.ts".to_string()));
358        assert!(entries.contains(&"dist/types.d.ts".to_string()));
359    }
360
361    #[test]
362    fn package_json_exports_nested() {
363        let pkg: PackageJson = serde_json::from_str(
364            r#"{
365            "exports": {
366                ".": {
367                    "import": "./dist/index.mjs",
368                    "require": "./dist/index.cjs"
369                },
370                "./utils": {
371                    "import": "./dist/utils.mjs"
372                }
373            }
374        }"#,
375        )
376        .unwrap();
377        let entries = pkg.entry_points();
378        assert!(entries.contains(&"./dist/index.mjs".to_string()));
379        assert!(entries.contains(&"./dist/index.cjs".to_string()));
380        assert!(entries.contains(&"./dist/utils.mjs".to_string()));
381    }
382
383    #[test]
384    fn package_json_exports_array() {
385        let pkg: PackageJson = serde_json::from_str(
386            r#"{
387            "exports": {
388                ".": ["./dist/index.mjs", "./dist/index.cjs"]
389            }
390        }"#,
391        )
392        .unwrap();
393        let entries = pkg.entry_points();
394        assert!(entries.contains(&"./dist/index.mjs".to_string()));
395        assert!(entries.contains(&"./dist/index.cjs".to_string()));
396    }
397
398    #[test]
399    fn extract_exports_ignores_non_relative() {
400        let pkg: PackageJson = serde_json::from_str(
401            r#"{
402            "exports": {
403                ".": "not-a-relative-path"
404            }
405        }"#,
406        )
407        .unwrap();
408        let entries = pkg.entry_points();
409        // "not-a-relative-path" doesn't start with "./" so should be excluded
410        assert!(entries.is_empty());
411    }
412
413    #[test]
414    fn package_json_source_field() {
415        let pkg: PackageJson = serde_json::from_str(
416            r#"{
417            "main": "dist/index.js",
418            "source": "src/index.ts"
419        }"#,
420        )
421        .unwrap();
422        let entries = pkg.entry_points();
423        assert!(entries.contains(&"src/index.ts".to_string()));
424        assert!(entries.contains(&"dist/index.js".to_string()));
425    }
426
427    #[test]
428    fn package_json_browser_field_string() {
429        let pkg: PackageJson = serde_json::from_str(
430            r#"{
431            "browser": "./dist/browser.js"
432        }"#,
433        )
434        .unwrap();
435        let entries = pkg.entry_points();
436        assert!(entries.contains(&"./dist/browser.js".to_string()));
437    }
438
439    #[test]
440    fn package_json_browser_field_object() {
441        let pkg: PackageJson = serde_json::from_str(
442            r#"{
443            "browser": {
444                "./server.js": "./browser.js",
445                "module-name": false
446            }
447        }"#,
448        )
449        .unwrap();
450        let entries = pkg.entry_points();
451        assert!(entries.contains(&"./browser.js".to_string()));
452        // non-relative paths and false values should be excluded
453        assert_eq!(entries.len(), 1);
454    }
455
456    #[test]
457    fn package_json_exports_string() {
458        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
459        let entries = pkg.entry_points();
460        assert_eq!(entries, vec!["./dist/index.js"]);
461    }
462
463    #[test]
464    fn package_json_workspace_patterns_object_with_nohoist() {
465        let pkg: PackageJson = serde_json::from_str(
466            r#"{
467            "workspaces": {
468                "packages": ["packages/*", "apps/*"],
469                "nohoist": ["**/react-native"]
470            }
471        }"#,
472        )
473        .unwrap();
474        let patterns = pkg.workspace_patterns();
475        assert_eq!(patterns, vec!["packages/*", "apps/*"]);
476    }
477
478    #[test]
479    fn package_json_missing_optional_fields() {
480        let pkg: PackageJson = serde_json::from_str(r"{}").unwrap();
481        assert!(pkg.name.is_none());
482        assert!(pkg.main.is_none());
483        assert!(pkg.module.is_none());
484        assert!(pkg.types.is_none());
485        assert!(pkg.typings.is_none());
486        assert!(pkg.source.is_none());
487        assert!(pkg.browser.is_none());
488        assert!(pkg.bin.is_none());
489        assert!(pkg.exports.is_none());
490        assert!(pkg.dependencies.is_none());
491        assert!(pkg.dev_dependencies.is_none());
492        assert!(pkg.peer_dependencies.is_none());
493        assert!(pkg.peer_dependencies_meta.is_none());
494        assert!(pkg.optional_dependencies.is_none());
495        assert!(pkg.scripts.is_none());
496        assert!(pkg.workspaces.is_none());
497        assert!(pkg.entry_points().is_empty());
498        assert!(pkg.workspace_patterns().is_empty());
499        assert!(pkg.all_dependency_names().is_empty());
500    }
501
502    #[test]
503    fn package_json_all_dependency_names() {
504        let pkg: PackageJson = serde_json::from_str(
505            r#"{
506            "dependencies": {"react": "^18", "react-dom": "^18"},
507            "devDependencies": {"typescript": "^5"},
508            "peerDependencies": {"node": ">=18"},
509            "optionalDependencies": {"fsevents": "^2"}
510        }"#,
511        )
512        .unwrap();
513        let deps = pkg.all_dependency_names();
514        assert_eq!(deps.len(), 5);
515        assert!(deps.contains(&"react".to_string()));
516        assert!(deps.contains(&"react-dom".to_string()));
517        assert!(deps.contains(&"typescript".to_string()));
518        assert!(deps.contains(&"node".to_string()));
519        assert!(deps.contains(&"fsevents".to_string()));
520    }
521
522    #[test]
523    fn package_json_production_dependency_names() {
524        let pkg: PackageJson = serde_json::from_str(
525            r#"{
526            "dependencies": {"react": "^18"},
527            "devDependencies": {"typescript": "^5"}
528        }"#,
529        )
530        .unwrap();
531        let prod = pkg.production_dependency_names();
532        assert_eq!(prod, vec!["react"]);
533        let dev = pkg.dev_dependency_names();
534        assert_eq!(dev, vec!["typescript"]);
535    }
536
537    #[test]
538    fn package_json_bin_field_string() {
539        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "./cli.js"}"#).unwrap();
540        let entries = pkg.entry_points();
541        assert!(entries.contains(&"./cli.js".to_string()));
542    }
543
544    #[test]
545    fn package_json_bin_field_object() {
546        let pkg: PackageJson = serde_json::from_str(
547            r#"{"bin": {"my-cli": "./bin/cli.js", "my-tool": "./bin/tool.js"}}"#,
548        )
549        .unwrap();
550        let entries = pkg.entry_points();
551        assert!(entries.contains(&"./bin/cli.js".to_string()));
552        assert!(entries.contains(&"./bin/tool.js".to_string()));
553    }
554
555    #[test]
556    fn package_json_exports_deeply_nested() {
557        let pkg: PackageJson = serde_json::from_str(
558            r#"{
559            "exports": {
560                ".": {
561                    "node": {
562                        "import": "./dist/node.mjs",
563                        "require": "./dist/node.cjs"
564                    },
565                    "browser": {
566                        "import": "./dist/browser.mjs"
567                    }
568                }
569            }
570        }"#,
571        )
572        .unwrap();
573        let entries = pkg.entry_points();
574        assert_eq!(entries.len(), 3);
575        assert!(entries.contains(&"./dist/node.mjs".to_string()));
576        assert!(entries.contains(&"./dist/node.cjs".to_string()));
577        assert!(entries.contains(&"./dist/browser.mjs".to_string()));
578    }
579
580    // ── Peer dependency names ───────────────────────────────────────
581
582    #[test]
583    fn package_json_peer_deps_only() {
584        let pkg: PackageJson =
585            serde_json::from_str(r#"{"peerDependencies": {"react": "^18", "react-dom": "^18"}}"#)
586                .unwrap();
587        let all = pkg.all_dependency_names();
588        assert_eq!(all.len(), 2);
589        assert!(all.contains(&"react".to_string()));
590        assert!(all.contains(&"react-dom".to_string()));
591
592        // No production or dev deps
593        assert!(pkg.production_dependency_names().is_empty());
594        assert!(pkg.dev_dependency_names().is_empty());
595    }
596
597    #[test]
598    fn package_json_required_peer_dependency_names_excludes_optional_peers() {
599        let pkg: PackageJson = serde_json::from_str(
600            r#"{
601            "peerDependencies": {"react": "^18", "typescript": "^5"},
602            "peerDependenciesMeta": {"typescript": {"optional": true}}
603        }"#,
604        )
605        .unwrap();
606        assert_eq!(pkg.required_peer_dependency_names(), vec!["react"]);
607    }
608
609    // ── Optional dependencies ───────────────────────────────────────
610
611    #[test]
612    fn package_json_optional_deps_in_all_names() {
613        let pkg: PackageJson =
614            serde_json::from_str(r#"{"optionalDependencies": {"fsevents": "^2"}}"#).unwrap();
615        let all = pkg.all_dependency_names();
616        assert!(all.contains(&"fsevents".to_string()));
617    }
618
619    // ── Browser field edge cases ────────────────────────────────────
620
621    #[test]
622    fn package_json_browser_array_ignored() {
623        // Browser field as array is not supported -- should not crash
624        let pkg: PackageJson =
625            serde_json::from_str(r#"{"browser": ["./a.js", "./b.js"]}"#).unwrap();
626        let entries = pkg.entry_points();
627        assert!(
628            entries.is_empty(),
629            "array browser field should not produce entries"
630        );
631    }
632
633    #[test]
634    fn package_json_browser_object_non_relative_skipped() {
635        let pkg: PackageJson = serde_json::from_str(
636            r#"{"browser": {"crypto": false, "./local.js": "./browser-local.js"}}"#,
637        )
638        .unwrap();
639        let entries = pkg.entry_points();
640        // false is not a string, "crypto" is not relative
641        // only "./browser-local.js" starts with "./"
642        assert_eq!(entries.len(), 1);
643        assert!(entries.contains(&"./browser-local.js".to_string()));
644    }
645
646    // ── Exports field edge cases ────────────────────────────────────
647
648    #[test]
649    fn package_json_exports_null_value() {
650        // Some packages use null for subpath exclusions
651        let pkg: PackageJson =
652            serde_json::from_str(r#"{"exports": {".": "./dist/index.js", "./internal": null}}"#)
653                .unwrap();
654        let entries = pkg.entry_points();
655        assert_eq!(entries.len(), 1);
656        assert!(entries.contains(&"./dist/index.js".to_string()));
657    }
658
659    #[test]
660    fn package_json_exports_empty_object() {
661        let pkg: PackageJson = serde_json::from_str(r#"{"exports": {}}"#).unwrap();
662        let entries = pkg.entry_points();
663        assert!(entries.is_empty());
664    }
665
666    // ── Workspace patterns edge cases ───────────────────────────────
667
668    #[test]
669    fn package_json_workspace_patterns_string_value_ignored() {
670        // workspaces as a string is not a valid format
671        let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": "packages/*"}"#).unwrap();
672        let patterns = pkg.workspace_patterns();
673        assert!(patterns.is_empty());
674    }
675
676    #[test]
677    fn package_json_workspace_patterns_object_missing_packages() {
678        let pkg: PackageJson =
679            serde_json::from_str(r#"{"workspaces": {"nohoist": ["**/react-native"]}}"#).unwrap();
680        let patterns = pkg.workspace_patterns();
681        assert!(patterns.is_empty());
682    }
683
684    // ── Load from invalid JSON ──────────────────────────────────────
685
686    #[test]
687    fn package_json_load_invalid_json() {
688        let temp_dir = std::env::temp_dir().join("fallow-test-invalid-pkg-json");
689        let _ = std::fs::create_dir_all(&temp_dir);
690        let pkg_path = temp_dir.join("package.json");
691        std::fs::write(&pkg_path, "{ not valid json }").unwrap();
692
693        let result = PackageJson::load(&pkg_path);
694        assert!(result.is_err());
695        let err = result.unwrap_err();
696        assert!(err.contains("Failed to parse"), "got: {err}");
697
698        let _ = std::fs::remove_dir_all(&temp_dir);
699    }
700
701    // ── Bin field with non-string value ─────────────────────────────
702
703    #[test]
704    fn package_json_bin_object_non_string_values_skipped() {
705        let pkg: PackageJson =
706            serde_json::from_str(r#"{"bin": {"cli": "./bin/cli.js", "bad": 42}}"#).unwrap();
707        let entries = pkg.entry_points();
708        assert_eq!(entries.len(), 1);
709        assert!(entries.contains(&"./bin/cli.js".to_string()));
710    }
711
712    // ── Default trait ───────────────────────────────────────────────
713
714    #[test]
715    fn package_json_default() {
716        let pkg = PackageJson::default();
717        assert!(pkg.name.is_none());
718        assert!(pkg.main.is_none());
719        assert!(pkg.entry_points().is_empty());
720        assert!(pkg.all_dependency_names().is_empty());
721        assert!(pkg.workspace_patterns().is_empty());
722    }
723
724    // ── Exports subdirectories ─────────────────────────────────────
725
726    #[test]
727    fn exports_subdirectories_preact_style() {
728        let pkg: PackageJson = serde_json::from_str(
729            r#"{
730            "exports": {
731                ".": "./dist/index.js",
732                "./compat": { "import": "./compat/dist/compat.mjs" },
733                "./hooks": { "import": "./hooks/dist/hooks.mjs" },
734                "./debug": { "import": "./debug/dist/debug.mjs" },
735                "./jsx-runtime": { "import": "./jsx-runtime/dist/jsx.mjs" },
736                "./package.json": "./package.json"
737            }
738        }"#,
739        )
740        .unwrap();
741        let mut dirs = pkg.exports_subdirectories();
742        dirs.sort();
743        assert_eq!(dirs, vec!["compat", "debug", "hooks", "jsx-runtime"]);
744    }
745
746    #[test]
747    fn exports_subdirectories_skips_dist_dirs() {
748        let pkg: PackageJson = serde_json::from_str(
749            r#"{
750            "exports": {
751                "./dist/index.js": "./dist/index.js",
752                "./build/bundle.js": "./build/bundle.js",
753                "./lib/utils": "./lib/utils.js",
754                "./compat": "./compat/index.js"
755            }
756        }"#,
757        )
758        .unwrap();
759        let dirs = pkg.exports_subdirectories();
760        // dist, build, lib are skipped
761        assert_eq!(dirs, vec!["compat"]);
762    }
763
764    #[test]
765    fn exports_subdirectories_no_exports() {
766        let pkg: PackageJson = serde_json::from_str(r#"{"main": "index.js"}"#).unwrap();
767        assert!(pkg.exports_subdirectories().is_empty());
768    }
769
770    #[test]
771    fn exports_subdirectories_dot_only() {
772        let pkg: PackageJson =
773            serde_json::from_str(r#"{"exports": {".": "./dist/index.js"}}"#).unwrap();
774        assert!(pkg.exports_subdirectories().is_empty());
775    }
776}