1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
9pub struct WorkspaceConfig {
10 #[serde(default)]
12 pub patterns: Vec<String>,
13}
14
15#[derive(Debug, Clone)]
17pub struct WorkspaceInfo {
18 pub root: PathBuf,
20 pub name: String,
22 pub is_internal_dependency: bool,
24}
25
26pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
33 let mut patterns = Vec::new();
34
35 let pkg_path = root.join("package.json");
37 if let Ok(pkg) = PackageJson::load(&pkg_path) {
38 patterns.extend(pkg.workspace_patterns());
39 }
40
41 let pnpm_workspace = root.join("pnpm-workspace.yaml");
43 if pnpm_workspace.exists()
44 && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
45 {
46 patterns.extend(parse_pnpm_workspace_yaml(&content));
47 }
48
49 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
51 let mut workspaces = Vec::new();
52
53 if !patterns.is_empty() {
55 let (positive, negative): (Vec<&String>, Vec<&String>) =
59 patterns.iter().partition(|p| !p.starts_with('!'));
60 let negation_matchers: Vec<globset::GlobMatcher> = negative
61 .iter()
62 .filter_map(|p| {
63 let stripped = p.strip_prefix('!').unwrap_or(p);
64 globset::Glob::new(stripped)
65 .ok()
66 .map(|g| g.compile_matcher())
67 })
68 .collect();
69
70 for pattern in &positive {
71 let glob_pattern = if pattern.ends_with('/') {
76 format!("{pattern}*")
77 } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
78 (*pattern).clone()
80 } else {
81 (*pattern).clone()
82 };
83
84 let matched_dirs = expand_workspace_glob(root, &glob_pattern, &canonical_root);
88 for (dir, canonical_dir) in matched_dirs {
89 if canonical_dir == canonical_root {
92 continue;
93 }
94
95 let relative = dir.strip_prefix(root).unwrap_or(&dir);
97 let relative_str = relative.to_string_lossy();
98 if negation_matchers
99 .iter()
100 .any(|m| m.is_match(relative_str.as_ref()))
101 {
102 continue;
103 }
104
105 let ws_pkg_path = dir.join("package.json");
107 if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
108 let dep_names = pkg.all_dependency_names();
111 let name = pkg.name.unwrap_or_else(|| {
112 dir.file_name()
113 .map(|n| n.to_string_lossy().to_string())
114 .unwrap_or_default()
115 });
116 workspaces.push((
117 WorkspaceInfo {
118 root: dir,
119 name,
120 is_internal_dependency: false,
121 },
122 dep_names,
123 ));
124 }
125 }
126 }
127 }
128
129 for dir in parse_tsconfig_references(root) {
133 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
134 if canonical_dir == canonical_root || !canonical_dir.starts_with(&canonical_root) {
136 continue;
137 }
138
139 let ws_pkg_path = dir.join("package.json");
141 let (name, dep_names) = if ws_pkg_path.exists() {
142 if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
143 let deps = pkg.all_dependency_names();
144 let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
145 (n, deps)
146 } else {
147 (dir_name(&dir), Vec::new())
148 }
149 } else {
150 (dir_name(&dir), Vec::new())
153 };
154
155 workspaces.push((
156 WorkspaceInfo {
157 root: dir,
158 name,
159 is_internal_dependency: false,
160 },
161 dep_names,
162 ));
163 }
164
165 if workspaces.is_empty() {
166 return Vec::new();
167 }
168
169 {
173 let mut seen = rustc_hash::FxHashSet::default();
174 workspaces.retain(|(ws, _)| {
175 let canonical = ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone());
176 seen.insert(canonical)
177 });
178 }
179
180 let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
184 .iter()
185 .flat_map(|(_, deps)| deps.iter().cloned())
186 .collect();
187 for (ws, _) in &mut workspaces {
188 ws.is_internal_dependency = all_dep_names.contains(&ws.name);
189 }
190
191 workspaces.into_iter().map(|(ws, _)| ws).collect()
192}
193
194fn dir_name(dir: &Path) -> String {
196 dir.file_name()
197 .map(|n| n.to_string_lossy().to_string())
198 .unwrap_or_default()
199}
200
201fn parse_tsconfig_references(root: &Path) -> Vec<PathBuf> {
206 let tsconfig_path = root.join("tsconfig.json");
207 let Ok(content) = std::fs::read_to_string(&tsconfig_path) else {
208 return Vec::new();
209 };
210
211 let content = content.trim_start_matches('\u{FEFF}');
213
214 let mut stripped = String::new();
216 if json_comments::StripComments::new(content.as_bytes())
217 .read_to_string(&mut stripped)
218 .is_err()
219 {
220 return Vec::new();
221 }
222
223 let cleaned = strip_trailing_commas(&stripped);
225
226 let Ok(value) = serde_json::from_str::<serde_json::Value>(&cleaned) else {
227 return Vec::new();
228 };
229
230 let Some(refs) = value.get("references").and_then(|v| v.as_array()) else {
231 return Vec::new();
232 };
233
234 refs.iter()
235 .filter_map(|r| {
236 r.get("path").and_then(|p| p.as_str()).map(|p| {
237 let cleaned = p.strip_prefix("./").unwrap_or(p);
240 root.join(cleaned)
241 })
242 })
243 .filter(|p| p.is_dir())
244 .collect()
245}
246
247fn strip_trailing_commas(input: &str) -> String {
252 let bytes = input.as_bytes();
253 let len = bytes.len();
254 let mut result = Vec::with_capacity(len);
255 let mut in_string = false;
256 let mut i = 0;
257
258 while i < len {
259 let b = bytes[i];
260
261 if in_string {
262 result.push(b);
263 if b == b'\\' && i + 1 < len {
264 i += 1;
266 result.push(bytes[i]);
267 } else if b == b'"' {
268 in_string = false;
269 }
270 i += 1;
271 continue;
272 }
273
274 if b == b'"' {
275 in_string = true;
276 result.push(b);
277 i += 1;
278 continue;
279 }
280
281 if b == b',' {
282 let mut j = i + 1;
284 while j < len && bytes[j].is_ascii_whitespace() {
285 j += 1;
286 }
287 if j < len && (bytes[j] == b']' || bytes[j] == b'}') {
288 i += 1;
290 continue;
291 }
292 }
293
294 result.push(b);
295 i += 1;
296 }
297
298 String::from_utf8(result).unwrap_or_else(|_| input.to_string())
301}
302
303#[expect(clippy::print_stderr)]
312fn expand_workspace_glob(
313 root: &Path,
314 pattern: &str,
315 canonical_root: &Path,
316) -> Vec<(PathBuf, PathBuf)> {
317 let full_pattern = root.join(pattern).to_string_lossy().to_string();
318 match glob::glob(&full_pattern) {
319 Ok(paths) => paths
320 .filter_map(Result::ok)
321 .filter(|p| p.is_dir())
322 .filter(|p| p.join("package.json").exists())
326 .filter_map(|p| {
327 p.canonicalize()
329 .ok()
330 .filter(|cp| cp.starts_with(canonical_root))
331 .map(|cp| (p, cp))
332 })
333 .collect(),
334 Err(e) => {
335 eprintln!("Warning: Invalid workspace glob pattern '{pattern}': {e}");
336 Vec::new()
337 }
338 }
339}
340
341fn parse_pnpm_workspace_yaml(content: &str) -> Vec<String> {
343 let mut patterns = Vec::new();
348 let mut in_packages = false;
349
350 for line in content.lines() {
351 let trimmed = line.trim();
352 if trimmed == "packages:" {
353 in_packages = true;
354 continue;
355 }
356 if in_packages {
357 if trimmed.starts_with("- ") {
358 let value = trimmed
359 .strip_prefix("- ")
360 .unwrap_or(trimmed)
361 .trim_matches('\'')
362 .trim_matches('"');
363 patterns.push(value.to_string());
364 } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
365 break; }
367 }
368 }
369
370 patterns
371}
372
373#[expect(clippy::disallowed_types)]
377type StdHashMap<K, V> = std::collections::HashMap<K, V>;
378
379#[derive(Debug, Clone, Default, Deserialize, Serialize)]
381pub struct PackageJson {
382 #[serde(default)]
383 pub name: Option<String>,
384 #[serde(default)]
385 pub main: Option<String>,
386 #[serde(default)]
387 pub module: Option<String>,
388 #[serde(default)]
389 pub types: Option<String>,
390 #[serde(default)]
391 pub typings: Option<String>,
392 #[serde(default)]
393 pub source: Option<String>,
394 #[serde(default)]
395 pub browser: Option<serde_json::Value>,
396 #[serde(default)]
397 pub bin: Option<serde_json::Value>,
398 #[serde(default)]
399 pub exports: Option<serde_json::Value>,
400 #[serde(default)]
401 pub dependencies: Option<StdHashMap<String, String>>,
402 #[serde(default, rename = "devDependencies")]
403 pub dev_dependencies: Option<StdHashMap<String, String>>,
404 #[serde(default, rename = "peerDependencies")]
405 pub peer_dependencies: Option<StdHashMap<String, String>>,
406 #[serde(default, rename = "optionalDependencies")]
407 pub optional_dependencies: Option<StdHashMap<String, String>>,
408 #[serde(default)]
409 pub scripts: Option<StdHashMap<String, String>>,
410 #[serde(default)]
411 pub workspaces: Option<serde_json::Value>,
412}
413
414impl PackageJson {
415 pub fn load(path: &std::path::Path) -> Result<Self, String> {
417 let content = std::fs::read_to_string(path)
418 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
419 serde_json::from_str(&content)
420 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
421 }
422
423 pub fn all_dependency_names(&self) -> Vec<String> {
425 let mut deps = Vec::new();
426 if let Some(d) = &self.dependencies {
427 deps.extend(d.keys().cloned());
428 }
429 if let Some(d) = &self.dev_dependencies {
430 deps.extend(d.keys().cloned());
431 }
432 if let Some(d) = &self.peer_dependencies {
433 deps.extend(d.keys().cloned());
434 }
435 if let Some(d) = &self.optional_dependencies {
436 deps.extend(d.keys().cloned());
437 }
438 deps
439 }
440
441 pub fn production_dependency_names(&self) -> Vec<String> {
443 self.dependencies
444 .as_ref()
445 .map(|d| d.keys().cloned().collect())
446 .unwrap_or_default()
447 }
448
449 pub fn dev_dependency_names(&self) -> Vec<String> {
451 self.dev_dependencies
452 .as_ref()
453 .map(|d| d.keys().cloned().collect())
454 .unwrap_or_default()
455 }
456
457 pub fn optional_dependency_names(&self) -> Vec<String> {
459 self.optional_dependencies
460 .as_ref()
461 .map(|d| d.keys().cloned().collect())
462 .unwrap_or_default()
463 }
464
465 pub fn entry_points(&self) -> Vec<String> {
467 let mut entries = Vec::new();
468
469 if let Some(main) = &self.main {
470 entries.push(main.clone());
471 }
472 if let Some(module) = &self.module {
473 entries.push(module.clone());
474 }
475 if let Some(types) = &self.types {
476 entries.push(types.clone());
477 }
478 if let Some(typings) = &self.typings {
479 entries.push(typings.clone());
480 }
481 if let Some(source) = &self.source {
482 entries.push(source.clone());
483 }
484
485 if let Some(browser) = &self.browser {
487 match browser {
488 serde_json::Value::String(s) => entries.push(s.clone()),
489 serde_json::Value::Object(map) => {
490 for v in map.values() {
491 if let serde_json::Value::String(s) = v
492 && (s.starts_with("./") || s.starts_with("../"))
493 {
494 entries.push(s.clone());
495 }
496 }
497 }
498 _ => {}
499 }
500 }
501
502 if let Some(bin) = &self.bin {
504 match bin {
505 serde_json::Value::String(s) => entries.push(s.clone()),
506 serde_json::Value::Object(map) => {
507 for v in map.values() {
508 if let serde_json::Value::String(s) = v {
509 entries.push(s.clone());
510 }
511 }
512 }
513 _ => {}
514 }
515 }
516
517 if let Some(exports) = &self.exports {
519 extract_exports_entries(exports, &mut entries);
520 }
521
522 entries
523 }
524
525 pub fn workspace_patterns(&self) -> Vec<String> {
527 match &self.workspaces {
528 Some(serde_json::Value::Array(arr)) => arr
529 .iter()
530 .filter_map(|v| v.as_str().map(String::from))
531 .collect(),
532 Some(serde_json::Value::Object(obj)) => obj
533 .get("packages")
534 .and_then(|v| v.as_array())
535 .map(|arr| {
536 arr.iter()
537 .filter_map(|v| v.as_str().map(String::from))
538 .collect()
539 })
540 .unwrap_or_default(),
541 _ => Vec::new(),
542 }
543 }
544}
545
546fn extract_exports_entries(value: &serde_json::Value, entries: &mut Vec<String>) {
548 match value {
549 serde_json::Value::String(s) => {
550 if s.starts_with("./") || s.starts_with("../") {
551 entries.push(s.clone());
552 }
553 }
554 serde_json::Value::Object(map) => {
555 for v in map.values() {
556 extract_exports_entries(v, entries);
557 }
558 }
559 serde_json::Value::Array(arr) => {
560 for v in arr {
561 extract_exports_entries(v, entries);
562 }
563 }
564 _ => {}
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn parse_pnpm_workspace_basic() {
574 let yaml = "packages:\n - 'packages/*'\n - 'apps/*'\n";
575 let patterns = parse_pnpm_workspace_yaml(yaml);
576 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
577 }
578
579 #[test]
580 fn parse_pnpm_workspace_double_quotes() {
581 let yaml = "packages:\n - \"packages/*\"\n - \"apps/*\"\n";
582 let patterns = parse_pnpm_workspace_yaml(yaml);
583 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
584 }
585
586 #[test]
587 fn parse_pnpm_workspace_no_quotes() {
588 let yaml = "packages:\n - packages/*\n - apps/*\n";
589 let patterns = parse_pnpm_workspace_yaml(yaml);
590 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
591 }
592
593 #[test]
594 fn parse_pnpm_workspace_empty() {
595 let yaml = "";
596 let patterns = parse_pnpm_workspace_yaml(yaml);
597 assert!(patterns.is_empty());
598 }
599
600 #[test]
601 fn parse_pnpm_workspace_no_packages_key() {
602 let yaml = "other:\n - something\n";
603 let patterns = parse_pnpm_workspace_yaml(yaml);
604 assert!(patterns.is_empty());
605 }
606
607 #[test]
608 fn parse_pnpm_workspace_with_comments() {
609 let yaml = "packages:\n # Comment\n - 'packages/*'\n";
610 let patterns = parse_pnpm_workspace_yaml(yaml);
611 assert_eq!(patterns, vec!["packages/*"]);
612 }
613
614 #[test]
615 fn parse_pnpm_workspace_stops_at_next_key() {
616 let yaml = "packages:\n - 'packages/*'\ncatalog:\n react: ^18\n";
617 let patterns = parse_pnpm_workspace_yaml(yaml);
618 assert_eq!(patterns, vec!["packages/*"]);
619 }
620
621 #[test]
622 fn strip_trailing_commas_basic() {
623 assert_eq!(
624 strip_trailing_commas(r#"{"a": 1, "b": 2,}"#),
625 r#"{"a": 1, "b": 2}"#
626 );
627 }
628
629 #[test]
630 fn strip_trailing_commas_array() {
631 assert_eq!(strip_trailing_commas(r#"[1, 2, 3,]"#), r#"[1, 2, 3]"#);
632 }
633
634 #[test]
635 fn strip_trailing_commas_with_whitespace() {
636 assert_eq!(
637 strip_trailing_commas("{\n \"a\": 1,\n}"),
638 "{\n \"a\": 1\n}"
639 );
640 }
641
642 #[test]
643 fn strip_trailing_commas_preserves_strings() {
644 assert_eq!(
646 strip_trailing_commas(r#"{"a": "hello,}"}"#),
647 r#"{"a": "hello,}"}"#
648 );
649 }
650
651 #[test]
652 fn strip_trailing_commas_nested() {
653 let input = r#"{"refs": [{"path": "./a",}, {"path": "./b",},],}"#;
654 let expected = r#"{"refs": [{"path": "./a"}, {"path": "./b"}]}"#;
655 assert_eq!(strip_trailing_commas(input), expected);
656 }
657
658 #[test]
659 fn strip_trailing_commas_escaped_quotes() {
660 assert_eq!(
661 strip_trailing_commas(r#"{"a": "he\"llo,}",}"#),
662 r#"{"a": "he\"llo,}"}"#
663 );
664 }
665
666 #[test]
667 fn tsconfig_references_from_dir() {
668 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-refs");
669 let _ = std::fs::remove_dir_all(&temp_dir);
670 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
671 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
672
673 std::fs::write(
674 temp_dir.join("tsconfig.json"),
675 r#"{
676 // Root tsconfig with project references
677 "references": [
678 {"path": "./packages/core"},
679 {"path": "./packages/ui"},
680 ],
681 }"#,
682 )
683 .unwrap();
684
685 let refs = parse_tsconfig_references(&temp_dir);
686 assert_eq!(refs.len(), 2);
687 assert!(refs.iter().any(|p| p.ends_with("packages/core")));
688 assert!(refs.iter().any(|p| p.ends_with("packages/ui")));
689
690 let _ = std::fs::remove_dir_all(&temp_dir);
691 }
692
693 #[test]
694 fn tsconfig_references_no_file() {
695 let refs = parse_tsconfig_references(std::path::Path::new("/nonexistent"));
696 assert!(refs.is_empty());
697 }
698
699 #[test]
700 fn tsconfig_references_no_references_field() {
701 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-no-refs");
702 let _ = std::fs::remove_dir_all(&temp_dir);
703 std::fs::create_dir_all(&temp_dir).unwrap();
704
705 std::fs::write(
706 temp_dir.join("tsconfig.json"),
707 r#"{"compilerOptions": {"strict": true}}"#,
708 )
709 .unwrap();
710
711 let refs = parse_tsconfig_references(&temp_dir);
712 assert!(refs.is_empty());
713
714 let _ = std::fs::remove_dir_all(&temp_dir);
715 }
716
717 #[test]
718 fn tsconfig_references_skips_nonexistent_dirs() {
719 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-missing-dir");
720 let _ = std::fs::remove_dir_all(&temp_dir);
721 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
722
723 std::fs::write(
724 temp_dir.join("tsconfig.json"),
725 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/missing"}]}"#,
726 )
727 .unwrap();
728
729 let refs = parse_tsconfig_references(&temp_dir);
730 assert_eq!(refs.len(), 1);
731 assert!(refs[0].ends_with("packages/core"));
732
733 let _ = std::fs::remove_dir_all(&temp_dir);
734 }
735
736 #[test]
737 fn discover_workspaces_from_tsconfig_references() {
738 let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
739 let _ = std::fs::remove_dir_all(&temp_dir);
740 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
741 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
742
743 std::fs::write(
745 temp_dir.join("tsconfig.json"),
746 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
747 )
748 .unwrap();
749
750 std::fs::write(
752 temp_dir.join("packages/core/package.json"),
753 r#"{"name": "@project/core"}"#,
754 )
755 .unwrap();
756
757 let workspaces = discover_workspaces(&temp_dir);
759 assert_eq!(workspaces.len(), 2);
760 assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
761 assert!(workspaces.iter().any(|ws| ws.name == "ui"));
762
763 let _ = std::fs::remove_dir_all(&temp_dir);
764 }
765
766 #[test]
767 fn tsconfig_references_outside_root_rejected() {
768 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
769 let _ = std::fs::remove_dir_all(&temp_dir);
770 std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
771 std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
773
774 std::fs::write(
775 temp_dir.join("project/tsconfig.json"),
776 r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
777 )
778 .unwrap();
779
780 let workspaces = discover_workspaces(&temp_dir.join("project"));
782 assert_eq!(
783 workspaces.len(),
784 1,
785 "reference outside project root should be rejected: {workspaces:?}"
786 );
787 assert!(
788 workspaces[0]
789 .root
790 .to_string_lossy()
791 .contains("packages/core")
792 );
793
794 let _ = std::fs::remove_dir_all(&temp_dir);
795 }
796
797 #[test]
798 fn package_json_workspace_patterns_array() {
799 let pkg: PackageJson =
800 serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();
801 let patterns = pkg.workspace_patterns();
802 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
803 }
804
805 #[test]
806 fn package_json_workspace_patterns_object() {
807 let pkg: PackageJson =
808 serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();
809 let patterns = pkg.workspace_patterns();
810 assert_eq!(patterns, vec!["packages/*"]);
811 }
812
813 #[test]
814 fn package_json_workspace_patterns_none() {
815 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
816 let patterns = pkg.workspace_patterns();
817 assert!(patterns.is_empty());
818 }
819
820 #[test]
821 fn package_json_workspace_patterns_empty_array() {
822 let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": []}"#).unwrap();
823 let patterns = pkg.workspace_patterns();
824 assert!(patterns.is_empty());
825 }
826
827 #[test]
828 fn package_json_load_valid() {
829 let temp_dir = std::env::temp_dir().join("fallow-test-pkg-json");
830 let _ = std::fs::create_dir_all(&temp_dir);
831 let pkg_path = temp_dir.join("package.json");
832 std::fs::write(&pkg_path, r#"{"name": "test", "main": "index.js"}"#).unwrap();
833
834 let pkg = PackageJson::load(&pkg_path).unwrap();
835 assert_eq!(pkg.name, Some("test".to_string()));
836 assert_eq!(pkg.main, Some("index.js".to_string()));
837
838 let _ = std::fs::remove_dir_all(&temp_dir);
839 }
840
841 #[test]
842 fn package_json_load_missing_file() {
843 let result = PackageJson::load(std::path::Path::new("/nonexistent/package.json"));
844 assert!(result.is_err());
845 }
846
847 #[test]
848 fn package_json_entry_points_combined() {
849 let pkg: PackageJson = serde_json::from_str(
850 r#"{
851 "main": "dist/index.js",
852 "module": "dist/index.mjs",
853 "types": "dist/index.d.ts",
854 "typings": "dist/types.d.ts"
855 }"#,
856 )
857 .unwrap();
858 let entries = pkg.entry_points();
859 assert_eq!(entries.len(), 4);
860 assert!(entries.contains(&"dist/index.js".to_string()));
861 assert!(entries.contains(&"dist/index.mjs".to_string()));
862 assert!(entries.contains(&"dist/index.d.ts".to_string()));
863 assert!(entries.contains(&"dist/types.d.ts".to_string()));
864 }
865
866 #[test]
867 fn package_json_exports_nested() {
868 let pkg: PackageJson = serde_json::from_str(
869 r#"{
870 "exports": {
871 ".": {
872 "import": "./dist/index.mjs",
873 "require": "./dist/index.cjs"
874 },
875 "./utils": {
876 "import": "./dist/utils.mjs"
877 }
878 }
879 }"#,
880 )
881 .unwrap();
882 let entries = pkg.entry_points();
883 assert!(entries.contains(&"./dist/index.mjs".to_string()));
884 assert!(entries.contains(&"./dist/index.cjs".to_string()));
885 assert!(entries.contains(&"./dist/utils.mjs".to_string()));
886 }
887
888 #[test]
889 fn package_json_exports_array() {
890 let pkg: PackageJson = serde_json::from_str(
891 r#"{
892 "exports": {
893 ".": ["./dist/index.mjs", "./dist/index.cjs"]
894 }
895 }"#,
896 )
897 .unwrap();
898 let entries = pkg.entry_points();
899 assert!(entries.contains(&"./dist/index.mjs".to_string()));
900 assert!(entries.contains(&"./dist/index.cjs".to_string()));
901 }
902
903 #[test]
904 fn extract_exports_ignores_non_relative() {
905 let pkg: PackageJson = serde_json::from_str(
906 r#"{
907 "exports": {
908 ".": "not-a-relative-path"
909 }
910 }"#,
911 )
912 .unwrap();
913 let entries = pkg.entry_points();
914 assert!(entries.is_empty());
916 }
917
918 #[test]
919 fn package_json_source_field() {
920 let pkg: PackageJson = serde_json::from_str(
921 r#"{
922 "main": "dist/index.js",
923 "source": "src/index.ts"
924 }"#,
925 )
926 .unwrap();
927 let entries = pkg.entry_points();
928 assert!(entries.contains(&"src/index.ts".to_string()));
929 assert!(entries.contains(&"dist/index.js".to_string()));
930 }
931
932 #[test]
933 fn package_json_browser_field_string() {
934 let pkg: PackageJson = serde_json::from_str(
935 r#"{
936 "browser": "./dist/browser.js"
937 }"#,
938 )
939 .unwrap();
940 let entries = pkg.entry_points();
941 assert!(entries.contains(&"./dist/browser.js".to_string()));
942 }
943
944 #[test]
945 fn package_json_browser_field_object() {
946 let pkg: PackageJson = serde_json::from_str(
947 r#"{
948 "browser": {
949 "./server.js": "./browser.js",
950 "module-name": false
951 }
952 }"#,
953 )
954 .unwrap();
955 let entries = pkg.entry_points();
956 assert!(entries.contains(&"./browser.js".to_string()));
957 assert_eq!(entries.len(), 1);
959 }
960}