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