1use crate::errors::WorkspaceError;
2use crate::types::{CrateInfo, Workspace};
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::{Component, Path, PathBuf};
6
7type Result<T> = std::result::Result<T, WorkspaceError>;
8
9pub fn discover_workspace(start_dir: &Path) -> Result<Workspace> {
11 let (root, root_toml) = find_workspace_root(start_dir)?;
12 let members = parse_workspace_members(&root, &root_toml)?;
13 let mut crates = Vec::new();
14
15 let mut name_to_path: BTreeMap<String, PathBuf> = BTreeMap::new();
17 for member_dir in &members {
18 let manifest_path = member_dir.join("Cargo.toml");
19 let text = fs::read_to_string(&manifest_path).map_err(|e| {
20 WorkspaceError::Io(crate::errors::io_error_with_path(e, &manifest_path))
21 })?;
22 let value: toml::Value = text.parse().map_err(|e| {
23 WorkspaceError::InvalidToml(format!("{}: {}", manifest_path.display(), e))
24 })?;
25 let pkg = value
26 .get("package")
27 .and_then(|v| v.as_table())
28 .ok_or_else(|| {
29 WorkspaceError::InvalidToml(format!(
30 "missing [package] in {}",
31 manifest_path.display()
32 ))
33 })?;
34 let name = pkg
35 .get("name")
36 .and_then(|v| v.as_str())
37 .ok_or_else(|| {
38 WorkspaceError::InvalidToml(format!(
39 "missing package.name in {}",
40 manifest_path.display()
41 ))
42 })?
43 .to_string();
44 let version = pkg
45 .get("version")
46 .and_then(|v| v.as_str())
47 .unwrap_or("")
48 .to_string();
49 name_to_path.insert(name.clone(), member_dir.clone());
50 crates.push((name, version, member_dir.clone(), value));
51 }
52
53 let mut out: Vec<CrateInfo> = Vec::new();
55 for (name, version, path, manifest) in crates {
56 let internal_deps = collect_internal_deps(&path, &name_to_path, &manifest);
57 out.push(CrateInfo {
58 name,
59 version,
60 path,
61 internal_deps,
62 });
63 }
64
65 Ok(Workspace { root, members: out })
66}
67
68pub fn parse_workspace_members(root: &Path, root_toml: &toml::Value) -> Result<Vec<PathBuf>> {
70 let workspace = root_toml
71 .get("workspace")
72 .and_then(|v| v.as_table())
73 .ok_or(WorkspaceError::NotFound)?;
74
75 let members = workspace
76 .get("members")
77 .and_then(|v| v.as_array())
78 .ok_or_else(|| {
79 WorkspaceError::InvalidWorkspace("missing 'members' in [workspace]".into())
80 })?;
81
82 let mut paths = Vec::new();
83 for mem in members {
84 let pattern = mem.as_str().ok_or_else(|| {
85 WorkspaceError::InvalidWorkspace("non-string member in workspace.members".into())
86 })?;
87 expand_member_pattern(root, pattern, &mut paths)?;
88 }
89
90 Ok(paths)
91}
92
93fn find_workspace_root(start_dir: &Path) -> Result<(PathBuf, toml::Value)> {
95 let mut current = start_dir;
96 loop {
97 let toml_path = current.join("Cargo.toml");
98 if toml_path.exists() {
99 let text = fs::read_to_string(&toml_path).map_err(|e| {
100 WorkspaceError::Io(crate::errors::io_error_with_path(e, &toml_path))
101 })?;
102 let value: toml::Value = text.parse().map_err(|e| {
103 WorkspaceError::InvalidToml(format!("{}: {}", toml_path.display(), e))
104 })?;
105 if value.get("workspace").is_some() {
106 return Ok((current.to_path_buf(), value));
107 }
108 }
109 current = current.parent().ok_or(WorkspaceError::NotFound)?;
110 }
111}
112
113fn expand_member_pattern(root: &Path, pattern: &str, paths: &mut Vec<PathBuf>) -> Result<()> {
115 if pattern.contains('*') {
116 let full_pattern = root.join(pattern);
118 let pattern_str = full_pattern.to_string_lossy();
119 let entries = glob::glob(&pattern_str).map_err(|e| {
120 WorkspaceError::InvalidWorkspace(format!("invalid glob pattern '{}': {}", pattern, e))
121 })?;
122 for entry in entries {
123 let path = entry
124 .map_err(|e| WorkspaceError::InvalidWorkspace(format!("glob error: {}", e)))?;
125 if path.join("Cargo.toml").exists() {
127 paths.push(path);
128 }
129 }
130 } else {
131 let member_path = clean_path(&root.join(pattern));
133 if member_path.join("Cargo.toml").exists() {
134 paths.push(member_path);
135 } else {
136 return Err(WorkspaceError::InvalidWorkspace(format!(
137 "member '{}' does not contain Cargo.toml",
138 pattern
139 )));
140 }
141 }
142 Ok(())
143}
144
145fn clean_path(path: &Path) -> PathBuf {
147 let mut result = PathBuf::new();
148 for component in path.components() {
149 match component {
150 Component::CurDir => {}
151 Component::ParentDir => {
152 if !matches!(
154 result.components().next_back(),
155 Some(Component::RootDir | Component::Prefix(_))
156 ) {
157 result.pop();
158 }
159 }
160 Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {
161 result.push(component);
162 }
163 }
164 }
165 result
166}
167
168fn collect_internal_deps(
170 crate_dir: &Path,
171 name_to_path: &BTreeMap<String, PathBuf>,
172 manifest: &toml::Value,
173) -> BTreeSet<String> {
174 let mut internal = BTreeSet::new();
175 for key in ["dependencies", "dev-dependencies", "build-dependencies"] {
176 if let Some(tbl) = manifest.get(key).and_then(|v| v.as_table()) {
177 for (dep_name, dep_val) in tbl {
178 if is_internal_dep(crate_dir, name_to_path, dep_name, dep_val) {
179 internal.insert(dep_name.clone());
180 }
181 }
182 }
183 }
184 internal
185}
186
187fn is_internal_dep(
189 crate_dir: &Path,
190 name_to_path: &BTreeMap<String, PathBuf>,
191 dep_name: &str,
192 dep_val: &toml::Value,
193) -> bool {
194 if let Some(tbl) = dep_val.as_table() {
195 if let Some(path_val) = tbl.get("path")
197 && let Some(path_str) = path_val.as_str()
198 {
199 let dep_path = clean_path(&crate_dir.join(path_str));
200 return name_to_path.values().any(|p| *p == dep_path);
201 }
202 if let Some(workspace_val) = tbl.get("workspace")
204 && workspace_val.as_bool() == Some(true)
205 {
206 return name_to_path.contains_key(dep_name);
208 }
209 }
210 false
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use std::fs;
217
218 #[test]
219 fn clean_path_collapses_segments() {
220 let input = Path::new("/a/b/../c/./d");
221 let expected = PathBuf::from("/a/c/d");
222 assert_eq!(clean_path(input), expected);
223 }
224
225 #[test]
226 fn clean_path_prevents_escaping_root() {
227 let input = Path::new("/a/../..");
229 let expected = PathBuf::from("/");
230 assert_eq!(clean_path(input), expected);
231
232 let input = Path::new("a/../..");
234 let expected = PathBuf::from("");
235 assert_eq!(clean_path(input), expected);
236 }
237
238 #[test]
239 fn expand_members_supports_plain_and_glob() {
240 let temp = tempfile::tempdir().unwrap();
241 let root = temp.path();
242 fs::write(
244 root.join("Cargo.toml"),
245 "[workspace]\nmembers = [\"crates/*\"]\n",
246 )
247 .unwrap();
248
249 let crates_dir = root.join("crates");
251 fs::create_dir_all(crates_dir.join("a")).unwrap();
252 fs::create_dir_all(crates_dir.join("b")).unwrap();
253 fs::write(
254 crates_dir.join("a/Cargo.toml"),
255 "[package]\nname = \"a\"\nversion = \"0.1.0\"\n",
256 )
257 .unwrap();
258 fs::write(
259 crates_dir.join("b/Cargo.toml"),
260 "[package]\nname = \"b\"\nversion = \"0.2.0\"\n",
261 )
262 .unwrap();
263
264 let (_root, root_toml) = find_workspace_root(root).unwrap();
265 let members = parse_workspace_members(root, &root_toml).unwrap();
266 let mut names: Vec<_> = members
267 .iter()
268 .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
269 .collect();
270 names.sort();
271 assert_eq!(names, vec!["a", "b"]);
272 }
273
274 #[test]
275 fn glob_skips_non_crate_dirs() {
276 let temp = tempfile::tempdir().unwrap();
277 let root = temp.path();
278 fs::write(
279 root.join("Cargo.toml"),
280 "[workspace]\nmembers = [\"crates/*\"]\n",
281 )
282 .unwrap();
283
284 let crates_dir = root.join("crates");
285 fs::create_dir_all(crates_dir.join("real-crate")).unwrap();
286 fs::create_dir_all(crates_dir.join("not-a-crate")).unwrap();
287 fs::write(
289 crates_dir.join("real-crate/Cargo.toml"),
290 "[package]\nname=\"real-crate\"\nversion=\"0.1.0\"\n",
291 )
292 .unwrap();
293
294 let (_root, root_toml) = find_workspace_root(root).unwrap();
295 let members = parse_workspace_members(root, &root_toml).unwrap();
296 assert_eq!(members.len(), 1);
297 assert_eq!(
298 members[0].file_name().unwrap().to_string_lossy(),
299 "real-crate"
300 );
301 }
302
303 #[test]
304 fn internal_deps_detect_path_and_workspace() {
305 let temp = tempfile::tempdir().unwrap();
306 let root = temp.path();
307 fs::write(
309 root.join("Cargo.toml"),
310 "[workspace]\nmembers = [\"crates/*\"]\n",
311 )
312 .unwrap();
313 let crates_dir = root.join("crates");
315 fs::create_dir_all(crates_dir.join("x")).unwrap();
316 fs::create_dir_all(crates_dir.join("y")).unwrap();
317 fs::create_dir_all(crates_dir.join("z")).unwrap();
318 fs::write(
319 crates_dir.join("x/Cargo.toml"),
320 format!(
321 "{}{}{}",
322 "[package]\nname=\"x\"\nversion=\"0.1.0\"\n",
323 "[dependencies]\n",
324 "y={ path=\"../y\" }\n z={ workspace=true }\n"
325 ),
326 )
327 .unwrap();
328 fs::write(
329 crates_dir.join("y/Cargo.toml"),
330 "[package]\nname=\"y\"\nversion=\"0.1.0\"\n",
331 )
332 .unwrap();
333 fs::write(
334 crates_dir.join("z/Cargo.toml"),
335 "[package]\nname=\"z\"\nversion=\"0.1.0\"\n",
336 )
337 .unwrap();
338
339 let ws = discover_workspace(root).unwrap();
340 let x = ws.members.iter().find(|c| c.name == "x").unwrap();
341 assert!(x.internal_deps.contains("y"));
342 assert!(x.internal_deps.contains("z"));
343 }
344
345 #[test]
346 fn workspace_dep_external_is_not_internal() {
347 let temp = tempfile::tempdir().unwrap();
348 let root = temp.path();
349 fs::write(
350 root.join("Cargo.toml"),
351 "[workspace]\nmembers=[\"crates/*\"]\n",
352 )
353 .unwrap();
354
355 let crates_dir = root.join("crates");
356 fs::create_dir_all(crates_dir.join("x")).unwrap();
357 fs::write(
358 crates_dir.join("x/Cargo.toml"),
359 "[package]\nname=\"x\"\nversion=\"0.1.0\"\n[dependencies]\nserde={ workspace=true }\n",
360 )
361 .unwrap();
362
363 let ws = discover_workspace(root).unwrap();
364 let x = ws.members.iter().find(|c| c.name == "x").unwrap();
365 assert!(!x.internal_deps.contains("serde"));
366 }
367}