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