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);
86 for dir in matched_dirs {
87 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
90 if canonical_dir == canonical_root {
91 continue;
92 }
93
94 let relative = dir.strip_prefix(root).unwrap_or(&dir);
96 let relative_str = relative.to_string_lossy();
97 if negation_matchers
98 .iter()
99 .any(|m| m.is_match(relative_str.as_ref()))
100 {
101 continue;
102 }
103
104 let ws_pkg_path = dir.join("package.json");
105 if ws_pkg_path.exists()
106 && let Ok(pkg) = PackageJson::load(&ws_pkg_path)
107 {
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)]
308fn expand_workspace_glob(root: &Path, pattern: &str, canonical_root: &Path) -> Vec<PathBuf> {
309 let full_pattern = root.join(pattern).to_string_lossy().to_string();
310 match glob::glob(&full_pattern) {
311 Ok(paths) => paths
312 .filter_map(Result::ok)
313 .filter(|p| p.is_dir())
314 .filter(|p| {
315 p.canonicalize()
317 .ok()
318 .is_some_and(|cp| cp.starts_with(canonical_root))
319 })
320 .collect(),
321 Err(e) => {
322 eprintln!("Warning: Invalid workspace glob pattern '{pattern}': {e}");
323 Vec::new()
324 }
325 }
326}
327
328fn parse_pnpm_workspace_yaml(content: &str) -> Vec<String> {
330 let mut patterns = Vec::new();
335 let mut in_packages = false;
336
337 for line in content.lines() {
338 let trimmed = line.trim();
339 if trimmed == "packages:" {
340 in_packages = true;
341 continue;
342 }
343 if in_packages {
344 if trimmed.starts_with("- ") {
345 let value = trimmed
346 .strip_prefix("- ")
347 .unwrap_or(trimmed)
348 .trim_matches('\'')
349 .trim_matches('"');
350 patterns.push(value.to_string());
351 } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
352 break; }
354 }
355 }
356
357 patterns
358}
359
360#[expect(clippy::disallowed_types)]
364type StdHashMap<K, V> = std::collections::HashMap<K, V>;
365
366#[derive(Debug, Clone, Default, Deserialize, Serialize)]
368pub struct PackageJson {
369 #[serde(default)]
370 pub name: Option<String>,
371 #[serde(default)]
372 pub main: Option<String>,
373 #[serde(default)]
374 pub module: Option<String>,
375 #[serde(default)]
376 pub types: Option<String>,
377 #[serde(default)]
378 pub typings: Option<String>,
379 #[serde(default)]
380 pub source: Option<String>,
381 #[serde(default)]
382 pub browser: Option<serde_json::Value>,
383 #[serde(default)]
384 pub bin: Option<serde_json::Value>,
385 #[serde(default)]
386 pub exports: Option<serde_json::Value>,
387 #[serde(default)]
388 pub dependencies: Option<StdHashMap<String, String>>,
389 #[serde(default, rename = "devDependencies")]
390 pub dev_dependencies: Option<StdHashMap<String, String>>,
391 #[serde(default, rename = "peerDependencies")]
392 pub peer_dependencies: Option<StdHashMap<String, String>>,
393 #[serde(default)]
394 pub scripts: Option<StdHashMap<String, String>>,
395 #[serde(default)]
396 pub workspaces: Option<serde_json::Value>,
397}
398
399impl PackageJson {
400 pub fn load(path: &std::path::Path) -> Result<Self, String> {
402 let content = std::fs::read_to_string(path)
403 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
404 serde_json::from_str(&content)
405 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
406 }
407
408 pub fn all_dependency_names(&self) -> Vec<String> {
410 let mut deps = Vec::new();
411 if let Some(d) = &self.dependencies {
412 deps.extend(d.keys().cloned());
413 }
414 if let Some(d) = &self.dev_dependencies {
415 deps.extend(d.keys().cloned());
416 }
417 if let Some(d) = &self.peer_dependencies {
418 deps.extend(d.keys().cloned());
419 }
420 deps
421 }
422
423 pub fn production_dependency_names(&self) -> Vec<String> {
425 self.dependencies
426 .as_ref()
427 .map(|d| d.keys().cloned().collect())
428 .unwrap_or_default()
429 }
430
431 pub fn dev_dependency_names(&self) -> Vec<String> {
433 self.dev_dependencies
434 .as_ref()
435 .map(|d| d.keys().cloned().collect())
436 .unwrap_or_default()
437 }
438
439 pub fn entry_points(&self) -> Vec<String> {
441 let mut entries = Vec::new();
442
443 if let Some(main) = &self.main {
444 entries.push(main.clone());
445 }
446 if let Some(module) = &self.module {
447 entries.push(module.clone());
448 }
449 if let Some(types) = &self.types {
450 entries.push(types.clone());
451 }
452 if let Some(typings) = &self.typings {
453 entries.push(typings.clone());
454 }
455 if let Some(source) = &self.source {
456 entries.push(source.clone());
457 }
458
459 if let Some(browser) = &self.browser {
461 match browser {
462 serde_json::Value::String(s) => entries.push(s.clone()),
463 serde_json::Value::Object(map) => {
464 for v in map.values() {
465 if let serde_json::Value::String(s) = v
466 && (s.starts_with("./") || s.starts_with("../"))
467 {
468 entries.push(s.clone());
469 }
470 }
471 }
472 _ => {}
473 }
474 }
475
476 if let Some(bin) = &self.bin {
478 match bin {
479 serde_json::Value::String(s) => entries.push(s.clone()),
480 serde_json::Value::Object(map) => {
481 for v in map.values() {
482 if let serde_json::Value::String(s) = v {
483 entries.push(s.clone());
484 }
485 }
486 }
487 _ => {}
488 }
489 }
490
491 if let Some(exports) = &self.exports {
493 extract_exports_entries(exports, &mut entries);
494 }
495
496 entries
497 }
498
499 pub fn workspace_patterns(&self) -> Vec<String> {
501 match &self.workspaces {
502 Some(serde_json::Value::Array(arr)) => arr
503 .iter()
504 .filter_map(|v| v.as_str().map(String::from))
505 .collect(),
506 Some(serde_json::Value::Object(obj)) => obj
507 .get("packages")
508 .and_then(|v| v.as_array())
509 .map(|arr| {
510 arr.iter()
511 .filter_map(|v| v.as_str().map(String::from))
512 .collect()
513 })
514 .unwrap_or_default(),
515 _ => Vec::new(),
516 }
517 }
518}
519
520fn extract_exports_entries(value: &serde_json::Value, entries: &mut Vec<String>) {
522 match value {
523 serde_json::Value::String(s) => {
524 if s.starts_with("./") || s.starts_with("../") {
525 entries.push(s.clone());
526 }
527 }
528 serde_json::Value::Object(map) => {
529 for v in map.values() {
530 extract_exports_entries(v, entries);
531 }
532 }
533 serde_json::Value::Array(arr) => {
534 for v in arr {
535 extract_exports_entries(v, entries);
536 }
537 }
538 _ => {}
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 #[test]
547 fn parse_pnpm_workspace_basic() {
548 let yaml = "packages:\n - 'packages/*'\n - 'apps/*'\n";
549 let patterns = parse_pnpm_workspace_yaml(yaml);
550 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
551 }
552
553 #[test]
554 fn parse_pnpm_workspace_double_quotes() {
555 let yaml = "packages:\n - \"packages/*\"\n - \"apps/*\"\n";
556 let patterns = parse_pnpm_workspace_yaml(yaml);
557 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
558 }
559
560 #[test]
561 fn parse_pnpm_workspace_no_quotes() {
562 let yaml = "packages:\n - packages/*\n - apps/*\n";
563 let patterns = parse_pnpm_workspace_yaml(yaml);
564 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
565 }
566
567 #[test]
568 fn parse_pnpm_workspace_empty() {
569 let yaml = "";
570 let patterns = parse_pnpm_workspace_yaml(yaml);
571 assert!(patterns.is_empty());
572 }
573
574 #[test]
575 fn parse_pnpm_workspace_no_packages_key() {
576 let yaml = "other:\n - something\n";
577 let patterns = parse_pnpm_workspace_yaml(yaml);
578 assert!(patterns.is_empty());
579 }
580
581 #[test]
582 fn parse_pnpm_workspace_with_comments() {
583 let yaml = "packages:\n # Comment\n - 'packages/*'\n";
584 let patterns = parse_pnpm_workspace_yaml(yaml);
585 assert_eq!(patterns, vec!["packages/*"]);
586 }
587
588 #[test]
589 fn parse_pnpm_workspace_stops_at_next_key() {
590 let yaml = "packages:\n - 'packages/*'\ncatalog:\n react: ^18\n";
591 let patterns = parse_pnpm_workspace_yaml(yaml);
592 assert_eq!(patterns, vec!["packages/*"]);
593 }
594
595 #[test]
596 fn strip_trailing_commas_basic() {
597 assert_eq!(
598 strip_trailing_commas(r#"{"a": 1, "b": 2,}"#),
599 r#"{"a": 1, "b": 2}"#
600 );
601 }
602
603 #[test]
604 fn strip_trailing_commas_array() {
605 assert_eq!(strip_trailing_commas(r#"[1, 2, 3,]"#), r#"[1, 2, 3]"#);
606 }
607
608 #[test]
609 fn strip_trailing_commas_with_whitespace() {
610 assert_eq!(
611 strip_trailing_commas("{\n \"a\": 1,\n}"),
612 "{\n \"a\": 1\n}"
613 );
614 }
615
616 #[test]
617 fn strip_trailing_commas_preserves_strings() {
618 assert_eq!(
620 strip_trailing_commas(r#"{"a": "hello,}"}"#),
621 r#"{"a": "hello,}"}"#
622 );
623 }
624
625 #[test]
626 fn strip_trailing_commas_nested() {
627 let input = r#"{"refs": [{"path": "./a",}, {"path": "./b",},],}"#;
628 let expected = r#"{"refs": [{"path": "./a"}, {"path": "./b"}]}"#;
629 assert_eq!(strip_trailing_commas(input), expected);
630 }
631
632 #[test]
633 fn strip_trailing_commas_escaped_quotes() {
634 assert_eq!(
635 strip_trailing_commas(r#"{"a": "he\"llo,}",}"#),
636 r#"{"a": "he\"llo,}"}"#
637 );
638 }
639
640 #[test]
641 fn tsconfig_references_from_dir() {
642 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-refs");
643 let _ = std::fs::remove_dir_all(&temp_dir);
644 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
645 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
646
647 std::fs::write(
648 temp_dir.join("tsconfig.json"),
649 r#"{
650 // Root tsconfig with project references
651 "references": [
652 {"path": "./packages/core"},
653 {"path": "./packages/ui"},
654 ],
655 }"#,
656 )
657 .unwrap();
658
659 let refs = parse_tsconfig_references(&temp_dir);
660 assert_eq!(refs.len(), 2);
661 assert!(refs.iter().any(|p| p.ends_with("packages/core")));
662 assert!(refs.iter().any(|p| p.ends_with("packages/ui")));
663
664 let _ = std::fs::remove_dir_all(&temp_dir);
665 }
666
667 #[test]
668 fn tsconfig_references_no_file() {
669 let refs = parse_tsconfig_references(std::path::Path::new("/nonexistent"));
670 assert!(refs.is_empty());
671 }
672
673 #[test]
674 fn tsconfig_references_no_references_field() {
675 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-no-refs");
676 let _ = std::fs::remove_dir_all(&temp_dir);
677 std::fs::create_dir_all(&temp_dir).unwrap();
678
679 std::fs::write(
680 temp_dir.join("tsconfig.json"),
681 r#"{"compilerOptions": {"strict": true}}"#,
682 )
683 .unwrap();
684
685 let refs = parse_tsconfig_references(&temp_dir);
686 assert!(refs.is_empty());
687
688 let _ = std::fs::remove_dir_all(&temp_dir);
689 }
690
691 #[test]
692 fn tsconfig_references_skips_nonexistent_dirs() {
693 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-missing-dir");
694 let _ = std::fs::remove_dir_all(&temp_dir);
695 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
696
697 std::fs::write(
698 temp_dir.join("tsconfig.json"),
699 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/missing"}]}"#,
700 )
701 .unwrap();
702
703 let refs = parse_tsconfig_references(&temp_dir);
704 assert_eq!(refs.len(), 1);
705 assert!(refs[0].ends_with("packages/core"));
706
707 let _ = std::fs::remove_dir_all(&temp_dir);
708 }
709
710 #[test]
711 fn discover_workspaces_from_tsconfig_references() {
712 let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
713 let _ = std::fs::remove_dir_all(&temp_dir);
714 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
715 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
716
717 std::fs::write(
719 temp_dir.join("tsconfig.json"),
720 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
721 )
722 .unwrap();
723
724 std::fs::write(
726 temp_dir.join("packages/core/package.json"),
727 r#"{"name": "@project/core"}"#,
728 )
729 .unwrap();
730
731 let workspaces = discover_workspaces(&temp_dir);
733 assert_eq!(workspaces.len(), 2);
734 assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
735 assert!(workspaces.iter().any(|ws| ws.name == "ui"));
736
737 let _ = std::fs::remove_dir_all(&temp_dir);
738 }
739
740 #[test]
741 fn tsconfig_references_outside_root_rejected() {
742 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
743 let _ = std::fs::remove_dir_all(&temp_dir);
744 std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
745 std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
747
748 std::fs::write(
749 temp_dir.join("project/tsconfig.json"),
750 r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
751 )
752 .unwrap();
753
754 let workspaces = discover_workspaces(&temp_dir.join("project"));
756 assert_eq!(
757 workspaces.len(),
758 1,
759 "reference outside project root should be rejected: {workspaces:?}"
760 );
761 assert!(
762 workspaces[0]
763 .root
764 .to_string_lossy()
765 .contains("packages/core")
766 );
767
768 let _ = std::fs::remove_dir_all(&temp_dir);
769 }
770
771 #[test]
772 fn package_json_workspace_patterns_array() {
773 let pkg: PackageJson =
774 serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();
775 let patterns = pkg.workspace_patterns();
776 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
777 }
778
779 #[test]
780 fn package_json_workspace_patterns_object() {
781 let pkg: PackageJson =
782 serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();
783 let patterns = pkg.workspace_patterns();
784 assert_eq!(patterns, vec!["packages/*"]);
785 }
786
787 #[test]
788 fn package_json_workspace_patterns_none() {
789 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
790 let patterns = pkg.workspace_patterns();
791 assert!(patterns.is_empty());
792 }
793
794 #[test]
795 fn package_json_workspace_patterns_empty_array() {
796 let pkg: PackageJson = serde_json::from_str(r#"{"workspaces": []}"#).unwrap();
797 let patterns = pkg.workspace_patterns();
798 assert!(patterns.is_empty());
799 }
800
801 #[test]
802 fn package_json_load_valid() {
803 let temp_dir = std::env::temp_dir().join("fallow-test-pkg-json");
804 let _ = std::fs::create_dir_all(&temp_dir);
805 let pkg_path = temp_dir.join("package.json");
806 std::fs::write(&pkg_path, r#"{"name": "test", "main": "index.js"}"#).unwrap();
807
808 let pkg = PackageJson::load(&pkg_path).unwrap();
809 assert_eq!(pkg.name, Some("test".to_string()));
810 assert_eq!(pkg.main, Some("index.js".to_string()));
811
812 let _ = std::fs::remove_dir_all(&temp_dir);
813 }
814
815 #[test]
816 fn package_json_load_missing_file() {
817 let result = PackageJson::load(std::path::Path::new("/nonexistent/package.json"));
818 assert!(result.is_err());
819 }
820
821 #[test]
822 fn package_json_entry_points_combined() {
823 let pkg: PackageJson = serde_json::from_str(
824 r#"{
825 "main": "dist/index.js",
826 "module": "dist/index.mjs",
827 "types": "dist/index.d.ts",
828 "typings": "dist/types.d.ts"
829 }"#,
830 )
831 .unwrap();
832 let entries = pkg.entry_points();
833 assert_eq!(entries.len(), 4);
834 assert!(entries.contains(&"dist/index.js".to_string()));
835 assert!(entries.contains(&"dist/index.mjs".to_string()));
836 assert!(entries.contains(&"dist/index.d.ts".to_string()));
837 assert!(entries.contains(&"dist/types.d.ts".to_string()));
838 }
839
840 #[test]
841 fn package_json_exports_nested() {
842 let pkg: PackageJson = serde_json::from_str(
843 r#"{
844 "exports": {
845 ".": {
846 "import": "./dist/index.mjs",
847 "require": "./dist/index.cjs"
848 },
849 "./utils": {
850 "import": "./dist/utils.mjs"
851 }
852 }
853 }"#,
854 )
855 .unwrap();
856 let entries = pkg.entry_points();
857 assert!(entries.contains(&"./dist/index.mjs".to_string()));
858 assert!(entries.contains(&"./dist/index.cjs".to_string()));
859 assert!(entries.contains(&"./dist/utils.mjs".to_string()));
860 }
861
862 #[test]
863 fn package_json_exports_array() {
864 let pkg: PackageJson = serde_json::from_str(
865 r#"{
866 "exports": {
867 ".": ["./dist/index.mjs", "./dist/index.cjs"]
868 }
869 }"#,
870 )
871 .unwrap();
872 let entries = pkg.entry_points();
873 assert!(entries.contains(&"./dist/index.mjs".to_string()));
874 assert!(entries.contains(&"./dist/index.cjs".to_string()));
875 }
876
877 #[test]
878 fn extract_exports_ignores_non_relative() {
879 let pkg: PackageJson = serde_json::from_str(
880 r#"{
881 "exports": {
882 ".": "not-a-relative-path"
883 }
884 }"#,
885 )
886 .unwrap();
887 let entries = pkg.entry_points();
888 assert!(entries.is_empty());
890 }
891
892 #[test]
893 fn package_json_source_field() {
894 let pkg: PackageJson = serde_json::from_str(
895 r#"{
896 "main": "dist/index.js",
897 "source": "src/index.ts"
898 }"#,
899 )
900 .unwrap();
901 let entries = pkg.entry_points();
902 assert!(entries.contains(&"src/index.ts".to_string()));
903 assert!(entries.contains(&"dist/index.js".to_string()));
904 }
905
906 #[test]
907 fn package_json_browser_field_string() {
908 let pkg: PackageJson = serde_json::from_str(
909 r#"{
910 "browser": "./dist/browser.js"
911 }"#,
912 )
913 .unwrap();
914 let entries = pkg.entry_points();
915 assert!(entries.contains(&"./dist/browser.js".to_string()));
916 }
917
918 #[test]
919 fn package_json_browser_field_object() {
920 let pkg: PackageJson = serde_json::from_str(
921 r#"{
922 "browser": {
923 "./server.js": "./browser.js",
924 "module-name": false
925 }
926 }"#,
927 )
928 .unwrap();
929 let entries = pkg.entry_points();
930 assert!(entries.contains(&"./browser.js".to_string()));
931 assert_eq!(entries.len(), 1);
933 }
934}