cuenv_workspaces/discovery/
package_json.rs

1//! Discovery implementation for npm/Bun/Yarn workspaces via package.json.
2
3use crate::core::traits::WorkspaceDiscovery;
4use crate::core::types::{PackageManager, Workspace, WorkspaceMember};
5use crate::discovery::{read_json_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 `package.json`.
12///
13/// This handles the `workspaces` field in `package.json`, which is supported by
14/// npm, Bun, and Yarn. It supports both the array format and the object format
15/// (with `packages` key).
16pub struct PackageJsonDiscovery;
17
18impl WorkspaceDiscovery for PackageJsonDiscovery {
19    fn discover(&self, root: &Path) -> Result<Workspace> {
20        let package_json_path = root.join("package.json");
21        if !package_json_path.exists() {
22            return Err(Error::WorkspaceNotFound {
23                path: root.to_path_buf(),
24            });
25        }
26
27        let package_json: PackageJson = read_json_file(&package_json_path)?;
28
29        // If no workspaces field, verify it's a valid package but return empty workspace
30        // logic could vary, but usually a workspace needs the workspaces field
31        if package_json.workspaces.is_none() {
32            // It's a valid single package, but treat as empty workspace for now
33            // or potentially a workspace with just the root member?
34            // For now, we'll return a workspace with no members if workspaces field is missing,
35            // unless we want to treat single-repo as a workspace of 1.
36            // The prompt says: "If no workspaces field, return empty workspace (single-package repo)"
37            // We also need to detect the manager.
38            let manager = detect_manager(root);
39            let mut workspace = Workspace::new(root.to_path_buf(), manager);
40            workspace.lockfile = find_lockfile(root, manager);
41            return Ok(workspace);
42        }
43
44        let members = self.find_members(root)?;
45        let manager = detect_manager(root);
46
47        let mut workspace = Workspace::new(root.to_path_buf(), manager);
48        workspace.members = members;
49        workspace.lockfile = find_lockfile(root, manager);
50
51        Ok(workspace)
52    }
53
54    fn find_members(&self, root: &Path) -> Result<Vec<WorkspaceMember>> {
55        let package_json_path = root.join("package.json");
56        let package_json: PackageJson = read_json_file(&package_json_path)?;
57
58        let patterns = match package_json.workspaces {
59            Some(WorkspacesField::Array(patterns)) => patterns,
60            Some(WorkspacesField::Object { packages, .. }) => packages,
61            None => return Ok(Vec::new()),
62        };
63
64        let matched_paths = resolve_glob_patterns(root, &patterns, &[])?;
65        let mut members = Vec::new();
66
67        for path in matched_paths {
68            if self.validate_member(&path)? {
69                let manifest_path = path.join("package.json");
70                let member_pkg: PackageJson = read_json_file(&manifest_path)?;
71
72                if let Some(name) = member_pkg.name {
73                    let mut dependencies = Vec::new();
74                    if let Some(deps) = member_pkg.dependencies {
75                        dependencies.extend(deps.keys().cloned());
76                    }
77                    if let Some(dev_deps) = member_pkg.dev_dependencies {
78                        dependencies.extend(dev_deps.keys().cloned());
79                    }
80
81                    members.push(WorkspaceMember {
82                        name,
83                        path: path.strip_prefix(root).unwrap_or(&path).to_path_buf(),
84                        manifest_path,
85                        dependencies,
86                    });
87                }
88            }
89        }
90
91        members.sort_by(|a, b| a.name.cmp(&b.name));
92        Ok(members)
93    }
94
95    /// Validates whether a directory is a valid workspace member.
96    ///
97    /// # Tolerant Validation Behavior
98    ///
99    /// This method silently skips (returns `Ok(false)`) for:
100    /// - Directories without a `package.json` file
101    /// - `package.json` files with invalid JSON syntax
102    /// - `package.json` files missing a `name` field
103    ///
104    /// Only I/O errors (permission issues, etc.) are propagated as `Err`.
105    /// This tolerant approach allows workspace discovery to succeed even when
106    /// some member directories are malformed, including only valid members in the result.
107    fn validate_member(&self, member_path: &Path) -> Result<bool> {
108        let manifest_path = member_path.join("package.json");
109        if !manifest_path.exists() {
110            return Ok(false);
111        }
112
113        // Try parsing to ensure it has a name
114        match read_json_file::<PackageJson>(&manifest_path) {
115            Ok(pkg) => {
116                if pkg.name.is_some() {
117                    Ok(true)
118                } else {
119                    Ok(false)
120                }
121            }
122            Err(Error::Json { .. }) => Ok(false), // Invalid JSON: silently skip this member
123            Err(e) => Err(e),                     // I/O error: propagate
124        }
125    }
126}
127
128#[derive(Deserialize)]
129#[serde(rename_all = "camelCase")]
130struct PackageJson {
131    name: Option<String>,
132    workspaces: Option<WorkspacesField>,
133    dependencies: Option<HashMap<String, String>>,
134    dev_dependencies: Option<HashMap<String, String>>,
135}
136
137#[derive(Deserialize)]
138#[serde(untagged)]
139enum WorkspacesField {
140    Array(Vec<String>),
141    Object { packages: Vec<String> },
142}
143
144fn detect_manager(root: &Path) -> PackageManager {
145    if root.join("bun.lock").exists() {
146        PackageManager::Bun
147    } else if root.join("yarn.lock").exists() {
148        // Could distinguish classic/modern by parsing, but for now default to modern or classic?
149        // The PackageManager enum has both. Let's pick one or check version.
150        // For simplicity, if we can't tell, maybe default to YarnClassic as it's more common in older repos,
151        // or check .yarnrc.yml for modern.
152        if root.join(".yarnrc.yml").exists() {
153            PackageManager::YarnModern
154        } else {
155            PackageManager::YarnClassic
156        }
157    } else if root.join("pnpm-lock.yaml").exists() {
158        PackageManager::Pnpm
159    } else {
160        PackageManager::Npm
161    }
162}
163
164fn find_lockfile(root: &Path, manager: PackageManager) -> Option<std::path::PathBuf> {
165    let lockfile = root.join(manager.lockfile_name());
166    if lockfile.exists() {
167        Some(lockfile)
168    } else {
169        None
170    }
171}