1use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11use serde::Deserialize;
12
13use super::scripts::parse_scripts;
14use super::types::Script;
15
16#[derive(Debug, Clone)]
18pub struct Workspace {
19 name: String,
21 path: PathBuf,
23 scripts: Vec<Script>,
25}
26
27impl Workspace {
28 pub fn new(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
30 Self {
31 name: name.into(),
32 path: path.into(),
33 scripts: Vec::new(),
34 }
35 }
36
37 pub fn with_scripts(
39 name: impl Into<String>,
40 path: impl Into<PathBuf>,
41 scripts: Vec<Script>,
42 ) -> Self {
43 Self {
44 name: name.into(),
45 path: path.into(),
46 scripts,
47 }
48 }
49
50 pub fn name(&self) -> &str {
52 &self.name
53 }
54
55 pub fn path(&self) -> &Path {
57 &self.path
58 }
59
60 pub fn scripts(&self) -> &[Script] {
62 &self.scripts
63 }
64
65 pub fn set_scripts(&mut self, scripts: Vec<Script>) {
67 self.scripts = scripts;
68 }
69
70 pub fn has_scripts(&self) -> bool {
72 !self.scripts.is_empty()
73 }
74
75 pub fn load_scripts(&mut self) -> Result<()> {
77 let package_json = self.path.join("package.json");
78 if package_json.exists() {
79 self.scripts = parse_scripts(&self.path)
80 .map(|scripts| scripts.into_iter().collect())
81 .unwrap_or_default();
82 }
83 Ok(())
84 }
85}
86
87#[derive(Debug, Clone, Default)]
89pub struct WorkspaceInfo {
90 pub is_monorepo: bool,
92 pub workspace_type: Option<WorkspaceType>,
94 pub workspaces: Vec<Workspace>,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum WorkspaceType {
101 Npm,
103 Pnpm,
105 Lerna,
107}
108
109impl std::fmt::Display for WorkspaceType {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 match self {
112 WorkspaceType::Npm => write!(f, "npm workspaces"),
113 WorkspaceType::Pnpm => write!(f, "pnpm workspaces"),
114 WorkspaceType::Lerna => write!(f, "lerna"),
115 }
116 }
117}
118
119#[derive(Debug, Deserialize)]
121struct PnpmWorkspace {
122 packages: Option<Vec<String>>,
123}
124
125#[derive(Debug, Deserialize)]
127struct LernaConfig {
128 packages: Option<Vec<String>>,
129}
130
131pub fn detect_workspaces(project_dir: &Path) -> Result<Vec<Workspace>> {
140 let info = detect_workspace_info(project_dir)?;
141 Ok(info.workspaces)
142}
143
144pub fn detect_workspace_info(project_dir: &Path) -> Result<WorkspaceInfo> {
146 let pnpm_workspace = project_dir.join("pnpm-workspace.yaml");
148 if pnpm_workspace.exists() {
149 return detect_pnpm_workspaces(project_dir, &pnpm_workspace);
150 }
151
152 let lerna_json = project_dir.join("lerna.json");
154 if lerna_json.exists() {
155 return detect_lerna_workspaces(project_dir, &lerna_json);
156 }
157
158 let package_json = project_dir.join("package.json");
160 if package_json.exists() {
161 let info = detect_npm_workspaces(project_dir, &package_json)?;
162 if info.is_monorepo {
163 return Ok(info);
164 }
165 }
166
167 Ok(WorkspaceInfo::default())
168}
169
170pub fn is_monorepo(project_dir: &Path) -> bool {
172 let pnpm_workspace = project_dir.join("pnpm-workspace.yaml");
173 if pnpm_workspace.exists() {
174 return true;
175 }
176
177 let lerna_json = project_dir.join("lerna.json");
178 if lerna_json.exists() {
179 return true;
180 }
181
182 let package_json = project_dir.join("package.json");
183 if package_json.exists() {
184 if let Ok(content) = std::fs::read_to_string(&package_json) {
185 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
186 return json.get("workspaces").is_some();
187 }
188 }
189 }
190
191 false
192}
193
194fn detect_npm_workspaces(project_dir: &Path, package_json: &Path) -> Result<WorkspaceInfo> {
196 let content = std::fs::read_to_string(package_json)
197 .with_context(|| format!("Failed to read {}", package_json.display()))?;
198 let json: serde_json::Value = serde_json::from_str(&content)
199 .with_context(|| format!("Failed to parse {}", package_json.display()))?;
200
201 let workspace_patterns = match json.get("workspaces") {
202 Some(serde_json::Value::Array(arr)) => arr
203 .iter()
204 .filter_map(|v| v.as_str())
205 .map(String::from)
206 .collect::<Vec<_>>(),
207 Some(serde_json::Value::Object(obj)) => obj
208 .get("packages")
209 .and_then(|p| p.as_array())
210 .map(|arr| {
211 arr.iter()
212 .filter_map(|v| v.as_str())
213 .map(String::from)
214 .collect()
215 })
216 .unwrap_or_default(),
217 _ => return Ok(WorkspaceInfo::default()),
218 };
219
220 if workspace_patterns.is_empty() {
221 return Ok(WorkspaceInfo::default());
222 }
223
224 let workspaces = resolve_workspace_patterns(project_dir, &workspace_patterns)?;
225
226 Ok(WorkspaceInfo {
227 is_monorepo: true,
228 workspace_type: Some(WorkspaceType::Npm),
229 workspaces,
230 })
231}
232
233fn detect_pnpm_workspaces(project_dir: &Path, workspace_file: &Path) -> Result<WorkspaceInfo> {
235 let content = std::fs::read_to_string(workspace_file)
236 .with_context(|| format!("Failed to read {}", workspace_file.display()))?;
237
238 let config: PnpmWorkspace = serde_yaml::from_str(&content)
239 .with_context(|| format!("Failed to parse {}", workspace_file.display()))?;
240
241 let patterns = config.packages.unwrap_or_default();
242
243 if patterns.is_empty() {
244 return Ok(WorkspaceInfo {
245 is_monorepo: true,
246 workspace_type: Some(WorkspaceType::Pnpm),
247 workspaces: Vec::new(),
248 });
249 }
250
251 let workspaces = resolve_workspace_patterns(project_dir, &patterns)?;
252
253 Ok(WorkspaceInfo {
254 is_monorepo: true,
255 workspace_type: Some(WorkspaceType::Pnpm),
256 workspaces,
257 })
258}
259
260fn detect_lerna_workspaces(project_dir: &Path, lerna_file: &Path) -> Result<WorkspaceInfo> {
262 let content = std::fs::read_to_string(lerna_file)
263 .with_context(|| format!("Failed to read {}", lerna_file.display()))?;
264
265 let config: LernaConfig = serde_json::from_str(&content)
266 .with_context(|| format!("Failed to parse {}", lerna_file.display()))?;
267
268 let patterns = config
270 .packages
271 .unwrap_or_else(|| vec!["packages/*".to_string()]);
272
273 let workspaces = resolve_workspace_patterns(project_dir, &patterns)?;
274
275 Ok(WorkspaceInfo {
276 is_monorepo: true,
277 workspace_type: Some(WorkspaceType::Lerna),
278 workspaces,
279 })
280}
281
282fn resolve_workspace_patterns(project_dir: &Path, patterns: &[String]) -> Result<Vec<Workspace>> {
284 let mut workspaces = Vec::new();
285 let mut seen_paths = std::collections::HashSet::new();
286
287 for pattern in patterns {
288 if pattern.starts_with('!') {
290 continue;
291 }
292
293 let normalized_pattern = normalize_glob_pattern(pattern);
295 let full_pattern = project_dir.join(&normalized_pattern);
296
297 let glob_pattern = full_pattern.to_string_lossy();
299
300 match glob::glob(&glob_pattern) {
301 Ok(entries) => {
302 for entry in entries.flatten() {
303 if !entry.is_dir() {
305 continue;
306 }
307
308 if !seen_paths.insert(entry.clone()) {
310 continue;
311 }
312
313 let package_json = entry.join("package.json");
315 if !package_json.exists() {
316 continue;
317 }
318
319 if let Some(workspace) = create_workspace_from_path(&entry) {
321 workspaces.push(workspace);
322 }
323 }
324 }
325 Err(_) => {
326 let direct_path = project_dir.join(pattern.trim_end_matches("/*"));
328 if direct_path.is_dir()
329 && direct_path.join("package.json").exists()
330 && seen_paths.insert(direct_path.clone())
331 {
332 if let Some(workspace) = create_workspace_from_path(&direct_path) {
333 workspaces.push(workspace);
334 }
335 }
336 }
337 }
338 }
339
340 workspaces.sort_by(|a, b| a.name.cmp(&b.name));
342
343 Ok(workspaces)
344}
345
346fn normalize_glob_pattern(pattern: &str) -> String {
348 let mut normalized = pattern.to_string();
349
350 normalized = normalized.replace("**", "\0DOUBLESTAR\0");
352
353 if normalized.ends_with('\0') {
356 normalized.push('*');
357 }
358
359 normalized = normalized.replace("\0DOUBLESTAR\0", "**");
361
362 normalized
363}
364
365fn create_workspace_from_path(path: &Path) -> Option<Workspace> {
367 let package_json = path.join("package.json");
368 let content = std::fs::read_to_string(&package_json).ok()?;
369 let json: serde_json::Value = serde_json::from_str(&content).ok()?;
370
371 let name = json
373 .get("name")
374 .and_then(|n| n.as_str())
375 .map(String::from)
376 .or_else(|| path.file_name().and_then(|n| n.to_str()).map(String::from))?;
377
378 let scripts: Vec<Script> = parse_scripts(path)
380 .map(|s| s.into_iter().collect())
381 .unwrap_or_default();
382
383 Some(Workspace::with_scripts(name, path.to_path_buf(), scripts))
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use std::fs;
390 use tempfile::TempDir;
391
392 fn create_package_json(dir: &Path, name: &str, scripts: &[(&str, &str)]) {
393 let scripts_obj: serde_json::Map<_, _> = scripts
394 .iter()
395 .map(|(k, v)| (k.to_string(), serde_json::Value::String(v.to_string())))
396 .collect();
397
398 let package = serde_json::json!({
399 "name": name,
400 "version": "1.0.0",
401 "scripts": scripts_obj
402 });
403
404 fs::write(
405 dir.join("package.json"),
406 serde_json::to_string_pretty(&package).unwrap(),
407 )
408 .unwrap();
409 }
410
411 fn create_monorepo(temp: &TempDir, workspace_type: &str) -> PathBuf {
412 let root = temp.path().to_path_buf();
413
414 let root_scripts = [("dev", "turbo dev"), ("build", "turbo build")];
416
417 match workspace_type {
418 "npm" => {
419 let package = serde_json::json!({
420 "name": "monorepo",
421 "private": true,
422 "workspaces": ["packages/*"],
423 "scripts": {
424 "dev": "turbo dev",
425 "build": "turbo build"
426 }
427 });
428 fs::write(root.join("package.json"), package.to_string()).unwrap();
429 }
430 "pnpm" => {
431 create_package_json(&root, "monorepo", &root_scripts);
432 fs::write(
433 root.join("pnpm-workspace.yaml"),
434 "packages:\n - packages/*\n",
435 )
436 .unwrap();
437 }
438 "lerna" => {
439 create_package_json(&root, "monorepo", &root_scripts);
440 fs::write(
441 root.join("lerna.json"),
442 r#"{"packages": ["packages/*"], "version": "1.0.0"}"#,
443 )
444 .unwrap();
445 }
446 _ => panic!("Unknown workspace type"),
447 }
448
449 let packages_dir = root.join("packages");
451 fs::create_dir_all(&packages_dir).unwrap();
452
453 let pkg_a = packages_dir.join("pkg-a");
455 fs::create_dir_all(&pkg_a).unwrap();
456 create_package_json(
457 &pkg_a,
458 "@monorepo/pkg-a",
459 &[("build", "tsc"), ("test", "jest")],
460 );
461
462 let pkg_b = packages_dir.join("pkg-b");
464 fs::create_dir_all(&pkg_b).unwrap();
465 create_package_json(
466 &pkg_b,
467 "@monorepo/pkg-b",
468 &[("dev", "vite"), ("build", "vite build")],
469 );
470
471 root
472 }
473
474 #[test]
477 fn test_workspace_new() {
478 let ws = Workspace::new("test", "/path/to/workspace");
479 assert_eq!(ws.name(), "test");
480 assert_eq!(ws.path(), Path::new("/path/to/workspace"));
481 assert!(ws.scripts().is_empty());
482 }
483
484 #[test]
485 fn test_workspace_with_scripts() {
486 let scripts = vec![Script::new("build", "tsc"), Script::new("test", "jest")];
487 let ws = Workspace::with_scripts("test", "/path", scripts.clone());
488 assert_eq!(ws.scripts().len(), 2);
489 assert!(ws.has_scripts());
490 }
491
492 #[test]
495 fn test_detect_npm_workspaces() {
496 let temp = TempDir::new().unwrap();
497 let root = create_monorepo(&temp, "npm");
498
499 let info = detect_workspace_info(&root).unwrap();
500 assert!(info.is_monorepo);
501 assert_eq!(info.workspace_type, Some(WorkspaceType::Npm));
502 assert_eq!(info.workspaces.len(), 2);
503
504 let names: Vec<&str> = info.workspaces.iter().map(|w| w.name()).collect();
505 assert!(names.contains(&"@monorepo/pkg-a"));
506 assert!(names.contains(&"@monorepo/pkg-b"));
507 }
508
509 #[test]
510 fn test_detect_npm_workspaces_object_format() {
511 let temp = TempDir::new().unwrap();
512 let root = temp.path();
513
514 let package = serde_json::json!({
516 "name": "monorepo",
517 "workspaces": {
518 "packages": ["packages/*"]
519 }
520 });
521 fs::write(root.join("package.json"), package.to_string()).unwrap();
522
523 let packages_dir = root.join("packages");
525 fs::create_dir_all(&packages_dir).unwrap();
526 let pkg = packages_dir.join("pkg");
527 fs::create_dir_all(&pkg).unwrap();
528 create_package_json(&pkg, "pkg", &[("build", "tsc")]);
529
530 let info = detect_workspace_info(root).unwrap();
531 assert!(info.is_monorepo);
532 assert_eq!(info.workspaces.len(), 1);
533 }
534
535 #[test]
538 fn test_detect_pnpm_workspaces() {
539 let temp = TempDir::new().unwrap();
540 let root = create_monorepo(&temp, "pnpm");
541
542 let info = detect_workspace_info(&root).unwrap();
543 assert!(info.is_monorepo);
544 assert_eq!(info.workspace_type, Some(WorkspaceType::Pnpm));
545 assert_eq!(info.workspaces.len(), 2);
546 }
547
548 #[test]
549 fn test_detect_pnpm_empty_packages() {
550 let temp = TempDir::new().unwrap();
551 let root = temp.path();
552
553 create_package_json(root, "monorepo", &[]);
554 fs::write(root.join("pnpm-workspace.yaml"), "packages: []\n").unwrap();
555
556 let info = detect_workspace_info(root).unwrap();
557 assert!(info.is_monorepo);
558 assert_eq!(info.workspace_type, Some(WorkspaceType::Pnpm));
559 assert!(info.workspaces.is_empty());
560 }
561
562 #[test]
565 fn test_detect_lerna_workspaces() {
566 let temp = TempDir::new().unwrap();
567 let root = create_monorepo(&temp, "lerna");
568
569 let info = detect_workspace_info(&root).unwrap();
570 assert!(info.is_monorepo);
571 assert_eq!(info.workspace_type, Some(WorkspaceType::Lerna));
572 assert_eq!(info.workspaces.len(), 2);
573 }
574
575 #[test]
576 fn test_detect_lerna_default_packages() {
577 let temp = TempDir::new().unwrap();
578 let root = temp.path();
579
580 create_package_json(root, "monorepo", &[]);
581 fs::write(root.join("lerna.json"), r#"{"version": "1.0.0"}"#).unwrap();
582
583 let packages_dir = root.join("packages");
585 fs::create_dir_all(&packages_dir).unwrap();
586 let pkg = packages_dir.join("pkg");
587 fs::create_dir_all(&pkg).unwrap();
588 create_package_json(&pkg, "pkg", &[("build", "tsc")]);
589
590 let info = detect_workspace_info(root).unwrap();
591 assert!(info.is_monorepo);
592 assert_eq!(info.workspace_type, Some(WorkspaceType::Lerna));
593 assert_eq!(info.workspaces.len(), 1);
594 }
595
596 #[test]
599 fn test_is_monorepo_npm() {
600 let temp = TempDir::new().unwrap();
601 let root = create_monorepo(&temp, "npm");
602 assert!(is_monorepo(&root));
603 }
604
605 #[test]
606 fn test_is_monorepo_pnpm() {
607 let temp = TempDir::new().unwrap();
608 let root = create_monorepo(&temp, "pnpm");
609 assert!(is_monorepo(&root));
610 }
611
612 #[test]
613 fn test_is_monorepo_lerna() {
614 let temp = TempDir::new().unwrap();
615 let root = create_monorepo(&temp, "lerna");
616 assert!(is_monorepo(&root));
617 }
618
619 #[test]
620 fn test_is_not_monorepo() {
621 let temp = TempDir::new().unwrap();
622 let root = temp.path();
623 create_package_json(root, "simple-project", &[("build", "tsc")]);
624 assert!(!is_monorepo(root));
625 }
626
627 #[test]
630 fn test_workspace_scripts_loaded() {
631 let temp = TempDir::new().unwrap();
632 let root = create_monorepo(&temp, "npm");
633
634 let workspaces = detect_workspaces(&root).unwrap();
635
636 let pkg_a = workspaces.iter().find(|w| w.name() == "@monorepo/pkg-a");
638 assert!(pkg_a.is_some());
639
640 let pkg_a = pkg_a.unwrap();
641 assert!(pkg_a.has_scripts());
642
643 let script_names: Vec<&str> = pkg_a.scripts().iter().map(|s| s.name()).collect();
644 assert!(script_names.contains(&"build"));
645 assert!(script_names.contains(&"test"));
646 }
647
648 #[test]
651 fn test_no_workspaces() {
652 let temp = TempDir::new().unwrap();
653 create_package_json(temp.path(), "simple", &[("build", "tsc")]);
654
655 let info = detect_workspace_info(temp.path()).unwrap();
656 assert!(!info.is_monorepo);
657 assert!(info.workspaces.is_empty());
658 }
659
660 #[test]
661 fn test_workspace_without_package_json() {
662 let temp = TempDir::new().unwrap();
663 let root = temp.path();
664
665 let package = serde_json::json!({
667 "name": "monorepo",
668 "workspaces": ["packages/*"]
669 });
670 fs::write(root.join("package.json"), package.to_string()).unwrap();
671
672 let packages_dir = root.join("packages");
673 fs::create_dir_all(&packages_dir).unwrap();
674
675 fs::create_dir_all(packages_dir.join("no-pkg")).unwrap();
677
678 let info = detect_workspace_info(root).unwrap();
679 assert!(info.is_monorepo);
680 assert!(info.workspaces.is_empty());
682 }
683
684 #[test]
685 fn test_workspace_type_display() {
686 assert_eq!(format!("{}", WorkspaceType::Npm), "npm workspaces");
687 assert_eq!(format!("{}", WorkspaceType::Pnpm), "pnpm workspaces");
688 assert_eq!(format!("{}", WorkspaceType::Lerna), "lerna");
689 }
690
691 #[test]
694 fn test_multiple_workspace_patterns() {
695 let temp = TempDir::new().unwrap();
696 let root = temp.path();
697
698 let package = serde_json::json!({
699 "name": "monorepo",
700 "workspaces": ["packages/*", "apps/*"]
701 });
702 fs::write(root.join("package.json"), package.to_string()).unwrap();
703
704 let packages_dir = root.join("packages");
706 fs::create_dir_all(&packages_dir).unwrap();
707 let pkg = packages_dir.join("lib");
708 fs::create_dir_all(&pkg).unwrap();
709 create_package_json(&pkg, "@monorepo/lib", &[("build", "tsc")]);
710
711 let apps_dir = root.join("apps");
713 fs::create_dir_all(&apps_dir).unwrap();
714 let app = apps_dir.join("web");
715 fs::create_dir_all(&app).unwrap();
716 create_package_json(&app, "@monorepo/web", &[("dev", "vite")]);
717
718 let info = detect_workspace_info(root).unwrap();
719 assert!(info.is_monorepo);
720 assert_eq!(info.workspaces.len(), 2);
721
722 let names: Vec<&str> = info.workspaces.iter().map(|w| w.name()).collect();
723 assert!(names.contains(&"@monorepo/lib"));
724 assert!(names.contains(&"@monorepo/web"));
725 }
726}