Skip to main content

fallow_config/workspace/
package_json.rs

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