1mod package_json;
2mod parsers;
3
4use std::path::{Path, PathBuf};
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9pub use package_json::PackageJson;
10pub use parsers::parse_tsconfig_root_dir;
11use parsers::{expand_workspace_glob, parse_pnpm_workspace_yaml, parse_tsconfig_references};
12
13#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
15pub struct WorkspaceConfig {
16 #[serde(default)]
18 pub patterns: Vec<String>,
19}
20
21#[derive(Debug, Clone)]
23pub struct WorkspaceInfo {
24 pub root: PathBuf,
26 pub name: String,
28 pub is_internal_dependency: bool,
30}
31
32#[derive(Debug, Clone)]
34pub struct WorkspaceDiagnostic {
35 pub path: PathBuf,
37 pub message: String,
39}
40
41#[must_use]
48pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
49 let patterns = collect_workspace_patterns(root);
50 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
51
52 let mut workspaces = expand_patterns_to_workspaces(root, &patterns, &canonical_root);
53 workspaces.extend(collect_tsconfig_workspaces(root, &canonical_root));
54 if patterns.is_empty() {
55 workspaces.extend(collect_shallow_package_workspaces(root, &canonical_root));
56 }
57
58 if workspaces.is_empty() {
59 return Vec::new();
60 }
61
62 mark_internal_dependencies(&mut workspaces);
63 workspaces.into_iter().map(|(ws, _)| ws).collect()
64}
65
66#[must_use]
72pub fn find_undeclared_workspaces(
73 root: &Path,
74 declared: &[WorkspaceInfo],
75) -> Vec<WorkspaceDiagnostic> {
76 find_undeclared_workspaces_with_ignores(root, declared, &globset::GlobSet::empty())
77}
78
79#[must_use]
89pub fn find_undeclared_workspaces_with_ignores(
90 root: &Path,
91 declared: &[WorkspaceInfo],
92 ignore_patterns: &globset::GlobSet,
93) -> Vec<WorkspaceDiagnostic> {
94 let patterns = collect_workspace_patterns(root);
96 if patterns.is_empty() {
97 return Vec::new();
98 }
99
100 let declared_roots: rustc_hash::FxHashSet<PathBuf> = declared
101 .iter()
102 .map(|w| dunce::canonicalize(&w.root).unwrap_or_else(|_| w.root.clone()))
103 .collect();
104
105 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
106
107 let mut undeclared = Vec::new();
108
109 let Ok(top_entries) = std::fs::read_dir(root) else {
111 return Vec::new();
112 };
113
114 for entry in top_entries.filter_map(Result::ok) {
115 let path = entry.path();
116 if !path.is_dir() {
117 continue;
118 }
119
120 let name = entry.file_name();
121 let name_str = name.to_string_lossy();
122 if name_str.starts_with('.') || name_str == "node_modules" || name_str == "build" {
123 continue;
124 }
125
126 check_undeclared(
128 &path,
129 root,
130 &canonical_root,
131 &declared_roots,
132 ignore_patterns,
133 &mut undeclared,
134 );
135
136 let Ok(child_entries) = std::fs::read_dir(&path) else {
138 continue;
139 };
140 for child in child_entries.filter_map(Result::ok) {
141 let child_path = child.path();
142 if !child_path.is_dir() {
143 continue;
144 }
145 let child_name = child.file_name();
146 let child_name_str = child_name.to_string_lossy();
147 if child_name_str.starts_with('.')
148 || child_name_str == "node_modules"
149 || child_name_str == "build"
150 {
151 continue;
152 }
153 check_undeclared(
154 &child_path,
155 root,
156 &canonical_root,
157 &declared_roots,
158 ignore_patterns,
159 &mut undeclared,
160 );
161 }
162 }
163
164 undeclared
165}
166
167fn check_undeclared(
169 dir: &Path,
170 root: &Path,
171 canonical_root: &Path,
172 declared_roots: &rustc_hash::FxHashSet<PathBuf>,
173 ignore_patterns: &globset::GlobSet,
174 undeclared: &mut Vec<WorkspaceDiagnostic>,
175) {
176 if !dir.join("package.json").exists() {
177 return;
178 }
179 let canonical = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
180 if canonical == *canonical_root {
182 return;
183 }
184 if declared_roots.contains(&canonical) {
185 return;
186 }
187 let relative = dir.strip_prefix(root).unwrap_or(dir);
188 let relative_str = relative.to_string_lossy().replace('\\', "/");
192 if ignore_patterns.is_match(relative_str.as_str())
193 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
194 {
195 return;
196 }
197 undeclared.push(WorkspaceDiagnostic {
198 path: dir.to_path_buf(),
199 message: format!(
200 "Directory '{}' contains package.json but is not declared as a workspace",
201 relative.display()
202 ),
203 });
204}
205
206fn collect_workspace_patterns(root: &Path) -> Vec<String> {
208 let mut patterns = Vec::new();
209
210 let pkg_path = root.join("package.json");
212 if let Ok(pkg) = PackageJson::load(&pkg_path) {
213 patterns.extend(pkg.workspace_patterns());
214 }
215
216 let pnpm_workspace = root.join("pnpm-workspace.yaml");
218 if pnpm_workspace.exists()
219 && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
220 {
221 patterns.extend(parse_pnpm_workspace_yaml(&content));
222 }
223
224 patterns
225}
226
227fn expand_patterns_to_workspaces(
232 root: &Path,
233 patterns: &[String],
234 canonical_root: &Path,
235) -> Vec<(WorkspaceInfo, Vec<String>)> {
236 if patterns.is_empty() {
237 return Vec::new();
238 }
239
240 let mut workspaces = Vec::new();
241
242 let (positive, negative): (Vec<&String>, Vec<&String>) =
246 patterns.iter().partition(|p| !p.starts_with('!'));
247 let negation_matchers: Vec<globset::GlobMatcher> = negative
248 .iter()
249 .filter_map(|p| {
250 let stripped = p.strip_prefix('!').unwrap_or(p);
251 globset::Glob::new(stripped)
252 .ok()
253 .map(|g| g.compile_matcher())
254 })
255 .collect();
256
257 for pattern in &positive {
258 let glob_pattern = if pattern.ends_with('/') {
263 format!("{pattern}*")
264 } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
265 (*pattern).clone()
267 } else {
268 (*pattern).clone()
269 };
270
271 let matched_dirs = expand_workspace_glob(root, &glob_pattern, canonical_root);
275 for (dir, canonical_dir) in matched_dirs {
276 if canonical_dir == *canonical_root {
279 continue;
280 }
281
282 let relative = dir.strip_prefix(root).unwrap_or(&dir);
284 let relative_str = relative.to_string_lossy();
285 if negation_matchers
286 .iter()
287 .any(|m| m.is_match(relative_str.as_ref()))
288 {
289 continue;
290 }
291
292 let ws_pkg_path = dir.join("package.json");
294 if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
295 let dep_names = pkg.all_dependency_names();
298 let name = pkg.name.unwrap_or_else(|| {
299 dir.file_name()
300 .map(|n| n.to_string_lossy().to_string())
301 .unwrap_or_default()
302 });
303 workspaces.push((
304 WorkspaceInfo {
305 root: dir,
306 name,
307 is_internal_dependency: false,
308 },
309 dep_names,
310 ));
311 }
312 }
313 }
314
315 workspaces
316}
317
318fn collect_tsconfig_workspaces(
323 root: &Path,
324 canonical_root: &Path,
325) -> Vec<(WorkspaceInfo, Vec<String>)> {
326 let mut workspaces = Vec::new();
327
328 for dir in parse_tsconfig_references(root) {
329 let canonical_dir = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
330 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
332 continue;
333 }
334
335 let ws_pkg_path = dir.join("package.json");
337 let (name, dep_names) = if ws_pkg_path.exists() {
338 if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
339 let deps = pkg.all_dependency_names();
340 let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
341 (n, deps)
342 } else {
343 (dir_name(&dir), Vec::new())
344 }
345 } else {
346 (dir_name(&dir), Vec::new())
349 };
350
351 workspaces.push((
352 WorkspaceInfo {
353 root: dir,
354 name,
355 is_internal_dependency: false,
356 },
357 dep_names,
358 ));
359 }
360
361 workspaces
362}
363
364fn collect_shallow_package_workspaces(
371 root: &Path,
372 canonical_root: &Path,
373) -> Vec<(WorkspaceInfo, Vec<String>)> {
374 let mut workspaces = Vec::new();
375 let Ok(top_entries) = std::fs::read_dir(root) else {
376 return workspaces;
377 };
378
379 for entry in top_entries.filter_map(Result::ok) {
380 let path = entry.path();
381 if !path.is_dir() || should_skip_workspace_scan_dir(&entry.file_name().to_string_lossy()) {
382 continue;
383 }
384
385 collect_shallow_workspace_candidate(&path, canonical_root, &mut workspaces);
386
387 let Ok(child_entries) = std::fs::read_dir(&path) else {
388 continue;
389 };
390 for child in child_entries.filter_map(Result::ok) {
391 let child_path = child.path();
392 if !child_path.is_dir()
393 || should_skip_workspace_scan_dir(&child.file_name().to_string_lossy())
394 {
395 continue;
396 }
397
398 collect_shallow_workspace_candidate(&child_path, canonical_root, &mut workspaces);
399 }
400 }
401
402 workspaces
403}
404
405fn collect_shallow_workspace_candidate(
406 dir: &Path,
407 canonical_root: &Path,
408 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
409) {
410 let pkg_path = dir.join("package.json");
411 if !pkg_path.exists() {
412 return;
413 }
414
415 let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
416 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
417 return;
418 }
419
420 let Ok(pkg) = PackageJson::load(&pkg_path) else {
421 return;
422 };
423 let dep_names = pkg.all_dependency_names();
424 let name = pkg.name.unwrap_or_else(|| dir_name(dir));
425
426 workspaces.push((
427 WorkspaceInfo {
428 root: dir.to_path_buf(),
429 name,
430 is_internal_dependency: false,
431 },
432 dep_names,
433 ));
434}
435
436fn should_skip_workspace_scan_dir(name: &str) -> bool {
437 name.starts_with('.') || name == "node_modules" || name == "build"
438}
439
440fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
446 {
448 let mut seen = rustc_hash::FxHashSet::default();
449 workspaces.retain(|(ws, _)| {
450 let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
451 seen.insert(canonical)
452 });
453 }
454
455 let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
459 .iter()
460 .flat_map(|(_, deps)| deps.iter().cloned())
461 .collect();
462 for (ws, _) in &mut *workspaces {
463 ws.is_internal_dependency = all_dep_names.contains(&ws.name);
464 }
465}
466
467fn dir_name(dir: &Path) -> String {
469 dir.file_name()
470 .map(|n| n.to_string_lossy().to_string())
471 .unwrap_or_default()
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn discover_workspaces_from_tsconfig_references() {
480 let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
481 let _ = std::fs::remove_dir_all(&temp_dir);
482 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
483 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
484
485 std::fs::write(
487 temp_dir.join("tsconfig.json"),
488 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
489 )
490 .unwrap();
491
492 std::fs::write(
494 temp_dir.join("packages/core/package.json"),
495 r#"{"name": "@project/core"}"#,
496 )
497 .unwrap();
498
499 let workspaces = discover_workspaces(&temp_dir);
501 assert_eq!(workspaces.len(), 2);
502 assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
503 assert!(workspaces.iter().any(|ws| ws.name == "ui"));
504
505 let _ = std::fs::remove_dir_all(&temp_dir);
506 }
507
508 #[test]
509 fn tsconfig_references_outside_root_rejected() {
510 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
511 let _ = std::fs::remove_dir_all(&temp_dir);
512 std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
513 std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
515
516 std::fs::write(
517 temp_dir.join("project/tsconfig.json"),
518 r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
519 )
520 .unwrap();
521
522 let workspaces = discover_workspaces(&temp_dir.join("project"));
524 assert_eq!(
525 workspaces.len(),
526 1,
527 "reference outside project root should be rejected: {workspaces:?}"
528 );
529 assert!(
530 workspaces[0]
531 .root
532 .to_string_lossy()
533 .contains("packages/core")
534 );
535
536 let _ = std::fs::remove_dir_all(&temp_dir);
537 }
538
539 #[test]
542 fn dir_name_extracts_last_component() {
543 assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
544 assert_eq!(dir_name(Path::new("/my-app")), "my-app");
545 }
546
547 #[test]
548 fn dir_name_empty_for_root_path() {
549 assert_eq!(dir_name(Path::new("/")), "");
551 }
552
553 #[test]
556 fn workspace_config_deserialize_json() {
557 let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
558 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
559 assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
560 }
561
562 #[test]
563 fn workspace_config_deserialize_empty_patterns() {
564 let json = r#"{"patterns": []}"#;
565 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
566 assert!(config.patterns.is_empty());
567 }
568
569 #[test]
570 fn workspace_config_default_patterns() {
571 let json = "{}";
572 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
573 assert!(config.patterns.is_empty());
574 }
575
576 #[test]
579 fn workspace_info_default_not_internal() {
580 let ws = WorkspaceInfo {
581 root: PathBuf::from("/project/packages/a"),
582 name: "a".to_string(),
583 is_internal_dependency: false,
584 };
585 assert!(!ws.is_internal_dependency);
586 }
587
588 #[test]
591 fn mark_internal_deps_detects_cross_references() {
592 let temp_dir = tempfile::tempdir().expect("create temp dir");
593 let pkg_a = temp_dir.path().join("a");
594 let pkg_b = temp_dir.path().join("b");
595 std::fs::create_dir_all(&pkg_a).unwrap();
596 std::fs::create_dir_all(&pkg_b).unwrap();
597
598 let mut workspaces = vec![
599 (
600 WorkspaceInfo {
601 root: pkg_a,
602 name: "@scope/a".to_string(),
603 is_internal_dependency: false,
604 },
605 vec!["@scope/b".to_string()], ),
607 (
608 WorkspaceInfo {
609 root: pkg_b,
610 name: "@scope/b".to_string(),
611 is_internal_dependency: false,
612 },
613 vec!["lodash".to_string()], ),
615 ];
616
617 mark_internal_dependencies(&mut workspaces);
618
619 let ws_a = workspaces
621 .iter()
622 .find(|(ws, _)| ws.name == "@scope/a")
623 .unwrap();
624 assert!(
625 !ws_a.0.is_internal_dependency,
626 "a is not depended on by others"
627 );
628
629 let ws_b = workspaces
630 .iter()
631 .find(|(ws, _)| ws.name == "@scope/b")
632 .unwrap();
633 assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
634 }
635
636 #[test]
637 fn mark_internal_deps_no_cross_references() {
638 let temp_dir = tempfile::tempdir().expect("create temp dir");
639 let pkg_a = temp_dir.path().join("a");
640 let pkg_b = temp_dir.path().join("b");
641 std::fs::create_dir_all(&pkg_a).unwrap();
642 std::fs::create_dir_all(&pkg_b).unwrap();
643
644 let mut workspaces = vec![
645 (
646 WorkspaceInfo {
647 root: pkg_a,
648 name: "a".to_string(),
649 is_internal_dependency: false,
650 },
651 vec!["react".to_string()],
652 ),
653 (
654 WorkspaceInfo {
655 root: pkg_b,
656 name: "b".to_string(),
657 is_internal_dependency: false,
658 },
659 vec!["lodash".to_string()],
660 ),
661 ];
662
663 mark_internal_dependencies(&mut workspaces);
664
665 assert!(!workspaces[0].0.is_internal_dependency);
666 assert!(!workspaces[1].0.is_internal_dependency);
667 }
668
669 #[test]
670 fn mark_internal_deps_deduplicates_by_path() {
671 let temp_dir = tempfile::tempdir().expect("create temp dir");
672 let pkg_a = temp_dir.path().join("a");
673 std::fs::create_dir_all(&pkg_a).unwrap();
674
675 let mut workspaces = vec![
676 (
677 WorkspaceInfo {
678 root: pkg_a.clone(),
679 name: "a".to_string(),
680 is_internal_dependency: false,
681 },
682 vec![],
683 ),
684 (
685 WorkspaceInfo {
686 root: pkg_a,
687 name: "a".to_string(),
688 is_internal_dependency: false,
689 },
690 vec![],
691 ),
692 ];
693
694 mark_internal_dependencies(&mut workspaces);
695 assert_eq!(
696 workspaces.len(),
697 1,
698 "duplicate paths should be deduplicated"
699 );
700 }
701
702 #[test]
705 fn collect_patterns_from_package_json() {
706 let dir = tempfile::tempdir().expect("create temp dir");
707 std::fs::write(
708 dir.path().join("package.json"),
709 r#"{"workspaces": ["packages/*", "apps/*"]}"#,
710 )
711 .unwrap();
712
713 let patterns = collect_workspace_patterns(dir.path());
714 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
715 }
716
717 #[test]
718 fn collect_patterns_from_pnpm_workspace() {
719 let dir = tempfile::tempdir().expect("create temp dir");
720 std::fs::write(
721 dir.path().join("pnpm-workspace.yaml"),
722 "packages:\n - 'packages/*'\n - 'libs/*'\n",
723 )
724 .unwrap();
725
726 let patterns = collect_workspace_patterns(dir.path());
727 assert_eq!(patterns, vec!["packages/*", "libs/*"]);
728 }
729
730 #[test]
731 fn collect_patterns_combines_sources() {
732 let dir = tempfile::tempdir().expect("create temp dir");
733 std::fs::write(
734 dir.path().join("package.json"),
735 r#"{"workspaces": ["packages/*"]}"#,
736 )
737 .unwrap();
738 std::fs::write(
739 dir.path().join("pnpm-workspace.yaml"),
740 "packages:\n - 'apps/*'\n",
741 )
742 .unwrap();
743
744 let patterns = collect_workspace_patterns(dir.path());
745 assert!(patterns.contains(&"packages/*".to_string()));
746 assert!(patterns.contains(&"apps/*".to_string()));
747 }
748
749 #[test]
750 fn collect_patterns_empty_when_no_configs() {
751 let dir = tempfile::tempdir().expect("create temp dir");
752 let patterns = collect_workspace_patterns(dir.path());
753 assert!(patterns.is_empty());
754 }
755
756 #[test]
759 fn discover_workspaces_from_package_json() {
760 let dir = tempfile::tempdir().expect("create temp dir");
761 let pkg_a = dir.path().join("packages").join("a");
762 let pkg_b = dir.path().join("packages").join("b");
763 std::fs::create_dir_all(&pkg_a).unwrap();
764 std::fs::create_dir_all(&pkg_b).unwrap();
765
766 std::fs::write(
767 dir.path().join("package.json"),
768 r#"{"workspaces": ["packages/*"]}"#,
769 )
770 .unwrap();
771 std::fs::write(
772 pkg_a.join("package.json"),
773 r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
774 )
775 .unwrap();
776 std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
777
778 let workspaces = discover_workspaces(dir.path());
779 assert_eq!(workspaces.len(), 2);
780
781 let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
782 assert!(!ws_a.is_internal_dependency);
783
784 let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
785 assert!(ws_b.is_internal_dependency, "b is depended on by a");
786 }
787
788 #[test]
789 fn discover_workspaces_empty_project() {
790 let dir = tempfile::tempdir().expect("create temp dir");
791 let workspaces = discover_workspaces(dir.path());
792 assert!(workspaces.is_empty());
793 }
794
795 #[test]
796 fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
797 let dir = tempfile::tempdir().expect("create temp dir");
798 let benchmarks = dir.path().join("benchmarks");
799 let vscode = dir.path().join("editors").join("vscode");
800 let deep = dir.path().join("tests").join("fixtures").join("demo");
801 std::fs::create_dir_all(&benchmarks).unwrap();
802 std::fs::create_dir_all(&vscode).unwrap();
803 std::fs::create_dir_all(&deep).unwrap();
804
805 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
806 std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
807 std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
808
809 let workspaces = discover_workspaces(dir.path());
810 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
811
812 assert!(
813 names.contains(&"benchmarks"),
814 "top-level nested package should be discovered: {workspaces:?}"
815 );
816 assert!(
817 names.contains(&"fallow-vscode"),
818 "second-level nested package should be discovered: {workspaces:?}"
819 );
820 assert!(
821 !names.contains(&"deep-fixture"),
822 "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
823 );
824 }
825
826 #[test]
827 fn discover_workspaces_with_negated_patterns() {
828 let dir = tempfile::tempdir().expect("create temp dir");
829 let pkg_a = dir.path().join("packages").join("a");
830 let pkg_test = dir.path().join("packages").join("test-utils");
831 std::fs::create_dir_all(&pkg_a).unwrap();
832 std::fs::create_dir_all(&pkg_test).unwrap();
833
834 std::fs::write(
835 dir.path().join("package.json"),
836 r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
837 )
838 .unwrap();
839 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
840 std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
841
842 let workspaces = discover_workspaces(dir.path());
843 assert_eq!(workspaces.len(), 1);
844 assert_eq!(workspaces[0].name, "a");
845 }
846
847 #[test]
848 fn discover_workspaces_skips_root_as_workspace() {
849 let dir = tempfile::tempdir().expect("create temp dir");
850 std::fs::write(
852 dir.path().join("pnpm-workspace.yaml"),
853 "packages:\n - '.'\n",
854 )
855 .unwrap();
856 std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
857
858 let workspaces = discover_workspaces(dir.path());
859 assert!(
860 workspaces.is_empty(),
861 "root directory should not be added as workspace"
862 );
863 }
864
865 #[test]
866 fn discover_workspaces_name_fallback_to_dir_name() {
867 let dir = tempfile::tempdir().expect("create temp dir");
868 let pkg_a = dir.path().join("packages").join("my-app");
869 std::fs::create_dir_all(&pkg_a).unwrap();
870
871 std::fs::write(
872 dir.path().join("package.json"),
873 r#"{"workspaces": ["packages/*"]}"#,
874 )
875 .unwrap();
876 std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
878
879 let workspaces = discover_workspaces(dir.path());
880 assert_eq!(workspaces.len(), 1);
881 assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
882 }
883
884 #[test]
885 fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
886 let dir = tempfile::tempdir().expect("create temp dir");
887 let pkg_a = dir.path().join("packages").join("a");
888 let benchmarks = dir.path().join("benchmarks");
889 std::fs::create_dir_all(&pkg_a).unwrap();
890 std::fs::create_dir_all(&benchmarks).unwrap();
891
892 std::fs::write(
893 dir.path().join("package.json"),
894 r#"{"workspaces": ["packages/*"]}"#,
895 )
896 .unwrap();
897 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
898 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
899
900 let workspaces = discover_workspaces(dir.path());
901 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
902
903 assert_eq!(workspaces.len(), 1);
904 assert!(names.contains(&"a"));
905 assert!(
906 !names.contains(&"benchmarks"),
907 "explicit workspace config should keep undeclared packages out: {workspaces:?}"
908 );
909 }
910
911 #[test]
914 fn undeclared_workspace_detected() {
915 let dir = tempfile::tempdir().expect("create temp dir");
916 let pkg_a = dir.path().join("packages").join("a");
917 let pkg_b = dir.path().join("packages").join("b");
918 std::fs::create_dir_all(&pkg_a).unwrap();
919 std::fs::create_dir_all(&pkg_b).unwrap();
920
921 std::fs::write(
923 dir.path().join("package.json"),
924 r#"{"workspaces": ["packages/a"]}"#,
925 )
926 .unwrap();
927 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
928 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
929
930 let declared = discover_workspaces(dir.path());
931 assert_eq!(declared.len(), 1);
932
933 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
934 assert_eq!(undeclared.len(), 1);
935 assert!(
936 undeclared[0]
937 .path
938 .to_string_lossy()
939 .replace('\\', "/")
940 .contains("packages/b"),
941 "should detect packages/b as undeclared: {:?}",
942 undeclared[0].path
943 );
944 }
945
946 #[test]
947 fn no_undeclared_when_all_covered() {
948 let dir = tempfile::tempdir().expect("create temp dir");
949 let pkg_a = dir.path().join("packages").join("a");
950 std::fs::create_dir_all(&pkg_a).unwrap();
951
952 std::fs::write(
953 dir.path().join("package.json"),
954 r#"{"workspaces": ["packages/*"]}"#,
955 )
956 .unwrap();
957 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
958
959 let declared = discover_workspaces(dir.path());
960 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
961 assert!(undeclared.is_empty());
962 }
963
964 #[test]
965 fn no_undeclared_when_no_workspace_patterns() {
966 let dir = tempfile::tempdir().expect("create temp dir");
967 let sub = dir.path().join("lib");
968 std::fs::create_dir_all(&sub).unwrap();
969
970 std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
972 std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
973
974 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
975 assert!(
976 undeclared.is_empty(),
977 "should skip check when no workspace patterns exist"
978 );
979 }
980
981 #[test]
982 fn undeclared_skips_node_modules_and_hidden_dirs() {
983 let dir = tempfile::tempdir().expect("create temp dir");
984 let nm = dir.path().join("node_modules").join("some-pkg");
985 let hidden = dir.path().join(".hidden");
986 std::fs::create_dir_all(&nm).unwrap();
987 std::fs::create_dir_all(&hidden).unwrap();
988
989 std::fs::write(
990 dir.path().join("package.json"),
991 r#"{"workspaces": ["packages/*"]}"#,
992 )
993 .unwrap();
994 std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
996 std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
997
998 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
999 assert!(
1000 undeclared.is_empty(),
1001 "should not flag node_modules or hidden directories"
1002 );
1003 }
1004
1005 fn build_globset(patterns: &[&str]) -> globset::GlobSet {
1006 let mut builder = globset::GlobSetBuilder::new();
1007 for pattern in patterns {
1008 builder.add(globset::Glob::new(pattern).expect("valid glob"));
1009 }
1010 builder.build().expect("build globset")
1011 }
1012
1013 #[test]
1014 fn undeclared_skips_dirs_matching_ignore_patterns() {
1015 let dir = tempfile::tempdir().expect("create temp dir");
1018 let pkg_a = dir.path().join("packages").join("a");
1019 let vitest_ref = dir.path().join("references").join("vitest");
1020 let tanstack_ref = dir.path().join("references").join("tanstack-router");
1021 std::fs::create_dir_all(&pkg_a).unwrap();
1022 std::fs::create_dir_all(&vitest_ref).unwrap();
1023 std::fs::create_dir_all(&tanstack_ref).unwrap();
1024
1025 std::fs::write(
1026 dir.path().join("package.json"),
1027 r#"{"workspaces": ["packages/*"]}"#,
1028 )
1029 .unwrap();
1030 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1031 std::fs::write(
1032 vitest_ref.join("package.json"),
1033 r#"{"name": "vitest-reference"}"#,
1034 )
1035 .unwrap();
1036 std::fs::write(
1037 tanstack_ref.join("package.json"),
1038 r#"{"name": "tanstack-reference"}"#,
1039 )
1040 .unwrap();
1041
1042 let declared = discover_workspaces(dir.path());
1043 let ignore = build_globset(&["references/*"]);
1044 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1045 assert!(
1046 undeclared.is_empty(),
1047 "references/* should be ignored: {undeclared:?}"
1048 );
1049 }
1050
1051 #[test]
1052 fn undeclared_still_reported_when_ignore_does_not_match() {
1053 let dir = tempfile::tempdir().expect("create temp dir");
1054 let pkg_b = dir.path().join("packages").join("b");
1055 std::fs::create_dir_all(&pkg_b).unwrap();
1056
1057 std::fs::write(
1058 dir.path().join("package.json"),
1059 r#"{"workspaces": ["packages/a"]}"#,
1060 )
1061 .unwrap();
1062 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1063
1064 let declared = discover_workspaces(dir.path());
1065 let ignore = build_globset(&["references/*"]);
1067 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1068 assert_eq!(
1069 undeclared.len(),
1070 1,
1071 "non-matching ignore patterns should not silence other undeclared dirs"
1072 );
1073 }
1074
1075 #[test]
1076 fn undeclared_skips_dirs_matching_package_json_glob() {
1077 let dir = tempfile::tempdir().expect("create temp dir");
1081 let pkg_a = dir.path().join("packages").join("a");
1082 let vitest_ref = dir.path().join("references").join("vitest");
1083 std::fs::create_dir_all(&pkg_a).unwrap();
1084 std::fs::create_dir_all(&vitest_ref).unwrap();
1085
1086 std::fs::write(
1087 dir.path().join("package.json"),
1088 r#"{"workspaces": ["packages/*"]}"#,
1089 )
1090 .unwrap();
1091 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1092 std::fs::write(
1093 vitest_ref.join("package.json"),
1094 r#"{"name": "vitest-reference"}"#,
1095 )
1096 .unwrap();
1097
1098 let declared = discover_workspaces(dir.path());
1099 let ignore = build_globset(&["references/*/package.json"]);
1100 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1101 assert!(
1102 undeclared.is_empty(),
1103 "package.json-suffixed glob should silence the warning: {undeclared:?}"
1104 );
1105 }
1106
1107 #[test]
1108 fn undeclared_skips_dirs_matching_doublestar_ignore() {
1109 let dir = tempfile::tempdir().expect("create temp dir");
1111 let pkg_a = dir.path().join("packages").join("a");
1112 let nested_ref = dir.path().join("references").join("vitest");
1113 std::fs::create_dir_all(&pkg_a).unwrap();
1114 std::fs::create_dir_all(&nested_ref).unwrap();
1115
1116 std::fs::write(
1117 dir.path().join("package.json"),
1118 r#"{"workspaces": ["packages/*"]}"#,
1119 )
1120 .unwrap();
1121 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1122 std::fs::write(
1123 nested_ref.join("package.json"),
1124 r#"{"name": "vitest-reference"}"#,
1125 )
1126 .unwrap();
1127
1128 let declared = discover_workspaces(dir.path());
1129 let ignore = build_globset(&["**/references/**"]);
1130 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1131 assert!(
1132 undeclared.is_empty(),
1133 "**/references/** should ignore nested package.json dirs: {undeclared:?}"
1134 );
1135 }
1136}