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]
169 pub fn workspace_patterns(&self) -> Vec<String> {
170 match &self.workspaces {
171 Some(serde_json::Value::Array(arr)) => arr
172 .iter()
173 .filter_map(|v| v.as_str().map(String::from))
174 .collect(),
175 Some(serde_json::Value::Object(obj)) => obj
176 .get("packages")
177 .and_then(|v| v.as_array())
178 .map(|arr| {
179 arr.iter()
180 .filter_map(|v| v.as_str().map(String::from))
181 .collect()
182 })
183 .unwrap_or_default(),
184 _ => Vec::new(),
185 }
186 }
187}
188
189fn extract_exports_entries(value: &serde_json::Value, entries: &mut Vec<String>) {
191 match value {
192 serde_json::Value::String(s) => {
193 if s.starts_with("./") || s.starts_with("../") {
194 entries.push(s.clone());
195 }
196 }
197 serde_json::Value::Object(map) => {
198 for v in map.values() {
199 extract_exports_entries(v, entries);
200 }
201 }
202 serde_json::Value::Array(arr) => {
203 for v in arr {
204 extract_exports_entries(v, entries);
205 }
206 }
207 _ => {}
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn package_json_workspace_patterns_array() {
217 let pkg: PackageJson =
218 serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();
219 let patterns = pkg.workspace_patterns();
220 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
221 }
222
223 #[test]
224 fn package_json_workspace_patterns_object() {
225 let pkg: PackageJson =
226 serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();
227 let patterns = pkg.workspace_patterns();
228 assert_eq!(patterns, vec!["packages/*"]);
229 }
230
231 #[test]
232 fn package_json_workspace_patterns_none() {
233 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
234 let patterns = pkg.workspace_patterns();
235 assert!(patterns.is_empty());
236 }
237
238 #[test]
239 fn package_json_workspace_patterns_empty_array() {
240 let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": []}"#).unwrap();
241 let patterns = pkg.workspace_patterns();
242 assert!(patterns.is_empty());
243 }
244
245 #[test]
246 fn package_json_load_valid() {
247 let temp_dir = std::env::temp_dir().join("fallow-test-pkg-json");
248 let _ = std::fs::create_dir_all(&temp_dir);
249 let pkg_path = temp_dir.join("package.json");
250 std::fs::write(&pkg_path, r#"{"name": "test", "main": "index.js"}"#).unwrap();
251
252 let pkg = PackageJson::load(&pkg_path).unwrap();
253 assert_eq!(pkg.name, Some("test".to_string()));
254 assert_eq!(pkg.main, Some("index.js".to_string()));
255
256 let _ = std::fs::remove_dir_all(&temp_dir);
257 }
258
259 #[test]
260 fn package_json_load_missing_file() {
261 let result = PackageJson::load(std::path::Path::new("/nonexistent/package.json"));
262 assert!(result.is_err());
263 }
264
265 #[test]
266 fn package_json_entry_points_combined() {
267 let pkg: PackageJson = serde_json::from_str(
268 r#"{
269 "main": "dist/index.js",
270 "module": "dist/index.mjs",
271 "types": "dist/index.d.ts",
272 "typings": "dist/types.d.ts"
273 }"#,
274 )
275 .unwrap();
276 let entries = pkg.entry_points();
277 assert_eq!(entries.len(), 4);
278 assert!(entries.contains(&"dist/index.js".to_string()));
279 assert!(entries.contains(&"dist/index.mjs".to_string()));
280 assert!(entries.contains(&"dist/index.d.ts".to_string()));
281 assert!(entries.contains(&"dist/types.d.ts".to_string()));
282 }
283
284 #[test]
285 fn package_json_exports_nested() {
286 let pkg: PackageJson = serde_json::from_str(
287 r#"{
288 "exports": {
289 ".": {
290 "import": "./dist/index.mjs",
291 "require": "./dist/index.cjs"
292 },
293 "./utils": {
294 "import": "./dist/utils.mjs"
295 }
296 }
297 }"#,
298 )
299 .unwrap();
300 let entries = pkg.entry_points();
301 assert!(entries.contains(&"./dist/index.mjs".to_string()));
302 assert!(entries.contains(&"./dist/index.cjs".to_string()));
303 assert!(entries.contains(&"./dist/utils.mjs".to_string()));
304 }
305
306 #[test]
307 fn package_json_exports_array() {
308 let pkg: PackageJson = serde_json::from_str(
309 r#"{
310 "exports": {
311 ".": ["./dist/index.mjs", "./dist/index.cjs"]
312 }
313 }"#,
314 )
315 .unwrap();
316 let entries = pkg.entry_points();
317 assert!(entries.contains(&"./dist/index.mjs".to_string()));
318 assert!(entries.contains(&"./dist/index.cjs".to_string()));
319 }
320
321 #[test]
322 fn extract_exports_ignores_non_relative() {
323 let pkg: PackageJson = serde_json::from_str(
324 r#"{
325 "exports": {
326 ".": "not-a-relative-path"
327 }
328 }"#,
329 )
330 .unwrap();
331 let entries = pkg.entry_points();
332 assert!(entries.is_empty());
334 }
335
336 #[test]
337 fn package_json_source_field() {
338 let pkg: PackageJson = serde_json::from_str(
339 r#"{
340 "main": "dist/index.js",
341 "source": "src/index.ts"
342 }"#,
343 )
344 .unwrap();
345 let entries = pkg.entry_points();
346 assert!(entries.contains(&"src/index.ts".to_string()));
347 assert!(entries.contains(&"dist/index.js".to_string()));
348 }
349
350 #[test]
351 fn package_json_browser_field_string() {
352 let pkg: PackageJson = serde_json::from_str(
353 r#"{
354 "browser": "./dist/browser.js"
355 }"#,
356 )
357 .unwrap();
358 let entries = pkg.entry_points();
359 assert!(entries.contains(&"./dist/browser.js".to_string()));
360 }
361
362 #[test]
363 fn package_json_browser_field_object() {
364 let pkg: PackageJson = serde_json::from_str(
365 r#"{
366 "browser": {
367 "./server.js": "./browser.js",
368 "module-name": false
369 }
370 }"#,
371 )
372 .unwrap();
373 let entries = pkg.entry_points();
374 assert!(entries.contains(&"./browser.js".to_string()));
375 assert_eq!(entries.len(), 1);
377 }
378
379 #[test]
380 fn package_json_exports_string() {
381 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
382 let entries = pkg.entry_points();
383 assert_eq!(entries, vec!["./dist/index.js"]);
384 }
385
386 #[test]
387 fn package_json_workspace_patterns_object_with_nohoist() {
388 let pkg: PackageJson = serde_json::from_str(
389 r#"{
390 "workspaces": {
391 "packages": ["packages/*", "apps/*"],
392 "nohoist": ["**/react-native"]
393 }
394 }"#,
395 )
396 .unwrap();
397 let patterns = pkg.workspace_patterns();
398 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
399 }
400
401 #[test]
402 fn package_json_missing_optional_fields() {
403 let pkg: PackageJson = serde_json::from_str(r"{}").unwrap();
404 assert!(pkg.name.is_none());
405 assert!(pkg.main.is_none());
406 assert!(pkg.module.is_none());
407 assert!(pkg.types.is_none());
408 assert!(pkg.typings.is_none());
409 assert!(pkg.source.is_none());
410 assert!(pkg.browser.is_none());
411 assert!(pkg.bin.is_none());
412 assert!(pkg.exports.is_none());
413 assert!(pkg.dependencies.is_none());
414 assert!(pkg.dev_dependencies.is_none());
415 assert!(pkg.peer_dependencies.is_none());
416 assert!(pkg.optional_dependencies.is_none());
417 assert!(pkg.scripts.is_none());
418 assert!(pkg.workspaces.is_none());
419 assert!(pkg.entry_points().is_empty());
420 assert!(pkg.workspace_patterns().is_empty());
421 assert!(pkg.all_dependency_names().is_empty());
422 }
423
424 #[test]
425 fn package_json_all_dependency_names() {
426 let pkg: PackageJson = serde_json::from_str(
427 r#"{
428 "dependencies": {"react": "^18", "react-dom": "^18"},
429 "devDependencies": {"typescript": "^5"},
430 "peerDependencies": {"node": ">=18"},
431 "optionalDependencies": {"fsevents": "^2"}
432 }"#,
433 )
434 .unwrap();
435 let deps = pkg.all_dependency_names();
436 assert_eq!(deps.len(), 5);
437 assert!(deps.contains(&"react".to_string()));
438 assert!(deps.contains(&"react-dom".to_string()));
439 assert!(deps.contains(&"typescript".to_string()));
440 assert!(deps.contains(&"node".to_string()));
441 assert!(deps.contains(&"fsevents".to_string()));
442 }
443
444 #[test]
445 fn package_json_production_dependency_names() {
446 let pkg: PackageJson = serde_json::from_str(
447 r#"{
448 "dependencies": {"react": "^18"},
449 "devDependencies": {"typescript": "^5"}
450 }"#,
451 )
452 .unwrap();
453 let prod = pkg.production_dependency_names();
454 assert_eq!(prod, vec!["react"]);
455 let dev = pkg.dev_dependency_names();
456 assert_eq!(dev, vec!["typescript"]);
457 }
458
459 #[test]
460 fn package_json_bin_field_string() {
461 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "./cli.js"}"#).unwrap();
462 let entries = pkg.entry_points();
463 assert!(entries.contains(&"./cli.js".to_string()));
464 }
465
466 #[test]
467 fn package_json_bin_field_object() {
468 let pkg: PackageJson = serde_json::from_str(
469 r#"{"bin": {"my-cli": "./bin/cli.js", "my-tool": "./bin/tool.js"}}"#,
470 )
471 .unwrap();
472 let entries = pkg.entry_points();
473 assert!(entries.contains(&"./bin/cli.js".to_string()));
474 assert!(entries.contains(&"./bin/tool.js".to_string()));
475 }
476
477 #[test]
478 fn package_json_exports_deeply_nested() {
479 let pkg: PackageJson = serde_json::from_str(
480 r#"{
481 "exports": {
482 ".": {
483 "node": {
484 "import": "./dist/node.mjs",
485 "require": "./dist/node.cjs"
486 },
487 "browser": {
488 "import": "./dist/browser.mjs"
489 }
490 }
491 }
492 }"#,
493 )
494 .unwrap();
495 let entries = pkg.entry_points();
496 assert_eq!(entries.len(), 3);
497 assert!(entries.contains(&"./dist/node.mjs".to_string()));
498 assert!(entries.contains(&"./dist/node.cjs".to_string()));
499 assert!(entries.contains(&"./dist/browser.mjs".to_string()));
500 }
501
502 #[test]
505 fn package_json_peer_deps_only() {
506 let pkg: PackageJson =
507 serde_json::from_str(r#"{"peerDependencies": {"react": "^18", "react-dom": "^18"}}"#)
508 .unwrap();
509 let all = pkg.all_dependency_names();
510 assert_eq!(all.len(), 2);
511 assert!(all.contains(&"react".to_string()));
512 assert!(all.contains(&"react-dom".to_string()));
513
514 assert!(pkg.production_dependency_names().is_empty());
516 assert!(pkg.dev_dependency_names().is_empty());
517 }
518
519 #[test]
522 fn package_json_optional_deps_in_all_names() {
523 let pkg: PackageJson =
524 serde_json::from_str(r#"{"optionalDependencies": {"fsevents": "^2"}}"#).unwrap();
525 let all = pkg.all_dependency_names();
526 assert!(all.contains(&"fsevents".to_string()));
527 }
528
529 #[test]
532 fn package_json_browser_array_ignored() {
533 let pkg: PackageJson =
535 serde_json::from_str(r#"{"browser": ["./a.js", "./b.js"]}"#).unwrap();
536 let entries = pkg.entry_points();
537 assert!(
538 entries.is_empty(),
539 "array browser field should not produce entries"
540 );
541 }
542
543 #[test]
544 fn package_json_browser_object_non_relative_skipped() {
545 let pkg: PackageJson = serde_json::from_str(
546 r#"{"browser": {"crypto": false, "./local.js": "./browser-local.js"}}"#,
547 )
548 .unwrap();
549 let entries = pkg.entry_points();
550 assert_eq!(entries.len(), 1);
553 assert!(entries.contains(&"./browser-local.js".to_string()));
554 }
555
556 #[test]
559 fn package_json_exports_null_value() {
560 let pkg: PackageJson =
562 serde_json::from_str(r#"{"exports": {".": "./dist/index.js", "./internal": null}}"#)
563 .unwrap();
564 let entries = pkg.entry_points();
565 assert_eq!(entries.len(), 1);
566 assert!(entries.contains(&"./dist/index.js".to_string()));
567 }
568
569 #[test]
570 fn package_json_exports_empty_object() {
571 let pkg: PackageJson = serde_json::from_str(r#"{"exports": {}}"#).unwrap();
572 let entries = pkg.entry_points();
573 assert!(entries.is_empty());
574 }
575
576 #[test]
579 fn package_json_workspace_patterns_string_value_ignored() {
580 let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": "packages/*"}"#).unwrap();
582 let patterns = pkg.workspace_patterns();
583 assert!(patterns.is_empty());
584 }
585
586 #[test]
587 fn package_json_workspace_patterns_object_missing_packages() {
588 let pkg: PackageJson =
589 serde_json::from_str(r#"{"workspaces": {"nohoist": ["**/react-native"]}}"#).unwrap();
590 let patterns = pkg.workspace_patterns();
591 assert!(patterns.is_empty());
592 }
593
594 #[test]
597 fn package_json_load_invalid_json() {
598 let temp_dir = std::env::temp_dir().join("fallow-test-invalid-pkg-json");
599 let _ = std::fs::create_dir_all(&temp_dir);
600 let pkg_path = temp_dir.join("package.json");
601 std::fs::write(&pkg_path, "{ not valid json }").unwrap();
602
603 let result = PackageJson::load(&pkg_path);
604 assert!(result.is_err());
605 let err = result.unwrap_err();
606 assert!(err.contains("Failed to parse"), "got: {err}");
607
608 let _ = std::fs::remove_dir_all(&temp_dir);
609 }
610
611 #[test]
614 fn package_json_bin_object_non_string_values_skipped() {
615 let pkg: PackageJson =
616 serde_json::from_str(r#"{"bin": {"cli": "./bin/cli.js", "bad": 42}}"#).unwrap();
617 let entries = pkg.entry_points();
618 assert_eq!(entries.len(), 1);
619 assert!(entries.contains(&"./bin/cli.js".to_string()));
620 }
621
622 #[test]
625 fn package_json_default() {
626 let pkg = PackageJson::default();
627 assert!(pkg.name.is_none());
628 assert!(pkg.main.is_none());
629 assert!(pkg.entry_points().is_empty());
630 assert!(pkg.all_dependency_names().is_empty());
631 assert!(pkg.workspace_patterns().is_empty());
632 }
633}