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