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