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