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