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 let patterns = collect_workspace_patterns(root);
78 if patterns.is_empty() {
79 return Vec::new();
80 }
81
82 let declared_roots: rustc_hash::FxHashSet<PathBuf> = declared
83 .iter()
84 .map(|w| dunce::canonicalize(&w.root).unwrap_or_else(|_| w.root.clone()))
85 .collect();
86
87 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
88
89 let mut undeclared = Vec::new();
90
91 let Ok(top_entries) = std::fs::read_dir(root) else {
93 return Vec::new();
94 };
95
96 for entry in top_entries.filter_map(Result::ok) {
97 let path = entry.path();
98 if !path.is_dir() {
99 continue;
100 }
101
102 let name = entry.file_name();
103 let name_str = name.to_string_lossy();
104 if name_str.starts_with('.') || name_str == "node_modules" || name_str == "build" {
105 continue;
106 }
107
108 check_undeclared(
110 &path,
111 root,
112 &canonical_root,
113 &declared_roots,
114 &mut undeclared,
115 );
116
117 let Ok(child_entries) = std::fs::read_dir(&path) else {
119 continue;
120 };
121 for child in child_entries.filter_map(Result::ok) {
122 let child_path = child.path();
123 if !child_path.is_dir() {
124 continue;
125 }
126 let child_name = child.file_name();
127 let child_name_str = child_name.to_string_lossy();
128 if child_name_str.starts_with('.')
129 || child_name_str == "node_modules"
130 || child_name_str == "build"
131 {
132 continue;
133 }
134 check_undeclared(
135 &child_path,
136 root,
137 &canonical_root,
138 &declared_roots,
139 &mut undeclared,
140 );
141 }
142 }
143
144 undeclared
145}
146
147fn check_undeclared(
149 dir: &Path,
150 root: &Path,
151 canonical_root: &Path,
152 declared_roots: &rustc_hash::FxHashSet<PathBuf>,
153 undeclared: &mut Vec<WorkspaceDiagnostic>,
154) {
155 if !dir.join("package.json").exists() {
156 return;
157 }
158 let canonical = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
159 if canonical == *canonical_root {
161 return;
162 }
163 if declared_roots.contains(&canonical) {
164 return;
165 }
166 let relative = dir.strip_prefix(root).unwrap_or(dir);
167 undeclared.push(WorkspaceDiagnostic {
168 path: dir.to_path_buf(),
169 message: format!(
170 "Directory '{}' contains package.json but is not declared as a workspace",
171 relative.display()
172 ),
173 });
174}
175
176fn collect_workspace_patterns(root: &Path) -> Vec<String> {
178 let mut patterns = Vec::new();
179
180 let pkg_path = root.join("package.json");
182 if let Ok(pkg) = PackageJson::load(&pkg_path) {
183 patterns.extend(pkg.workspace_patterns());
184 }
185
186 let pnpm_workspace = root.join("pnpm-workspace.yaml");
188 if pnpm_workspace.exists()
189 && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
190 {
191 patterns.extend(parse_pnpm_workspace_yaml(&content));
192 }
193
194 patterns
195}
196
197fn expand_patterns_to_workspaces(
202 root: &Path,
203 patterns: &[String],
204 canonical_root: &Path,
205) -> Vec<(WorkspaceInfo, Vec<String>)> {
206 if patterns.is_empty() {
207 return Vec::new();
208 }
209
210 let mut workspaces = Vec::new();
211
212 let (positive, negative): (Vec<&String>, Vec<&String>) =
216 patterns.iter().partition(|p| !p.starts_with('!'));
217 let negation_matchers: Vec<globset::GlobMatcher> = negative
218 .iter()
219 .filter_map(|p| {
220 let stripped = p.strip_prefix('!').unwrap_or(p);
221 globset::Glob::new(stripped)
222 .ok()
223 .map(|g| g.compile_matcher())
224 })
225 .collect();
226
227 for pattern in &positive {
228 let glob_pattern = if pattern.ends_with('/') {
233 format!("{pattern}*")
234 } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
235 (*pattern).clone()
237 } else {
238 (*pattern).clone()
239 };
240
241 let matched_dirs = expand_workspace_glob(root, &glob_pattern, canonical_root);
245 for (dir, canonical_dir) in matched_dirs {
246 if canonical_dir == *canonical_root {
249 continue;
250 }
251
252 let relative = dir.strip_prefix(root).unwrap_or(&dir);
254 let relative_str = relative.to_string_lossy();
255 if negation_matchers
256 .iter()
257 .any(|m| m.is_match(relative_str.as_ref()))
258 {
259 continue;
260 }
261
262 let ws_pkg_path = dir.join("package.json");
264 if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
265 let dep_names = pkg.all_dependency_names();
268 let name = pkg.name.unwrap_or_else(|| {
269 dir.file_name()
270 .map(|n| n.to_string_lossy().to_string())
271 .unwrap_or_default()
272 });
273 workspaces.push((
274 WorkspaceInfo {
275 root: dir,
276 name,
277 is_internal_dependency: false,
278 },
279 dep_names,
280 ));
281 }
282 }
283 }
284
285 workspaces
286}
287
288fn collect_tsconfig_workspaces(
293 root: &Path,
294 canonical_root: &Path,
295) -> Vec<(WorkspaceInfo, Vec<String>)> {
296 let mut workspaces = Vec::new();
297
298 for dir in parse_tsconfig_references(root) {
299 let canonical_dir = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
300 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
302 continue;
303 }
304
305 let ws_pkg_path = dir.join("package.json");
307 let (name, dep_names) = if ws_pkg_path.exists() {
308 if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
309 let deps = pkg.all_dependency_names();
310 let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
311 (n, deps)
312 } else {
313 (dir_name(&dir), Vec::new())
314 }
315 } else {
316 (dir_name(&dir), Vec::new())
319 };
320
321 workspaces.push((
322 WorkspaceInfo {
323 root: dir,
324 name,
325 is_internal_dependency: false,
326 },
327 dep_names,
328 ));
329 }
330
331 workspaces
332}
333
334fn collect_shallow_package_workspaces(
341 root: &Path,
342 canonical_root: &Path,
343) -> Vec<(WorkspaceInfo, Vec<String>)> {
344 let mut workspaces = Vec::new();
345 let Ok(top_entries) = std::fs::read_dir(root) else {
346 return workspaces;
347 };
348
349 for entry in top_entries.filter_map(Result::ok) {
350 let path = entry.path();
351 if !path.is_dir() || should_skip_workspace_scan_dir(&entry.file_name().to_string_lossy()) {
352 continue;
353 }
354
355 collect_shallow_workspace_candidate(&path, canonical_root, &mut workspaces);
356
357 let Ok(child_entries) = std::fs::read_dir(&path) else {
358 continue;
359 };
360 for child in child_entries.filter_map(Result::ok) {
361 let child_path = child.path();
362 if !child_path.is_dir()
363 || should_skip_workspace_scan_dir(&child.file_name().to_string_lossy())
364 {
365 continue;
366 }
367
368 collect_shallow_workspace_candidate(&child_path, canonical_root, &mut workspaces);
369 }
370 }
371
372 workspaces
373}
374
375fn collect_shallow_workspace_candidate(
376 dir: &Path,
377 canonical_root: &Path,
378 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
379) {
380 let pkg_path = dir.join("package.json");
381 if !pkg_path.exists() {
382 return;
383 }
384
385 let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
386 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
387 return;
388 }
389
390 let Ok(pkg) = PackageJson::load(&pkg_path) else {
391 return;
392 };
393 let dep_names = pkg.all_dependency_names();
394 let name = pkg.name.unwrap_or_else(|| dir_name(dir));
395
396 workspaces.push((
397 WorkspaceInfo {
398 root: dir.to_path_buf(),
399 name,
400 is_internal_dependency: false,
401 },
402 dep_names,
403 ));
404}
405
406fn should_skip_workspace_scan_dir(name: &str) -> bool {
407 name.starts_with('.') || name == "node_modules" || name == "build"
408}
409
410fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
416 {
418 let mut seen = rustc_hash::FxHashSet::default();
419 workspaces.retain(|(ws, _)| {
420 let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
421 seen.insert(canonical)
422 });
423 }
424
425 let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
429 .iter()
430 .flat_map(|(_, deps)| deps.iter().cloned())
431 .collect();
432 for (ws, _) in &mut *workspaces {
433 ws.is_internal_dependency = all_dep_names.contains(&ws.name);
434 }
435}
436
437fn dir_name(dir: &Path) -> String {
439 dir.file_name()
440 .map(|n| n.to_string_lossy().to_string())
441 .unwrap_or_default()
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn discover_workspaces_from_tsconfig_references() {
450 let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
451 let _ = std::fs::remove_dir_all(&temp_dir);
452 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
453 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
454
455 std::fs::write(
457 temp_dir.join("tsconfig.json"),
458 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
459 )
460 .unwrap();
461
462 std::fs::write(
464 temp_dir.join("packages/core/package.json"),
465 r#"{"name": "@project/core"}"#,
466 )
467 .unwrap();
468
469 let workspaces = discover_workspaces(&temp_dir);
471 assert_eq!(workspaces.len(), 2);
472 assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
473 assert!(workspaces.iter().any(|ws| ws.name == "ui"));
474
475 let _ = std::fs::remove_dir_all(&temp_dir);
476 }
477
478 #[test]
479 fn tsconfig_references_outside_root_rejected() {
480 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
481 let _ = std::fs::remove_dir_all(&temp_dir);
482 std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
483 std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
485
486 std::fs::write(
487 temp_dir.join("project/tsconfig.json"),
488 r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
489 )
490 .unwrap();
491
492 let workspaces = discover_workspaces(&temp_dir.join("project"));
494 assert_eq!(
495 workspaces.len(),
496 1,
497 "reference outside project root should be rejected: {workspaces:?}"
498 );
499 assert!(
500 workspaces[0]
501 .root
502 .to_string_lossy()
503 .contains("packages/core")
504 );
505
506 let _ = std::fs::remove_dir_all(&temp_dir);
507 }
508
509 #[test]
512 fn dir_name_extracts_last_component() {
513 assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
514 assert_eq!(dir_name(Path::new("/my-app")), "my-app");
515 }
516
517 #[test]
518 fn dir_name_empty_for_root_path() {
519 assert_eq!(dir_name(Path::new("/")), "");
521 }
522
523 #[test]
526 fn workspace_config_deserialize_json() {
527 let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
528 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
529 assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
530 }
531
532 #[test]
533 fn workspace_config_deserialize_empty_patterns() {
534 let json = r#"{"patterns": []}"#;
535 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
536 assert!(config.patterns.is_empty());
537 }
538
539 #[test]
540 fn workspace_config_default_patterns() {
541 let json = "{}";
542 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
543 assert!(config.patterns.is_empty());
544 }
545
546 #[test]
549 fn workspace_info_default_not_internal() {
550 let ws = WorkspaceInfo {
551 root: PathBuf::from("/project/packages/a"),
552 name: "a".to_string(),
553 is_internal_dependency: false,
554 };
555 assert!(!ws.is_internal_dependency);
556 }
557
558 #[test]
561 fn mark_internal_deps_detects_cross_references() {
562 let temp_dir = tempfile::tempdir().expect("create temp dir");
563 let pkg_a = temp_dir.path().join("a");
564 let pkg_b = temp_dir.path().join("b");
565 std::fs::create_dir_all(&pkg_a).unwrap();
566 std::fs::create_dir_all(&pkg_b).unwrap();
567
568 let mut workspaces = vec![
569 (
570 WorkspaceInfo {
571 root: pkg_a,
572 name: "@scope/a".to_string(),
573 is_internal_dependency: false,
574 },
575 vec!["@scope/b".to_string()], ),
577 (
578 WorkspaceInfo {
579 root: pkg_b,
580 name: "@scope/b".to_string(),
581 is_internal_dependency: false,
582 },
583 vec!["lodash".to_string()], ),
585 ];
586
587 mark_internal_dependencies(&mut workspaces);
588
589 let ws_a = workspaces
591 .iter()
592 .find(|(ws, _)| ws.name == "@scope/a")
593 .unwrap();
594 assert!(
595 !ws_a.0.is_internal_dependency,
596 "a is not depended on by others"
597 );
598
599 let ws_b = workspaces
600 .iter()
601 .find(|(ws, _)| ws.name == "@scope/b")
602 .unwrap();
603 assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
604 }
605
606 #[test]
607 fn mark_internal_deps_no_cross_references() {
608 let temp_dir = tempfile::tempdir().expect("create temp dir");
609 let pkg_a = temp_dir.path().join("a");
610 let pkg_b = temp_dir.path().join("b");
611 std::fs::create_dir_all(&pkg_a).unwrap();
612 std::fs::create_dir_all(&pkg_b).unwrap();
613
614 let mut workspaces = vec![
615 (
616 WorkspaceInfo {
617 root: pkg_a,
618 name: "a".to_string(),
619 is_internal_dependency: false,
620 },
621 vec!["react".to_string()],
622 ),
623 (
624 WorkspaceInfo {
625 root: pkg_b,
626 name: "b".to_string(),
627 is_internal_dependency: false,
628 },
629 vec!["lodash".to_string()],
630 ),
631 ];
632
633 mark_internal_dependencies(&mut workspaces);
634
635 assert!(!workspaces[0].0.is_internal_dependency);
636 assert!(!workspaces[1].0.is_internal_dependency);
637 }
638
639 #[test]
640 fn mark_internal_deps_deduplicates_by_path() {
641 let temp_dir = tempfile::tempdir().expect("create temp dir");
642 let pkg_a = temp_dir.path().join("a");
643 std::fs::create_dir_all(&pkg_a).unwrap();
644
645 let mut workspaces = vec![
646 (
647 WorkspaceInfo {
648 root: pkg_a.clone(),
649 name: "a".to_string(),
650 is_internal_dependency: false,
651 },
652 vec![],
653 ),
654 (
655 WorkspaceInfo {
656 root: pkg_a,
657 name: "a".to_string(),
658 is_internal_dependency: false,
659 },
660 vec![],
661 ),
662 ];
663
664 mark_internal_dependencies(&mut workspaces);
665 assert_eq!(
666 workspaces.len(),
667 1,
668 "duplicate paths should be deduplicated"
669 );
670 }
671
672 #[test]
675 fn collect_patterns_from_package_json() {
676 let dir = tempfile::tempdir().expect("create temp dir");
677 std::fs::write(
678 dir.path().join("package.json"),
679 r#"{"workspaces": ["packages/*", "apps/*"]}"#,
680 )
681 .unwrap();
682
683 let patterns = collect_workspace_patterns(dir.path());
684 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
685 }
686
687 #[test]
688 fn collect_patterns_from_pnpm_workspace() {
689 let dir = tempfile::tempdir().expect("create temp dir");
690 std::fs::write(
691 dir.path().join("pnpm-workspace.yaml"),
692 "packages:\n - 'packages/*'\n - 'libs/*'\n",
693 )
694 .unwrap();
695
696 let patterns = collect_workspace_patterns(dir.path());
697 assert_eq!(patterns, vec!["packages/*", "libs/*"]);
698 }
699
700 #[test]
701 fn collect_patterns_combines_sources() {
702 let dir = tempfile::tempdir().expect("create temp dir");
703 std::fs::write(
704 dir.path().join("package.json"),
705 r#"{"workspaces": ["packages/*"]}"#,
706 )
707 .unwrap();
708 std::fs::write(
709 dir.path().join("pnpm-workspace.yaml"),
710 "packages:\n - 'apps/*'\n",
711 )
712 .unwrap();
713
714 let patterns = collect_workspace_patterns(dir.path());
715 assert!(patterns.contains(&"packages/*".to_string()));
716 assert!(patterns.contains(&"apps/*".to_string()));
717 }
718
719 #[test]
720 fn collect_patterns_empty_when_no_configs() {
721 let dir = tempfile::tempdir().expect("create temp dir");
722 let patterns = collect_workspace_patterns(dir.path());
723 assert!(patterns.is_empty());
724 }
725
726 #[test]
729 fn discover_workspaces_from_package_json() {
730 let dir = tempfile::tempdir().expect("create temp dir");
731 let pkg_a = dir.path().join("packages").join("a");
732 let pkg_b = dir.path().join("packages").join("b");
733 std::fs::create_dir_all(&pkg_a).unwrap();
734 std::fs::create_dir_all(&pkg_b).unwrap();
735
736 std::fs::write(
737 dir.path().join("package.json"),
738 r#"{"workspaces": ["packages/*"]}"#,
739 )
740 .unwrap();
741 std::fs::write(
742 pkg_a.join("package.json"),
743 r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
744 )
745 .unwrap();
746 std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
747
748 let workspaces = discover_workspaces(dir.path());
749 assert_eq!(workspaces.len(), 2);
750
751 let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
752 assert!(!ws_a.is_internal_dependency);
753
754 let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
755 assert!(ws_b.is_internal_dependency, "b is depended on by a");
756 }
757
758 #[test]
759 fn discover_workspaces_empty_project() {
760 let dir = tempfile::tempdir().expect("create temp dir");
761 let workspaces = discover_workspaces(dir.path());
762 assert!(workspaces.is_empty());
763 }
764
765 #[test]
766 fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
767 let dir = tempfile::tempdir().expect("create temp dir");
768 let benchmarks = dir.path().join("benchmarks");
769 let vscode = dir.path().join("editors").join("vscode");
770 let deep = dir.path().join("tests").join("fixtures").join("demo");
771 std::fs::create_dir_all(&benchmarks).unwrap();
772 std::fs::create_dir_all(&vscode).unwrap();
773 std::fs::create_dir_all(&deep).unwrap();
774
775 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
776 std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
777 std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
778
779 let workspaces = discover_workspaces(dir.path());
780 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
781
782 assert!(
783 names.contains(&"benchmarks"),
784 "top-level nested package should be discovered: {workspaces:?}"
785 );
786 assert!(
787 names.contains(&"fallow-vscode"),
788 "second-level nested package should be discovered: {workspaces:?}"
789 );
790 assert!(
791 !names.contains(&"deep-fixture"),
792 "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
793 );
794 }
795
796 #[test]
797 fn discover_workspaces_with_negated_patterns() {
798 let dir = tempfile::tempdir().expect("create temp dir");
799 let pkg_a = dir.path().join("packages").join("a");
800 let pkg_test = dir.path().join("packages").join("test-utils");
801 std::fs::create_dir_all(&pkg_a).unwrap();
802 std::fs::create_dir_all(&pkg_test).unwrap();
803
804 std::fs::write(
805 dir.path().join("package.json"),
806 r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
807 )
808 .unwrap();
809 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
810 std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
811
812 let workspaces = discover_workspaces(dir.path());
813 assert_eq!(workspaces.len(), 1);
814 assert_eq!(workspaces[0].name, "a");
815 }
816
817 #[test]
818 fn discover_workspaces_skips_root_as_workspace() {
819 let dir = tempfile::tempdir().expect("create temp dir");
820 std::fs::write(
822 dir.path().join("pnpm-workspace.yaml"),
823 "packages:\n - '.'\n",
824 )
825 .unwrap();
826 std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
827
828 let workspaces = discover_workspaces(dir.path());
829 assert!(
830 workspaces.is_empty(),
831 "root directory should not be added as workspace"
832 );
833 }
834
835 #[test]
836 fn discover_workspaces_name_fallback_to_dir_name() {
837 let dir = tempfile::tempdir().expect("create temp dir");
838 let pkg_a = dir.path().join("packages").join("my-app");
839 std::fs::create_dir_all(&pkg_a).unwrap();
840
841 std::fs::write(
842 dir.path().join("package.json"),
843 r#"{"workspaces": ["packages/*"]}"#,
844 )
845 .unwrap();
846 std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
848
849 let workspaces = discover_workspaces(dir.path());
850 assert_eq!(workspaces.len(), 1);
851 assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
852 }
853
854 #[test]
855 fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
856 let dir = tempfile::tempdir().expect("create temp dir");
857 let pkg_a = dir.path().join("packages").join("a");
858 let benchmarks = dir.path().join("benchmarks");
859 std::fs::create_dir_all(&pkg_a).unwrap();
860 std::fs::create_dir_all(&benchmarks).unwrap();
861
862 std::fs::write(
863 dir.path().join("package.json"),
864 r#"{"workspaces": ["packages/*"]}"#,
865 )
866 .unwrap();
867 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
868 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
869
870 let workspaces = discover_workspaces(dir.path());
871 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
872
873 assert_eq!(workspaces.len(), 1);
874 assert!(names.contains(&"a"));
875 assert!(
876 !names.contains(&"benchmarks"),
877 "explicit workspace config should keep undeclared packages out: {workspaces:?}"
878 );
879 }
880
881 #[test]
884 fn undeclared_workspace_detected() {
885 let dir = tempfile::tempdir().expect("create temp dir");
886 let pkg_a = dir.path().join("packages").join("a");
887 let pkg_b = dir.path().join("packages").join("b");
888 std::fs::create_dir_all(&pkg_a).unwrap();
889 std::fs::create_dir_all(&pkg_b).unwrap();
890
891 std::fs::write(
893 dir.path().join("package.json"),
894 r#"{"workspaces": ["packages/a"]}"#,
895 )
896 .unwrap();
897 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
898 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
899
900 let declared = discover_workspaces(dir.path());
901 assert_eq!(declared.len(), 1);
902
903 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
904 assert_eq!(undeclared.len(), 1);
905 assert!(
906 undeclared[0]
907 .path
908 .to_string_lossy()
909 .replace('\\', "/")
910 .contains("packages/b"),
911 "should detect packages/b as undeclared: {:?}",
912 undeclared[0].path
913 );
914 }
915
916 #[test]
917 fn no_undeclared_when_all_covered() {
918 let dir = tempfile::tempdir().expect("create temp dir");
919 let pkg_a = dir.path().join("packages").join("a");
920 std::fs::create_dir_all(&pkg_a).unwrap();
921
922 std::fs::write(
923 dir.path().join("package.json"),
924 r#"{"workspaces": ["packages/*"]}"#,
925 )
926 .unwrap();
927 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
928
929 let declared = discover_workspaces(dir.path());
930 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
931 assert!(undeclared.is_empty());
932 }
933
934 #[test]
935 fn no_undeclared_when_no_workspace_patterns() {
936 let dir = tempfile::tempdir().expect("create temp dir");
937 let sub = dir.path().join("lib");
938 std::fs::create_dir_all(&sub).unwrap();
939
940 std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
942 std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
943
944 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
945 assert!(
946 undeclared.is_empty(),
947 "should skip check when no workspace patterns exist"
948 );
949 }
950
951 #[test]
952 fn undeclared_skips_node_modules_and_hidden_dirs() {
953 let dir = tempfile::tempdir().expect("create temp dir");
954 let nm = dir.path().join("node_modules").join("some-pkg");
955 let hidden = dir.path().join(".hidden");
956 std::fs::create_dir_all(&nm).unwrap();
957 std::fs::create_dir_all(&hidden).unwrap();
958
959 std::fs::write(
960 dir.path().join("package.json"),
961 r#"{"workspaces": ["packages/*"]}"#,
962 )
963 .unwrap();
964 std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
966 std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
967
968 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
969 assert!(
970 undeclared.is_empty(),
971 "should not flag node_modules or hidden directories"
972 );
973 }
974}