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