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