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