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_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_val: &toml::Value,
201) -> bool {
202 if let Some(tbl) = dep_val.as_table() {
203 if let Some(path_val) = tbl.get("path")
205 && let Some(path_str) = path_val.as_str()
206 {
207 let dep_path = clean_path(&crate_dir.join(path_str));
208 return name_to_path.values().any(|p| *p == dep_path);
209 }
210 if let Some(workspace_val) = tbl.get("workspace")
212 && workspace_val.as_bool() == Some(true)
213 {
214 return true;
215 }
216 }
217 false
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use std::fs;
224
225 #[test]
226 fn clean_path_collapses_segments() {
227 let input = Path::new("/a/b/../c/./d");
228 let expected = PathBuf::from("/a/c/d");
229 assert_eq!(clean_path(input), expected);
230 }
231
232 #[test]
233 fn clean_path_prevents_escaping_root() {
234 let input = Path::new("/a/../..");
236 let expected = PathBuf::from("/");
237 assert_eq!(clean_path(input), expected);
238
239 let input = Path::new("a/../..");
241 let expected = PathBuf::from("");
242 assert_eq!(clean_path(input), expected);
243 }
244
245 #[test]
246 fn expand_members_supports_plain_and_glob() {
247 let temp = tempfile::tempdir().unwrap();
248 let root = temp.path();
249 fs::write(
251 root.join("Cargo.toml"),
252 "[workspace]\nmembers = [\"crates/*\"]\n",
253 )
254 .unwrap();
255
256 let crates_dir = root.join("crates");
258 fs::create_dir_all(crates_dir.join("a")).unwrap();
259 fs::create_dir_all(crates_dir.join("b")).unwrap();
260 fs::write(
261 crates_dir.join("a/Cargo.toml"),
262 "[package]\nname = \"a\"\nversion = \"0.1.0\"\n",
263 )
264 .unwrap();
265 fs::write(
266 crates_dir.join("b/Cargo.toml"),
267 "[package]\nname = \"b\"\nversion = \"0.2.0\"\n",
268 )
269 .unwrap();
270
271 let (_root, root_toml) = find_workspace_root(root).unwrap();
272 let members = parse_workspace_members(root, &root_toml).unwrap();
273 let mut names: Vec<_> = members
274 .iter()
275 .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
276 .collect();
277 names.sort();
278 assert_eq!(names, vec!["a", "b"]);
279 }
280
281 #[test]
282 fn glob_skips_non_crate_dirs() {
283 let temp = tempfile::tempdir().unwrap();
284 let root = temp.path();
285 fs::write(
286 root.join("Cargo.toml"),
287 "[workspace]\nmembers = [\"crates/*\"]\n",
288 )
289 .unwrap();
290
291 let crates_dir = root.join("crates");
292 fs::create_dir_all(crates_dir.join("real-crate")).unwrap();
293 fs::create_dir_all(crates_dir.join("not-a-crate")).unwrap();
294 fs::write(
296 crates_dir.join("real-crate/Cargo.toml"),
297 "[package]\nname=\"real-crate\"\nversion=\"0.1.0\"\n",
298 )
299 .unwrap();
300
301 let (_root, root_toml) = find_workspace_root(root).unwrap();
302 let members = parse_workspace_members(root, &root_toml).unwrap();
303 assert_eq!(members.len(), 1);
304 assert_eq!(
305 members[0].file_name().unwrap().to_string_lossy(),
306 "real-crate"
307 );
308 }
309
310 #[test]
311 fn internal_deps_detect_path_and_workspace() {
312 let temp = tempfile::tempdir().unwrap();
313 let root = temp.path();
314 fs::write(
316 root.join("Cargo.toml"),
317 "[workspace]\nmembers = [\"crates/*\"]\n",
318 )
319 .unwrap();
320 let crates_dir = root.join("crates");
322 fs::create_dir_all(crates_dir.join("x")).unwrap();
323 fs::create_dir_all(crates_dir.join("y")).unwrap();
324 fs::create_dir_all(crates_dir.join("z")).unwrap();
325 fs::write(
326 crates_dir.join("x/Cargo.toml"),
327 format!(
328 "{}{}{}",
329 "[package]\nname=\"x\"\nversion=\"0.1.0\"\n",
330 "[dependencies]\n",
331 "y={ path=\"../y\" }\n z={ workspace=true }\n"
332 ),
333 )
334 .unwrap();
335 fs::write(
336 crates_dir.join("y/Cargo.toml"),
337 "[package]\nname=\"y\"\nversion=\"0.1.0\"\n",
338 )
339 .unwrap();
340 fs::write(
341 crates_dir.join("z/Cargo.toml"),
342 "[package]\nname=\"z\"\nversion=\"0.1.0\"\n",
343 )
344 .unwrap();
345
346 let ws = discover_workspace(root).unwrap();
347 let x = ws.members.iter().find(|c| c.name == "x").unwrap();
348 assert!(x.internal_deps.contains("y"));
349 assert!(x.internal_deps.contains("z"));
350 }
351}