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) if s.starts_with("./") || s.starts_with("../") => {
236 entries.push(s.clone());
237 }
238 serde_json::Value::Object(map) => {
239 for v in map.values() {
240 extract_exports_entries(v, entries);
241 }
242 }
243 serde_json::Value::Array(arr) => {
244 for v in arr {
245 extract_exports_entries(v, entries);
246 }
247 }
248 _ => {}
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn package_json_workspace_patterns_array() {
258 let pkg: PackageJson =
259 serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();
260 let patterns = pkg.workspace_patterns();
261 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
262 }
263
264 #[test]
265 fn package_json_workspace_patterns_object() {
266 let pkg: PackageJson =
267 serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();
268 let patterns = pkg.workspace_patterns();
269 assert_eq!(patterns, vec!["packages/*"]);
270 }
271
272 #[test]
273 fn package_json_workspace_patterns_none() {
274 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
275 let patterns = pkg.workspace_patterns();
276 assert!(patterns.is_empty());
277 }
278
279 #[test]
280 fn package_json_workspace_patterns_empty_array() {
281 let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": []}"#).unwrap();
282 let patterns = pkg.workspace_patterns();
283 assert!(patterns.is_empty());
284 }
285
286 #[test]
287 fn package_json_load_valid() {
288 let temp_dir = std::env::temp_dir().join("fallow-test-pkg-json");
289 let _ = std::fs::create_dir_all(&temp_dir);
290 let pkg_path = temp_dir.join("package.json");
291 std::fs::write(&pkg_path, r#"{"name": "test", "main": "index.js"}"#).unwrap();
292
293 let pkg = PackageJson::load(&pkg_path).unwrap();
294 assert_eq!(pkg.name, Some("test".to_string()));
295 assert_eq!(pkg.main, Some("index.js".to_string()));
296
297 let _ = std::fs::remove_dir_all(&temp_dir);
298 }
299
300 #[test]
301 fn package_json_load_missing_file() {
302 let result = PackageJson::load(std::path::Path::new("/nonexistent/package.json"));
303 assert!(result.is_err());
304 }
305
306 #[test]
307 fn package_json_entry_points_combined() {
308 let pkg: PackageJson = serde_json::from_str(
309 r#"{
310 "main": "dist/index.js",
311 "module": "dist/index.mjs",
312 "types": "dist/index.d.ts",
313 "typings": "dist/types.d.ts"
314 }"#,
315 )
316 .unwrap();
317 let entries = pkg.entry_points();
318 assert_eq!(entries.len(), 4);
319 assert!(entries.contains(&"dist/index.js".to_string()));
320 assert!(entries.contains(&"dist/index.mjs".to_string()));
321 assert!(entries.contains(&"dist/index.d.ts".to_string()));
322 assert!(entries.contains(&"dist/types.d.ts".to_string()));
323 }
324
325 #[test]
326 fn package_json_exports_nested() {
327 let pkg: PackageJson = serde_json::from_str(
328 r#"{
329 "exports": {
330 ".": {
331 "import": "./dist/index.mjs",
332 "require": "./dist/index.cjs"
333 },
334 "./utils": {
335 "import": "./dist/utils.mjs"
336 }
337 }
338 }"#,
339 )
340 .unwrap();
341 let entries = pkg.entry_points();
342 assert!(entries.contains(&"./dist/index.mjs".to_string()));
343 assert!(entries.contains(&"./dist/index.cjs".to_string()));
344 assert!(entries.contains(&"./dist/utils.mjs".to_string()));
345 }
346
347 #[test]
348 fn package_json_exports_array() {
349 let pkg: PackageJson = serde_json::from_str(
350 r#"{
351 "exports": {
352 ".": ["./dist/index.mjs", "./dist/index.cjs"]
353 }
354 }"#,
355 )
356 .unwrap();
357 let entries = pkg.entry_points();
358 assert!(entries.contains(&"./dist/index.mjs".to_string()));
359 assert!(entries.contains(&"./dist/index.cjs".to_string()));
360 }
361
362 #[test]
363 fn extract_exports_ignores_non_relative() {
364 let pkg: PackageJson = serde_json::from_str(
365 r#"{
366 "exports": {
367 ".": "not-a-relative-path"
368 }
369 }"#,
370 )
371 .unwrap();
372 let entries = pkg.entry_points();
373 assert!(entries.is_empty());
375 }
376
377 #[test]
378 fn package_json_source_field() {
379 let pkg: PackageJson = serde_json::from_str(
380 r#"{
381 "main": "dist/index.js",
382 "source": "src/index.ts"
383 }"#,
384 )
385 .unwrap();
386 let entries = pkg.entry_points();
387 assert!(entries.contains(&"src/index.ts".to_string()));
388 assert!(entries.contains(&"dist/index.js".to_string()));
389 }
390
391 #[test]
392 fn package_json_browser_field_string() {
393 let pkg: PackageJson = serde_json::from_str(
394 r#"{
395 "browser": "./dist/browser.js"
396 }"#,
397 )
398 .unwrap();
399 let entries = pkg.entry_points();
400 assert!(entries.contains(&"./dist/browser.js".to_string()));
401 }
402
403 #[test]
404 fn package_json_browser_field_object() {
405 let pkg: PackageJson = serde_json::from_str(
406 r#"{
407 "browser": {
408 "./server.js": "./browser.js",
409 "module-name": false
410 }
411 }"#,
412 )
413 .unwrap();
414 let entries = pkg.entry_points();
415 assert!(entries.contains(&"./browser.js".to_string()));
416 assert_eq!(entries.len(), 1);
418 }
419
420 #[test]
421 fn package_json_exports_string() {
422 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
423 let entries = pkg.entry_points();
424 assert_eq!(entries, vec!["./dist/index.js"]);
425 }
426
427 #[test]
428 fn package_json_workspace_patterns_object_with_nohoist() {
429 let pkg: PackageJson = serde_json::from_str(
430 r#"{
431 "workspaces": {
432 "packages": ["packages/*", "apps/*"],
433 "nohoist": ["**/react-native"]
434 }
435 }"#,
436 )
437 .unwrap();
438 let patterns = pkg.workspace_patterns();
439 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
440 }
441
442 #[test]
443 fn package_json_missing_optional_fields() {
444 let pkg: PackageJson = serde_json::from_str(r"{}").unwrap();
445 assert!(pkg.name.is_none());
446 assert!(pkg.main.is_none());
447 assert!(pkg.module.is_none());
448 assert!(pkg.types.is_none());
449 assert!(pkg.typings.is_none());
450 assert!(pkg.source.is_none());
451 assert!(pkg.browser.is_none());
452 assert!(pkg.bin.is_none());
453 assert!(pkg.exports.is_none());
454 assert!(pkg.dependencies.is_none());
455 assert!(pkg.dev_dependencies.is_none());
456 assert!(pkg.peer_dependencies.is_none());
457 assert!(pkg.optional_dependencies.is_none());
458 assert!(pkg.scripts.is_none());
459 assert!(pkg.workspaces.is_none());
460 assert!(pkg.entry_points().is_empty());
461 assert!(pkg.workspace_patterns().is_empty());
462 assert!(pkg.all_dependency_names().is_empty());
463 }
464
465 #[test]
466 fn package_json_all_dependency_names() {
467 let pkg: PackageJson = serde_json::from_str(
468 r#"{
469 "dependencies": {"react": "^18", "react-dom": "^18"},
470 "devDependencies": {"typescript": "^5"},
471 "peerDependencies": {"node": ">=18"},
472 "optionalDependencies": {"fsevents": "^2"}
473 }"#,
474 )
475 .unwrap();
476 let deps = pkg.all_dependency_names();
477 assert_eq!(deps.len(), 5);
478 assert!(deps.contains(&"react".to_string()));
479 assert!(deps.contains(&"react-dom".to_string()));
480 assert!(deps.contains(&"typescript".to_string()));
481 assert!(deps.contains(&"node".to_string()));
482 assert!(deps.contains(&"fsevents".to_string()));
483 }
484
485 #[test]
486 fn package_json_production_dependency_names() {
487 let pkg: PackageJson = serde_json::from_str(
488 r#"{
489 "dependencies": {"react": "^18"},
490 "devDependencies": {"typescript": "^5"}
491 }"#,
492 )
493 .unwrap();
494 let prod = pkg.production_dependency_names();
495 assert_eq!(prod, vec!["react"]);
496 let dev = pkg.dev_dependency_names();
497 assert_eq!(dev, vec!["typescript"]);
498 }
499
500 #[test]
501 fn package_json_bin_field_string() {
502 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "./cli.js"}"#).unwrap();
503 let entries = pkg.entry_points();
504 assert!(entries.contains(&"./cli.js".to_string()));
505 }
506
507 #[test]
508 fn package_json_bin_field_object() {
509 let pkg: PackageJson = serde_json::from_str(
510 r#"{"bin": {"my-cli": "./bin/cli.js", "my-tool": "./bin/tool.js"}}"#,
511 )
512 .unwrap();
513 let entries = pkg.entry_points();
514 assert!(entries.contains(&"./bin/cli.js".to_string()));
515 assert!(entries.contains(&"./bin/tool.js".to_string()));
516 }
517
518 #[test]
519 fn package_json_exports_deeply_nested() {
520 let pkg: PackageJson = serde_json::from_str(
521 r#"{
522 "exports": {
523 ".": {
524 "node": {
525 "import": "./dist/node.mjs",
526 "require": "./dist/node.cjs"
527 },
528 "browser": {
529 "import": "./dist/browser.mjs"
530 }
531 }
532 }
533 }"#,
534 )
535 .unwrap();
536 let entries = pkg.entry_points();
537 assert_eq!(entries.len(), 3);
538 assert!(entries.contains(&"./dist/node.mjs".to_string()));
539 assert!(entries.contains(&"./dist/node.cjs".to_string()));
540 assert!(entries.contains(&"./dist/browser.mjs".to_string()));
541 }
542
543 #[test]
546 fn package_json_peer_deps_only() {
547 let pkg: PackageJson =
548 serde_json::from_str(r#"{"peerDependencies": {"react": "^18", "react-dom": "^18"}}"#)
549 .unwrap();
550 let all = pkg.all_dependency_names();
551 assert_eq!(all.len(), 2);
552 assert!(all.contains(&"react".to_string()));
553 assert!(all.contains(&"react-dom".to_string()));
554
555 assert!(pkg.production_dependency_names().is_empty());
557 assert!(pkg.dev_dependency_names().is_empty());
558 }
559
560 #[test]
563 fn package_json_optional_deps_in_all_names() {
564 let pkg: PackageJson =
565 serde_json::from_str(r#"{"optionalDependencies": {"fsevents": "^2"}}"#).unwrap();
566 let all = pkg.all_dependency_names();
567 assert!(all.contains(&"fsevents".to_string()));
568 }
569
570 #[test]
573 fn package_json_browser_array_ignored() {
574 let pkg: PackageJson =
576 serde_json::from_str(r#"{"browser": ["./a.js", "./b.js"]}"#).unwrap();
577 let entries = pkg.entry_points();
578 assert!(
579 entries.is_empty(),
580 "array browser field should not produce entries"
581 );
582 }
583
584 #[test]
585 fn package_json_browser_object_non_relative_skipped() {
586 let pkg: PackageJson = serde_json::from_str(
587 r#"{"browser": {"crypto": false, "./local.js": "./browser-local.js"}}"#,
588 )
589 .unwrap();
590 let entries = pkg.entry_points();
591 assert_eq!(entries.len(), 1);
594 assert!(entries.contains(&"./browser-local.js".to_string()));
595 }
596
597 #[test]
600 fn package_json_exports_null_value() {
601 let pkg: PackageJson =
603 serde_json::from_str(r#"{"exports": {".": "./dist/index.js", "./internal": null}}"#)
604 .unwrap();
605 let entries = pkg.entry_points();
606 assert_eq!(entries.len(), 1);
607 assert!(entries.contains(&"./dist/index.js".to_string()));
608 }
609
610 #[test]
611 fn package_json_exports_empty_object() {
612 let pkg: PackageJson = serde_json::from_str(r#"{"exports": {}}"#).unwrap();
613 let entries = pkg.entry_points();
614 assert!(entries.is_empty());
615 }
616
617 #[test]
620 fn package_json_workspace_patterns_string_value_ignored() {
621 let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": "packages/*"}"#).unwrap();
623 let patterns = pkg.workspace_patterns();
624 assert!(patterns.is_empty());
625 }
626
627 #[test]
628 fn package_json_workspace_patterns_object_missing_packages() {
629 let pkg: PackageJson =
630 serde_json::from_str(r#"{"workspaces": {"nohoist": ["**/react-native"]}}"#).unwrap();
631 let patterns = pkg.workspace_patterns();
632 assert!(patterns.is_empty());
633 }
634
635 #[test]
638 fn package_json_load_invalid_json() {
639 let temp_dir = std::env::temp_dir().join("fallow-test-invalid-pkg-json");
640 let _ = std::fs::create_dir_all(&temp_dir);
641 let pkg_path = temp_dir.join("package.json");
642 std::fs::write(&pkg_path, "{ not valid json }").unwrap();
643
644 let result = PackageJson::load(&pkg_path);
645 assert!(result.is_err());
646 let err = result.unwrap_err();
647 assert!(err.contains("Failed to parse"), "got: {err}");
648
649 let _ = std::fs::remove_dir_all(&temp_dir);
650 }
651
652 #[test]
655 fn package_json_bin_object_non_string_values_skipped() {
656 let pkg: PackageJson =
657 serde_json::from_str(r#"{"bin": {"cli": "./bin/cli.js", "bad": 42}}"#).unwrap();
658 let entries = pkg.entry_points();
659 assert_eq!(entries.len(), 1);
660 assert!(entries.contains(&"./bin/cli.js".to_string()));
661 }
662
663 #[test]
666 fn package_json_default() {
667 let pkg = PackageJson::default();
668 assert!(pkg.name.is_none());
669 assert!(pkg.main.is_none());
670 assert!(pkg.entry_points().is_empty());
671 assert!(pkg.all_dependency_names().is_empty());
672 assert!(pkg.workspace_patterns().is_empty());
673 }
674
675 #[test]
678 fn exports_subdirectories_preact_style() {
679 let pkg: PackageJson = serde_json::from_str(
680 r#"{
681 "exports": {
682 ".": "./dist/index.js",
683 "./compat": { "import": "./compat/dist/compat.mjs" },
684 "./hooks": { "import": "./hooks/dist/hooks.mjs" },
685 "./debug": { "import": "./debug/dist/debug.mjs" },
686 "./jsx-runtime": { "import": "./jsx-runtime/dist/jsx.mjs" },
687 "./package.json": "./package.json"
688 }
689 }"#,
690 )
691 .unwrap();
692 let mut dirs = pkg.exports_subdirectories();
693 dirs.sort();
694 assert_eq!(dirs, vec!["compat", "debug", "hooks", "jsx-runtime"]);
695 }
696
697 #[test]
698 fn exports_subdirectories_skips_dist_dirs() {
699 let pkg: PackageJson = serde_json::from_str(
700 r#"{
701 "exports": {
702 "./dist/index.js": "./dist/index.js",
703 "./build/bundle.js": "./build/bundle.js",
704 "./lib/utils": "./lib/utils.js",
705 "./compat": "./compat/index.js"
706 }
707 }"#,
708 )
709 .unwrap();
710 let dirs = pkg.exports_subdirectories();
711 assert_eq!(dirs, vec!["compat"]);
713 }
714
715 #[test]
716 fn exports_subdirectories_no_exports() {
717 let pkg: PackageJson = serde_json::from_str(r#"{"main": "index.js"}"#).unwrap();
718 assert!(pkg.exports_subdirectories().is_empty());
719 }
720
721 #[test]
722 fn exports_subdirectories_dot_only() {
723 let pkg: PackageJson =
724 serde_json::from_str(r#"{"exports": {".": "./dist/index.js"}}"#).unwrap();
725 assert!(pkg.exports_subdirectories().is_empty());
726 }
727}