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_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_val: &toml::Value,
201) -> bool {
202    if let Some(tbl) = dep_val.as_table() {
203        // Check for `path = "..."` dependency
204        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        // Check for `workspace = true` dependency
211        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        // Test that we can't escape beyond root directory
235        let input = Path::new("/a/../..");
236        let expected = PathBuf::from("/");
237        assert_eq!(clean_path(input), expected);
238
239        // Test with relative paths
240        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        // Create workspace Cargo.toml
250        fs::write(
251            root.join("Cargo.toml"),
252            "[workspace]\nmembers = [\"crates/*\"]\n",
253        )
254        .unwrap();
255
256        // Create crates/a and crates/b with manifests
257        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        // Only create Cargo.toml for one
295        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        // workspace
315        fs::write(
316            root.join("Cargo.toml"),
317            "[workspace]\nmembers = [\"crates/*\"]\n",
318        )
319        .unwrap();
320        // crates: x depends on y via path, and on z via workspace
321        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}