npm_run_scripts/package/
workspace.rs

1//! Monorepo and workspace support.
2//!
3//! Detects and manages workspaces in monorepo setups using:
4//! - npm/yarn workspaces (package.json `workspaces` field)
5//! - pnpm workspaces (pnpm-workspace.yaml)
6//! - Lerna (lerna.json)
7
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11use serde::Deserialize;
12
13use super::scripts::parse_scripts;
14use super::types::Script;
15
16/// Represents a workspace in a monorepo.
17#[derive(Debug, Clone)]
18pub struct Workspace {
19    /// Name of the workspace package.
20    name: String,
21    /// Path to the workspace directory.
22    path: PathBuf,
23    /// Scripts available in this workspace.
24    scripts: Vec<Script>,
25}
26
27impl Workspace {
28    /// Create a new workspace.
29    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    /// Create a new workspace with scripts.
38    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    /// Get the workspace name.
51    pub fn name(&self) -> &str {
52        &self.name
53    }
54
55    /// Get the workspace path.
56    pub fn path(&self) -> &Path {
57        &self.path
58    }
59
60    /// Get the workspace scripts.
61    pub fn scripts(&self) -> &[Script] {
62        &self.scripts
63    }
64
65    /// Set the workspace scripts.
66    pub fn set_scripts(&mut self, scripts: Vec<Script>) {
67        self.scripts = scripts;
68    }
69
70    /// Check if the workspace has scripts.
71    pub fn has_scripts(&self) -> bool {
72        !self.scripts.is_empty()
73    }
74
75    /// Load scripts from the workspace's package.json.
76    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/// Result of workspace detection.
88#[derive(Debug, Clone, Default)]
89pub struct WorkspaceInfo {
90    /// Whether this is a monorepo root.
91    pub is_monorepo: bool,
92    /// The type of workspace configuration detected.
93    pub workspace_type: Option<WorkspaceType>,
94    /// List of workspaces found.
95    pub workspaces: Vec<Workspace>,
96}
97
98/// Type of workspace configuration.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum WorkspaceType {
101    /// npm/yarn workspaces (package.json)
102    Npm,
103    /// pnpm workspaces (pnpm-workspace.yaml)
104    Pnpm,
105    /// Lerna (lerna.json)
106    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/// pnpm-workspace.yaml structure.
120#[derive(Debug, Deserialize)]
121struct PnpmWorkspace {
122    packages: Option<Vec<String>>,
123}
124
125/// lerna.json structure.
126#[derive(Debug, Deserialize)]
127struct LernaConfig {
128    packages: Option<Vec<String>>,
129}
130
131/// Detect workspaces in a monorepo.
132///
133/// Checks for:
134/// - `workspaces` field in package.json
135/// - `pnpm-workspace.yaml`
136/// - `lerna.json`
137///
138/// Returns a list of workspaces with their scripts loaded.
139pub fn detect_workspaces(project_dir: &Path) -> Result<Vec<Workspace>> {
140    let info = detect_workspace_info(project_dir)?;
141    Ok(info.workspaces)
142}
143
144/// Detect workspace configuration and return detailed info.
145pub fn detect_workspace_info(project_dir: &Path) -> Result<WorkspaceInfo> {
146    // Check for pnpm-workspace.yaml first (most specific)
147    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    // Check for lerna.json
153    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    // Check for workspaces in package.json
159    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
170/// Check if a directory is a monorepo root.
171pub 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
194/// Detect workspaces from package.json workspaces field.
195fn 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
233/// Detect workspaces from pnpm-workspace.yaml.
234fn 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
260/// Detect workspaces from lerna.json.
261fn 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    // Lerna defaults to "packages/*" if not specified
269    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
282/// Resolve workspace glob patterns to actual directories.
283fn 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        // Skip negation patterns (we'll filter later if needed)
289        if pattern.starts_with('!') {
290            continue;
291        }
292
293        // Normalize the pattern
294        let normalized_pattern = normalize_glob_pattern(pattern);
295        let full_pattern = project_dir.join(&normalized_pattern);
296
297        // Use glob to expand the pattern
298        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                    // Skip if not a directory
304                    if !entry.is_dir() {
305                        continue;
306                    }
307
308                    // Skip if we've already seen this path
309                    if !seen_paths.insert(entry.clone()) {
310                        continue;
311                    }
312
313                    // Check for package.json
314                    let package_json = entry.join("package.json");
315                    if !package_json.exists() {
316                        continue;
317                    }
318
319                    // Read workspace info
320                    if let Some(workspace) = create_workspace_from_path(&entry) {
321                        workspaces.push(workspace);
322                    }
323                }
324            }
325            Err(_) => {
326                // If glob fails, try as a direct path
327                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    // Sort workspaces by name
341    workspaces.sort_by(|a, b| a.name.cmp(&b.name));
342
343    Ok(workspaces)
344}
345
346/// Normalize a glob pattern for the glob crate.
347fn normalize_glob_pattern(pattern: &str) -> String {
348    let mut normalized = pattern.to_string();
349
350    // Replace ** with a temp marker to handle it specially
351    normalized = normalized.replace("**", "\0DOUBLESTAR\0");
352
353    // If pattern ends with *, it should match directories
354    // glob crate handles this, but we want to ensure we're matching directories
355    if normalized.ends_with('\0') {
356        normalized.push('*');
357    }
358
359    // Restore double star
360    normalized = normalized.replace("\0DOUBLESTAR\0", "**");
361
362    normalized
363}
364
365/// Create a Workspace from a directory path.
366fn 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    // Get the package name, falling back to directory name
372    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    // Parse scripts - parse_scripts takes a directory path
379    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        // Create root package.json
415        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        // Create packages directory
450        let packages_dir = root.join("packages");
451        fs::create_dir_all(&packages_dir).unwrap();
452
453        // Create package A
454        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        // Create package B
463        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    // ==================== Basic Tests ====================
475
476    #[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    // ==================== npm Workspace Tests ====================
493
494    #[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        // Use object format for workspaces
515        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        // Create packages
524        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    // ==================== pnpm Workspace Tests ====================
536
537    #[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    // ==================== Lerna Tests ====================
563
564    #[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        // Create default packages directory
584        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    // ==================== is_monorepo Tests ====================
597
598    #[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    // ==================== Workspace Scripts Tests ====================
628
629    #[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        // Find pkg-a and check its scripts
637        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    // ==================== Edge Cases ====================
649
650    #[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        // Create workspaces config pointing to a dir without package.json
666        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        // Create directory without package.json
676        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        // Should not include the directory without package.json
681        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    // ==================== Multiple Patterns Tests ====================
692
693    #[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        // Create packages
705        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        // Create apps
712        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}