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