Skip to main content

fallow_config/workspace/
package_json.rs

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