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;
10use parsers::{expand_workspace_glob, parse_pnpm_workspace_yaml, parse_tsconfig_references};
11
12#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
14pub struct WorkspaceConfig {
15 #[serde(default)]
17 pub patterns: Vec<String>,
18}
19
20#[derive(Debug, Clone)]
22pub struct WorkspaceInfo {
23 pub root: PathBuf,
25 pub name: String,
27 pub is_internal_dependency: bool,
29}
30
31#[must_use]
38pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
39 let patterns = collect_workspace_patterns(root);
40 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
41
42 let mut workspaces = expand_patterns_to_workspaces(root, &patterns, &canonical_root);
43 workspaces.extend(collect_tsconfig_workspaces(root, &canonical_root));
44
45 if workspaces.is_empty() {
46 return Vec::new();
47 }
48
49 mark_internal_dependencies(&mut workspaces);
50 workspaces.into_iter().map(|(ws, _)| ws).collect()
51}
52
53fn collect_workspace_patterns(root: &Path) -> Vec<String> {
55 let mut patterns = Vec::new();
56
57 let pkg_path = root.join("package.json");
59 if let Ok(pkg) = PackageJson::load(&pkg_path) {
60 patterns.extend(pkg.workspace_patterns());
61 }
62
63 let pnpm_workspace = root.join("pnpm-workspace.yaml");
65 if pnpm_workspace.exists()
66 && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
67 {
68 patterns.extend(parse_pnpm_workspace_yaml(&content));
69 }
70
71 patterns
72}
73
74fn expand_patterns_to_workspaces(
79 root: &Path,
80 patterns: &[String],
81 canonical_root: &Path,
82) -> Vec<(WorkspaceInfo, Vec<String>)> {
83 if patterns.is_empty() {
84 return Vec::new();
85 }
86
87 let mut workspaces = Vec::new();
88
89 let (positive, negative): (Vec<&String>, Vec<&String>) =
93 patterns.iter().partition(|p| !p.starts_with('!'));
94 let negation_matchers: Vec<globset::GlobMatcher> = negative
95 .iter()
96 .filter_map(|p| {
97 let stripped = p.strip_prefix('!').unwrap_or(p);
98 globset::Glob::new(stripped)
99 .ok()
100 .map(|g| g.compile_matcher())
101 })
102 .collect();
103
104 for pattern in &positive {
105 let glob_pattern = if pattern.ends_with('/') {
110 format!("{pattern}*")
111 } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
112 (*pattern).clone()
114 } else {
115 (*pattern).clone()
116 };
117
118 let matched_dirs = expand_workspace_glob(root, &glob_pattern, canonical_root);
122 for (dir, canonical_dir) in matched_dirs {
123 if canonical_dir == *canonical_root {
126 continue;
127 }
128
129 let relative = dir.strip_prefix(root).unwrap_or(&dir);
131 let relative_str = relative.to_string_lossy();
132 if negation_matchers
133 .iter()
134 .any(|m| m.is_match(relative_str.as_ref()))
135 {
136 continue;
137 }
138
139 let ws_pkg_path = dir.join("package.json");
141 if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
142 let dep_names = pkg.all_dependency_names();
145 let name = pkg.name.unwrap_or_else(|| {
146 dir.file_name()
147 .map(|n| n.to_string_lossy().to_string())
148 .unwrap_or_default()
149 });
150 workspaces.push((
151 WorkspaceInfo {
152 root: dir,
153 name,
154 is_internal_dependency: false,
155 },
156 dep_names,
157 ));
158 }
159 }
160 }
161
162 workspaces
163}
164
165fn collect_tsconfig_workspaces(
170 root: &Path,
171 canonical_root: &Path,
172) -> Vec<(WorkspaceInfo, Vec<String>)> {
173 let mut workspaces = Vec::new();
174
175 for dir in parse_tsconfig_references(root) {
176 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
177 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
179 continue;
180 }
181
182 let ws_pkg_path = dir.join("package.json");
184 let (name, dep_names) = if ws_pkg_path.exists() {
185 if let Ok(pkg) = PackageJson::load(&ws_pkg_path) {
186 let deps = pkg.all_dependency_names();
187 let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
188 (n, deps)
189 } else {
190 (dir_name(&dir), Vec::new())
191 }
192 } else {
193 (dir_name(&dir), Vec::new())
196 };
197
198 workspaces.push((
199 WorkspaceInfo {
200 root: dir,
201 name,
202 is_internal_dependency: false,
203 },
204 dep_names,
205 ));
206 }
207
208 workspaces
209}
210
211fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
217 {
219 let mut seen = rustc_hash::FxHashSet::default();
220 workspaces.retain(|(ws, _)| {
221 let canonical = ws.root.canonicalize().unwrap_or_else(|_| ws.root.clone());
222 seen.insert(canonical)
223 });
224 }
225
226 let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
230 .iter()
231 .flat_map(|(_, deps)| deps.iter().cloned())
232 .collect();
233 for (ws, _) in &mut *workspaces {
234 ws.is_internal_dependency = all_dep_names.contains(&ws.name);
235 }
236}
237
238fn dir_name(dir: &Path) -> String {
240 dir.file_name()
241 .map(|n| n.to_string_lossy().to_string())
242 .unwrap_or_default()
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn discover_workspaces_from_tsconfig_references() {
251 let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
252 let _ = std::fs::remove_dir_all(&temp_dir);
253 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
254 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
255
256 std::fs::write(
258 temp_dir.join("tsconfig.json"),
259 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
260 )
261 .unwrap();
262
263 std::fs::write(
265 temp_dir.join("packages/core/package.json"),
266 r#"{"name": "@project/core"}"#,
267 )
268 .unwrap();
269
270 let workspaces = discover_workspaces(&temp_dir);
272 assert_eq!(workspaces.len(), 2);
273 assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
274 assert!(workspaces.iter().any(|ws| ws.name == "ui"));
275
276 let _ = std::fs::remove_dir_all(&temp_dir);
277 }
278
279 #[test]
280 fn tsconfig_references_outside_root_rejected() {
281 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
282 let _ = std::fs::remove_dir_all(&temp_dir);
283 std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
284 std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
286
287 std::fs::write(
288 temp_dir.join("project/tsconfig.json"),
289 r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
290 )
291 .unwrap();
292
293 let workspaces = discover_workspaces(&temp_dir.join("project"));
295 assert_eq!(
296 workspaces.len(),
297 1,
298 "reference outside project root should be rejected: {workspaces:?}"
299 );
300 assert!(
301 workspaces[0]
302 .root
303 .to_string_lossy()
304 .contains("packages/core")
305 );
306
307 let _ = std::fs::remove_dir_all(&temp_dir);
308 }
309
310 #[test]
313 fn dir_name_extracts_last_component() {
314 assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
315 assert_eq!(dir_name(Path::new("/my-app")), "my-app");
316 }
317
318 #[test]
319 fn dir_name_empty_for_root_path() {
320 assert_eq!(dir_name(Path::new("/")), "");
322 }
323
324 #[test]
327 fn workspace_config_deserialize_json() {
328 let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
329 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
330 assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
331 }
332
333 #[test]
334 fn workspace_config_deserialize_empty_patterns() {
335 let json = r#"{"patterns": []}"#;
336 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
337 assert!(config.patterns.is_empty());
338 }
339
340 #[test]
341 fn workspace_config_default_patterns() {
342 let json = "{}";
343 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
344 assert!(config.patterns.is_empty());
345 }
346
347 #[test]
350 fn workspace_info_default_not_internal() {
351 let ws = WorkspaceInfo {
352 root: PathBuf::from("/project/packages/a"),
353 name: "a".to_string(),
354 is_internal_dependency: false,
355 };
356 assert!(!ws.is_internal_dependency);
357 }
358
359 #[test]
362 fn mark_internal_deps_detects_cross_references() {
363 let temp_dir = tempfile::tempdir().expect("create temp dir");
364 let pkg_a = temp_dir.path().join("a");
365 let pkg_b = temp_dir.path().join("b");
366 std::fs::create_dir_all(&pkg_a).unwrap();
367 std::fs::create_dir_all(&pkg_b).unwrap();
368
369 let mut workspaces = vec![
370 (
371 WorkspaceInfo {
372 root: pkg_a,
373 name: "@scope/a".to_string(),
374 is_internal_dependency: false,
375 },
376 vec!["@scope/b".to_string()], ),
378 (
379 WorkspaceInfo {
380 root: pkg_b,
381 name: "@scope/b".to_string(),
382 is_internal_dependency: false,
383 },
384 vec!["lodash".to_string()], ),
386 ];
387
388 mark_internal_dependencies(&mut workspaces);
389
390 let ws_a = workspaces
392 .iter()
393 .find(|(ws, _)| ws.name == "@scope/a")
394 .unwrap();
395 assert!(
396 !ws_a.0.is_internal_dependency,
397 "a is not depended on by others"
398 );
399
400 let ws_b = workspaces
401 .iter()
402 .find(|(ws, _)| ws.name == "@scope/b")
403 .unwrap();
404 assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
405 }
406
407 #[test]
408 fn mark_internal_deps_no_cross_references() {
409 let temp_dir = tempfile::tempdir().expect("create temp dir");
410 let pkg_a = temp_dir.path().join("a");
411 let pkg_b = temp_dir.path().join("b");
412 std::fs::create_dir_all(&pkg_a).unwrap();
413 std::fs::create_dir_all(&pkg_b).unwrap();
414
415 let mut workspaces = vec![
416 (
417 WorkspaceInfo {
418 root: pkg_a,
419 name: "a".to_string(),
420 is_internal_dependency: false,
421 },
422 vec!["react".to_string()],
423 ),
424 (
425 WorkspaceInfo {
426 root: pkg_b,
427 name: "b".to_string(),
428 is_internal_dependency: false,
429 },
430 vec!["lodash".to_string()],
431 ),
432 ];
433
434 mark_internal_dependencies(&mut workspaces);
435
436 assert!(!workspaces[0].0.is_internal_dependency);
437 assert!(!workspaces[1].0.is_internal_dependency);
438 }
439
440 #[test]
441 fn mark_internal_deps_deduplicates_by_path() {
442 let temp_dir = tempfile::tempdir().expect("create temp dir");
443 let pkg_a = temp_dir.path().join("a");
444 std::fs::create_dir_all(&pkg_a).unwrap();
445
446 let mut workspaces = vec![
447 (
448 WorkspaceInfo {
449 root: pkg_a.clone(),
450 name: "a".to_string(),
451 is_internal_dependency: false,
452 },
453 vec![],
454 ),
455 (
456 WorkspaceInfo {
457 root: pkg_a,
458 name: "a".to_string(),
459 is_internal_dependency: false,
460 },
461 vec![],
462 ),
463 ];
464
465 mark_internal_dependencies(&mut workspaces);
466 assert_eq!(
467 workspaces.len(),
468 1,
469 "duplicate paths should be deduplicated"
470 );
471 }
472
473 #[test]
476 fn collect_patterns_from_package_json() {
477 let dir = tempfile::tempdir().expect("create temp dir");
478 std::fs::write(
479 dir.path().join("package.json"),
480 r#"{"workspaces": ["packages/*", "apps/*"]}"#,
481 )
482 .unwrap();
483
484 let patterns = collect_workspace_patterns(dir.path());
485 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
486 }
487
488 #[test]
489 fn collect_patterns_from_pnpm_workspace() {
490 let dir = tempfile::tempdir().expect("create temp dir");
491 std::fs::write(
492 dir.path().join("pnpm-workspace.yaml"),
493 "packages:\n - 'packages/*'\n - 'libs/*'\n",
494 )
495 .unwrap();
496
497 let patterns = collect_workspace_patterns(dir.path());
498 assert_eq!(patterns, vec!["packages/*", "libs/*"]);
499 }
500
501 #[test]
502 fn collect_patterns_combines_sources() {
503 let dir = tempfile::tempdir().expect("create temp dir");
504 std::fs::write(
505 dir.path().join("package.json"),
506 r#"{"workspaces": ["packages/*"]}"#,
507 )
508 .unwrap();
509 std::fs::write(
510 dir.path().join("pnpm-workspace.yaml"),
511 "packages:\n - 'apps/*'\n",
512 )
513 .unwrap();
514
515 let patterns = collect_workspace_patterns(dir.path());
516 assert!(patterns.contains(&"packages/*".to_string()));
517 assert!(patterns.contains(&"apps/*".to_string()));
518 }
519
520 #[test]
521 fn collect_patterns_empty_when_no_configs() {
522 let dir = tempfile::tempdir().expect("create temp dir");
523 let patterns = collect_workspace_patterns(dir.path());
524 assert!(patterns.is_empty());
525 }
526
527 #[test]
530 fn discover_workspaces_from_package_json() {
531 let dir = tempfile::tempdir().expect("create temp dir");
532 let pkg_a = dir.path().join("packages").join("a");
533 let pkg_b = dir.path().join("packages").join("b");
534 std::fs::create_dir_all(&pkg_a).unwrap();
535 std::fs::create_dir_all(&pkg_b).unwrap();
536
537 std::fs::write(
538 dir.path().join("package.json"),
539 r#"{"workspaces": ["packages/*"]}"#,
540 )
541 .unwrap();
542 std::fs::write(
543 pkg_a.join("package.json"),
544 r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
545 )
546 .unwrap();
547 std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
548
549 let workspaces = discover_workspaces(dir.path());
550 assert_eq!(workspaces.len(), 2);
551
552 let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
553 assert!(!ws_a.is_internal_dependency);
554
555 let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
556 assert!(ws_b.is_internal_dependency, "b is depended on by a");
557 }
558
559 #[test]
560 fn discover_workspaces_empty_project() {
561 let dir = tempfile::tempdir().expect("create temp dir");
562 let workspaces = discover_workspaces(dir.path());
563 assert!(workspaces.is_empty());
564 }
565
566 #[test]
567 fn discover_workspaces_with_negated_patterns() {
568 let dir = tempfile::tempdir().expect("create temp dir");
569 let pkg_a = dir.path().join("packages").join("a");
570 let pkg_test = dir.path().join("packages").join("test-utils");
571 std::fs::create_dir_all(&pkg_a).unwrap();
572 std::fs::create_dir_all(&pkg_test).unwrap();
573
574 std::fs::write(
575 dir.path().join("package.json"),
576 r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
577 )
578 .unwrap();
579 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
580 std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
581
582 let workspaces = discover_workspaces(dir.path());
583 assert_eq!(workspaces.len(), 1);
584 assert_eq!(workspaces[0].name, "a");
585 }
586
587 #[test]
588 fn discover_workspaces_skips_root_as_workspace() {
589 let dir = tempfile::tempdir().expect("create temp dir");
590 std::fs::write(
592 dir.path().join("pnpm-workspace.yaml"),
593 "packages:\n - '.'\n",
594 )
595 .unwrap();
596 std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
597
598 let workspaces = discover_workspaces(dir.path());
599 assert!(
600 workspaces.is_empty(),
601 "root directory should not be added as workspace"
602 );
603 }
604
605 #[test]
606 fn discover_workspaces_name_fallback_to_dir_name() {
607 let dir = tempfile::tempdir().expect("create temp dir");
608 let pkg_a = dir.path().join("packages").join("my-app");
609 std::fs::create_dir_all(&pkg_a).unwrap();
610
611 std::fs::write(
612 dir.path().join("package.json"),
613 r#"{"workspaces": ["packages/*"]}"#,
614 )
615 .unwrap();
616 std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
618
619 let workspaces = discover_workspaces(dir.path());
620 assert_eq!(workspaces.len(), 1);
621 assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
622 }
623}