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