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