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