Skip to main content

sr_core/
workspaces.rs

1//! Workspace member discovery for Cargo, npm/pnpm/yarn, and uv.
2//!
3//! Given a workspace root path, returns absolute paths to the manifest file
4//! of every published member. Used by the workspace-aware publishers to:
5//! 1. Aggregate registry checks (Completed iff every member is already published).
6//! 2. Drive per-member publish commands for ecosystems without a native
7//!    `publish --workspace` flag (cargo, uv).
8//!
9//! Non-workspace roots return an empty list.
10
11use std::path::{Path, PathBuf};
12
13/// Discover Cargo workspace member manifests (Cargo.toml paths).
14///
15/// Reads `[workspace].members` globs from the root `Cargo.toml` and resolves
16/// each to a directory containing a `Cargo.toml`. Returns empty if the root
17/// is not a workspace or if the file is missing/unreadable.
18pub fn discover_cargo_members(root: &Path) -> Vec<PathBuf> {
19    let manifest = root.join("Cargo.toml");
20    let text = match std::fs::read_to_string(&manifest) {
21        Ok(t) => t,
22        Err(_) => return Vec::new(),
23    };
24    let doc: toml_edit::DocumentMut = match text.parse() {
25        Ok(d) => d,
26        Err(_) => return Vec::new(),
27    };
28
29    let members = toml_string_array(&doc, &["workspace", "members"]);
30    resolve_member_globs(root, &members, "Cargo.toml")
31}
32
33/// Discover npm/pnpm/yarn workspace member manifests (package.json paths).
34///
35/// Reads `workspaces` from the root `package.json` — supports both the
36/// array form (`"workspaces": ["packages/*"]`) and the object form
37/// (`"workspaces": {"packages": [...]}`). For pnpm-only repos, falls back
38/// to reading `pnpm-workspace.yaml` (`packages:` list).
39pub fn discover_npm_members(root: &Path) -> Vec<PathBuf> {
40    // Prefer package.json workspaces.
41    if let Ok(text) = std::fs::read_to_string(root.join("package.json"))
42        && let Ok(val) = serde_json::from_str::<serde_json::Value>(&text)
43    {
44        let patterns = match val.get("workspaces") {
45            Some(serde_json::Value::Array(arr)) => arr
46                .iter()
47                .filter_map(|v| v.as_str().map(String::from))
48                .collect::<Vec<_>>(),
49            Some(serde_json::Value::Object(obj)) => obj
50                .get("packages")
51                .and_then(|v| v.as_array())
52                .map(|a| {
53                    a.iter()
54                        .filter_map(|v| v.as_str().map(String::from))
55                        .collect::<Vec<_>>()
56                })
57                .unwrap_or_default(),
58            _ => Vec::new(),
59        };
60        if !patterns.is_empty() {
61            return resolve_member_globs(root, &patterns, "package.json");
62        }
63    }
64
65    // Fall back to pnpm-workspace.yaml.
66    if let Ok(text) = std::fs::read_to_string(root.join("pnpm-workspace.yaml"))
67        && let Ok(val) = serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&text)
68    {
69        let patterns: Vec<String> = val
70            .get("packages")
71            .and_then(|v| v.as_sequence())
72            .map(|seq| {
73                seq.iter()
74                    .filter_map(|v| v.as_str().map(String::from))
75                    .collect()
76            })
77            .unwrap_or_default();
78        if !patterns.is_empty() {
79            return resolve_member_globs(root, &patterns, "package.json");
80        }
81    }
82
83    Vec::new()
84}
85
86/// Discover uv workspace member manifests (pyproject.toml paths).
87///
88/// Reads `[tool.uv.workspace].members` globs from the root `pyproject.toml`.
89/// Returns empty for a non-workspace pyproject.
90pub fn discover_uv_members(root: &Path) -> Vec<PathBuf> {
91    let manifest = root.join("pyproject.toml");
92    let text = match std::fs::read_to_string(&manifest) {
93        Ok(t) => t,
94        Err(_) => return Vec::new(),
95    };
96    let doc: toml_edit::DocumentMut = match text.parse() {
97        Ok(d) => d,
98        Err(_) => return Vec::new(),
99    };
100    let members = toml_string_array(&doc, &["tool", "uv", "workspace", "members"]);
101    resolve_member_globs(root, &members, "pyproject.toml")
102}
103
104/// Detect which npm-compatible tool is in use at `root`. Checks lockfiles
105/// in priority order: pnpm > yarn > npm. Returns `"npm"` as the safe default.
106pub fn detect_npm_tool(root: &Path) -> &'static str {
107    if root.join("pnpm-lock.yaml").exists() || root.join("pnpm-workspace.yaml").exists() {
108        "pnpm"
109    } else if root.join("yarn.lock").exists() {
110        "yarn"
111    } else {
112        "npm"
113    }
114}
115
116/// Resolve glob patterns into a list of manifest paths. Each glob is
117/// resolved relative to `root`, and `manifest_name` is appended to each
118/// matched directory. Nonexistent manifests are filtered out.
119fn resolve_member_globs(root: &Path, patterns: &[String], manifest_name: &str) -> Vec<PathBuf> {
120    let mut out = Vec::new();
121    for pattern in patterns {
122        let full = root.join(pattern).to_string_lossy().into_owned();
123        let Ok(entries) = glob::glob(&full) else {
124            continue;
125        };
126        for entry in entries.flatten() {
127            if !entry.is_dir() {
128                continue;
129            }
130            let manifest = entry.join(manifest_name);
131            if manifest.exists() {
132                out.push(manifest);
133            }
134        }
135    }
136    out
137}
138
139fn toml_string_array(doc: &toml_edit::DocumentMut, keys: &[&str]) -> Vec<String> {
140    let mut item: Option<&toml_edit::Item> = None;
141    for key in keys {
142        item = match item {
143            None => doc.get(key),
144            Some(parent) => parent.get(key),
145        };
146        if item.is_none() {
147            return Vec::new();
148        }
149    }
150    item.and_then(|v| v.as_array())
151        .map(|arr| {
152            arr.iter()
153                .filter_map(|v| v.as_str().map(String::from))
154                .collect()
155        })
156        .unwrap_or_default()
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use std::fs;
163
164    fn tempdir() -> tempfile::TempDir {
165        tempfile::tempdir().unwrap()
166    }
167
168    #[test]
169    fn discover_cargo_members_basic() {
170        let dir = tempdir();
171        fs::write(
172            dir.path().join("Cargo.toml"),
173            "[workspace]\nmembers = [\"crates/*\"]\n",
174        )
175        .unwrap();
176        fs::create_dir_all(dir.path().join("crates/core")).unwrap();
177        fs::write(
178            dir.path().join("crates/core/Cargo.toml"),
179            "[package]\nname = \"core\"\nversion = \"0.1.0\"\n",
180        )
181        .unwrap();
182        fs::create_dir_all(dir.path().join("crates/cli")).unwrap();
183        fs::write(
184            dir.path().join("crates/cli/Cargo.toml"),
185            "[package]\nname = \"cli\"\nversion = \"0.1.0\"\n",
186        )
187        .unwrap();
188
189        let members = discover_cargo_members(dir.path());
190        assert_eq!(members.len(), 2);
191    }
192
193    #[test]
194    fn discover_cargo_members_no_workspace_returns_empty() {
195        let dir = tempdir();
196        fs::write(
197            dir.path().join("Cargo.toml"),
198            "[package]\nname = \"p\"\nversion = \"0.1.0\"\n",
199        )
200        .unwrap();
201        assert!(discover_cargo_members(dir.path()).is_empty());
202    }
203
204    #[test]
205    fn discover_npm_members_array_form() {
206        let dir = tempdir();
207        fs::write(
208            dir.path().join("package.json"),
209            r#"{"name": "root", "private": true, "workspaces": ["packages/*"]}"#,
210        )
211        .unwrap();
212        fs::create_dir_all(dir.path().join("packages/a")).unwrap();
213        fs::write(
214            dir.path().join("packages/a/package.json"),
215            r#"{"name": "a", "version": "0.1.0"}"#,
216        )
217        .unwrap();
218
219        let members = discover_npm_members(dir.path());
220        assert_eq!(members.len(), 1);
221    }
222
223    #[test]
224    fn discover_npm_members_object_form() {
225        let dir = tempdir();
226        fs::write(
227            dir.path().join("package.json"),
228            r#"{"workspaces": {"packages": ["pkgs/*"]}}"#,
229        )
230        .unwrap();
231        fs::create_dir_all(dir.path().join("pkgs/a")).unwrap();
232        fs::write(
233            dir.path().join("pkgs/a/package.json"),
234            r#"{"name": "a", "version": "0.1.0"}"#,
235        )
236        .unwrap();
237        assert_eq!(discover_npm_members(dir.path()).len(), 1);
238    }
239
240    #[test]
241    fn discover_npm_members_pnpm_workspace_yaml() {
242        let dir = tempdir();
243        // No workspaces in package.json — should fall back to pnpm-workspace.yaml.
244        fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
245        fs::write(
246            dir.path().join("pnpm-workspace.yaml"),
247            "packages:\n  - packages/*\n",
248        )
249        .unwrap();
250        fs::create_dir_all(dir.path().join("packages/x")).unwrap();
251        fs::write(
252            dir.path().join("packages/x/package.json"),
253            r#"{"name": "x", "version": "0.1.0"}"#,
254        )
255        .unwrap();
256        assert_eq!(discover_npm_members(dir.path()).len(), 1);
257    }
258
259    #[test]
260    fn discover_uv_members_basic() {
261        let dir = tempdir();
262        fs::write(
263            dir.path().join("pyproject.toml"),
264            "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n",
265        )
266        .unwrap();
267        fs::create_dir_all(dir.path().join("packages/core")).unwrap();
268        fs::write(
269            dir.path().join("packages/core/pyproject.toml"),
270            "[project]\nname = \"core\"\nversion = \"0.1.0\"\n",
271        )
272        .unwrap();
273        assert_eq!(discover_uv_members(dir.path()).len(), 1);
274    }
275
276    #[test]
277    fn detect_npm_tool_priority() {
278        let dir = tempdir();
279        assert_eq!(detect_npm_tool(dir.path()), "npm");
280
281        fs::write(dir.path().join("yarn.lock"), "").unwrap();
282        assert_eq!(detect_npm_tool(dir.path()), "yarn");
283
284        fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
285        assert_eq!(detect_npm_tool(dir.path()), "pnpm");
286    }
287}