sampo_core/
workspace.rs

1use crate::types::{CrateInfo, Workspace};
2use std::collections::{BTreeMap, BTreeSet};
3use std::fs;
4use std::io;
5use std::path::{Component, Path, PathBuf};
6
7/// Errors that can occur when working with workspaces
8#[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
22/// Discover a Cargo workspace starting from the given directory
23pub 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    // First pass: parse per-crate metadata (name, version)
29    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    // Second pass: compute internal dependencies
65    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
79/// Parse workspace members from the root Cargo.toml
80pub 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
104/// Find the workspace root starting from a directory
105fn 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
122/// Expand a member pattern (plain path or glob) into concrete paths
123fn expand_member_pattern(root: &Path, pattern: &str, paths: &mut Vec<PathBuf>) -> Result<()> {
124    if pattern.contains('*') {
125        // Glob pattern
126        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            // Only include if it has a Cargo.toml
135            if path.join("Cargo.toml").exists() {
136                paths.push(path);
137            }
138        }
139    } else {
140        // Plain path
141        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
154/// Clean a path by resolving .. and . components
155fn 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                // pop only normal components; keep root prefixes
162                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
177/// Collect internal dependencies for a crate
178fn 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
196/// Check if a dependency is internal to the workspace
197fn 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        // Check for `path = "..."` dependency
205        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        // Check for `workspace = true` dependency
212        if let Some(workspace_val) = tbl.get("workspace")
213            && workspace_val.as_bool() == Some(true)
214        {
215            // Only internal if dependency name is another workspace member
216            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        // Test that we can't escape beyond root directory
237        let input = Path::new("/a/../..");
238        let expected = PathBuf::from("/");
239        assert_eq!(clean_path(input), expected);
240
241        // Test with relative paths
242        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        // Create workspace Cargo.toml
252        fs::write(
253            root.join("Cargo.toml"),
254            "[workspace]\nmembers = [\"crates/*\"]\n",
255        )
256        .unwrap();
257
258        // Create crates/a and crates/b with manifests
259        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        // Only create Cargo.toml for one
297        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        // workspace
317        fs::write(
318            root.join("Cargo.toml"),
319            "[workspace]\nmembers = [\"crates/*\"]\n",
320        )
321        .unwrap();
322        // crates: x depends on y via path, and on z via workspace
323        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}