sampo_core/
workspace.rs

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
9/// Discover a Cargo workspace starting from the given directory
10pub 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    // First pass: parse per-crate metadata (name, version)
16    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    // Second pass: compute internal dependencies
54    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
68/// Parse workspace members from the root Cargo.toml
69pub 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
93/// Find the workspace root starting from a directory
94fn 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
113/// Expand a member pattern (plain path or glob) into concrete paths
114fn expand_member_pattern(root: &Path, pattern: &str, paths: &mut Vec<PathBuf>) -> Result<()> {
115    if pattern.contains('*') {
116        // Glob pattern
117        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            // Only include if it has a Cargo.toml
126            if path.join("Cargo.toml").exists() {
127                paths.push(path);
128            }
129        }
130    } else {
131        // Plain path
132        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
145/// Clean a path by resolving .. and . components
146fn 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                // pop only normal components; keep root prefixes
153                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
168/// Collect internal dependencies for a crate
169fn 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
187/// Check if a dependency is internal to the workspace
188fn 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        // Check for `path = "..."` dependency
196        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        // Check for `workspace = true` dependency
203        if let Some(workspace_val) = tbl.get("workspace")
204            && workspace_val.as_bool() == Some(true)
205        {
206            // Only internal if dependency name is another workspace member
207            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        // Test that we can't escape beyond root directory
228        let input = Path::new("/a/../..");
229        let expected = PathBuf::from("/");
230        assert_eq!(clean_path(input), expected);
231
232        // Test with relative paths
233        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        // Create workspace Cargo.toml
243        fs::write(
244            root.join("Cargo.toml"),
245            "[workspace]\nmembers = [\"crates/*\"]\n",
246        )
247        .unwrap();
248
249        // Create crates/a and crates/b with manifests
250        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        // Only create Cargo.toml for one
288        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        // workspace
308        fs::write(
309            root.join("Cargo.toml"),
310            "[workspace]\nmembers = [\"crates/*\"]\n",
311        )
312        .unwrap();
313        // crates: x depends on y via path, and on z via workspace
314        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}