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