1use std::path::{Path, PathBuf};
12
13pub 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
33pub fn discover_npm_members(root: &Path) -> Vec<PathBuf> {
40 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 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
86pub 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
104pub 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
116fn 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 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}