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