Skip to main content

cuenv_workspaces/discovery/
pnpm_workspace.rs

1//! Discovery implementation for pnpm workspaces via pnpm-workspace.yaml.
2
3use crate::core::traits::WorkspaceDiscovery;
4use crate::core::types::{PackageManager, Workspace, WorkspaceMember};
5use crate::discovery::{read_json_file, read_yaml_file, resolve_glob_patterns};
6use crate::error::{Error, Result};
7use serde::Deserialize;
8use std::collections::HashMap;
9use std::path::Path;
10
11/// Discovers workspaces configured in `pnpm-workspace.yaml`.
12pub struct PnpmWorkspaceDiscovery;
13
14impl WorkspaceDiscovery for PnpmWorkspaceDiscovery {
15    fn discover(&self, root: &Path) -> Result<Workspace> {
16        let workspace_yaml_path = root.join("pnpm-workspace.yaml");
17        if !workspace_yaml_path.exists() {
18            return Err(Error::WorkspaceNotFound {
19                path: root.to_path_buf(),
20            });
21        }
22
23        // We don't really need the content here except to validate it parses
24        let _: PnpmWorkspace = read_yaml_file(&workspace_yaml_path)?;
25
26        let members = self.find_members(root)?;
27
28        let mut workspace = Workspace::new(root.to_path_buf(), PackageManager::Pnpm);
29        workspace.members = members;
30
31        let lockfile = root.join("pnpm-lock.yaml");
32        if lockfile.exists() {
33            workspace.lockfile = Some(lockfile);
34        }
35
36        Ok(workspace)
37    }
38
39    fn find_members(&self, root: &Path) -> Result<Vec<WorkspaceMember>> {
40        let workspace_yaml_path = root.join("pnpm-workspace.yaml");
41        let workspace_config: PnpmWorkspace = read_yaml_file(&workspace_yaml_path)?;
42
43        let matched_paths = resolve_glob_patterns(root, &workspace_config.packages, &[])?;
44        let mut members = Vec::new();
45
46        for path in matched_paths {
47            if self.validate_member(&path)? {
48                let manifest_path = path.join("package.json");
49                let member_pkg: PackageJson = read_json_file(&manifest_path)?;
50
51                if let Some(name) = member_pkg.name {
52                    let mut dependencies = Vec::new();
53                    if let Some(deps) = member_pkg.dependencies {
54                        dependencies.extend(deps.keys().cloned());
55                    }
56                    if let Some(dev_deps) = member_pkg.dev_dependencies {
57                        dependencies.extend(dev_deps.keys().cloned());
58                    }
59                    if let Some(peer_deps) = member_pkg.peer_dependencies {
60                        dependencies.extend(peer_deps.keys().cloned());
61                    }
62
63                    members.push(WorkspaceMember {
64                        name,
65                        path: path.strip_prefix(root).unwrap_or(&path).to_path_buf(),
66                        manifest_path,
67                        dependencies,
68                    });
69                }
70            }
71        }
72
73        members.sort_by(|a, b| a.name.cmp(&b.name));
74        Ok(members)
75    }
76
77    /// Validates whether a directory is a valid workspace member.
78    ///
79    /// # Tolerant Validation Behavior
80    ///
81    /// This method silently skips (returns `Ok(false)`) for:
82    /// - Directories without a `package.json` file
83    /// - `package.json` files with invalid JSON syntax
84    /// - `package.json` files missing a `name` field
85    ///
86    /// Only I/O errors (permission issues, etc.) are propagated as `Err`.
87    /// This tolerant approach allows workspace discovery to succeed even when
88    /// some member directories are malformed, including only valid members in the result.
89    fn validate_member(&self, member_path: &Path) -> Result<bool> {
90        let manifest_path = member_path.join("package.json");
91        if !manifest_path.exists() {
92            return Ok(false);
93        }
94
95        // Try parsing to ensure it has a name
96        match read_json_file::<PackageJson>(&manifest_path) {
97            Ok(pkg) => {
98                if pkg.name.is_some() {
99                    Ok(true)
100                } else {
101                    Ok(false)
102                }
103            }
104            Err(Error::Json { .. }) => Ok(false), // Invalid JSON: silently skip this member
105            Err(e) => Err(e),                     // I/O error: propagate
106        }
107    }
108}
109
110#[derive(Deserialize)]
111struct PnpmWorkspace {
112    packages: Vec<String>,
113}
114
115#[derive(Deserialize)]
116#[serde(rename_all = "camelCase")]
117struct PackageJson {
118    name: Option<String>,
119    dependencies: Option<HashMap<String, String>>,
120    dev_dependencies: Option<HashMap<String, String>>,
121    peer_dependencies: Option<HashMap<String, String>>,
122}