rustloclib/
workspace.rs

1//! Cargo workspace discovery and crate enumeration.
2//!
3//! This module provides functionality to discover crates within a Cargo workspace
4//! and enumerate their source files.
5
6use std::path::{Path, PathBuf};
7
8use cargo_metadata::{MetadataCommand, Package};
9
10use crate::error::RustlocError;
11use crate::Result;
12
13/// Information about a crate in a workspace.
14#[derive(Debug, Clone)]
15pub struct CrateInfo {
16    /// Name of the crate
17    pub name: String,
18    /// Root directory of the crate (where Cargo.toml is)
19    pub root: PathBuf,
20    /// Source directories to scan (typically just "src")
21    pub src_dirs: Vec<PathBuf>,
22    /// Test directory if it exists
23    pub tests_dir: Option<PathBuf>,
24    /// Examples directory if it exists
25    pub examples_dir: Option<PathBuf>,
26    /// Benches directory if it exists
27    pub benches_dir: Option<PathBuf>,
28}
29
30impl CrateInfo {
31    /// Create CrateInfo from a cargo_metadata Package
32    fn from_package(package: &Package) -> Self {
33        let root = package
34            .manifest_path
35            .parent()
36            .map(|p| p.to_path_buf().into_std_path_buf())
37            .unwrap_or_default();
38
39        let src_dir = root.join("src");
40        let tests_dir = root.join("tests");
41        let examples_dir = root.join("examples");
42        let benches_dir = root.join("benches");
43
44        Self {
45            name: package.name.clone(),
46            root: root.clone(),
47            src_dirs: if src_dir.exists() {
48                vec![src_dir]
49            } else {
50                vec![]
51            },
52            tests_dir: if tests_dir.exists() {
53                Some(tests_dir)
54            } else {
55                None
56            },
57            examples_dir: if examples_dir.exists() {
58                Some(examples_dir)
59            } else {
60                None
61            },
62            benches_dir: if benches_dir.exists() {
63                Some(benches_dir)
64            } else {
65                None
66            },
67        }
68    }
69
70    /// Get all directories that should be scanned for this crate
71    pub fn all_dirs(&self) -> Vec<&Path> {
72        let mut dirs: Vec<&Path> = self.src_dirs.iter().map(|p| p.as_path()).collect();
73
74        if let Some(ref tests) = self.tests_dir {
75            dirs.push(tests.as_path());
76        }
77        if let Some(ref examples) = self.examples_dir {
78            dirs.push(examples.as_path());
79        }
80        if let Some(ref benches) = self.benches_dir {
81            dirs.push(benches.as_path());
82        }
83
84        dirs
85    }
86}
87
88/// Workspace information containing all discovered crates.
89#[derive(Debug, Clone)]
90pub struct WorkspaceInfo {
91    /// Root directory of the workspace
92    pub root: PathBuf,
93    /// All crates in the workspace
94    pub crates: Vec<CrateInfo>,
95}
96
97impl WorkspaceInfo {
98    /// Discover workspace information from a path.
99    ///
100    /// The path can be:
101    /// - A directory containing Cargo.toml
102    /// - A path to a Cargo.toml file
103    /// - Any path within a cargo project (will search up for Cargo.toml)
104    pub fn discover(path: impl AsRef<Path>) -> Result<Self> {
105        let path = path.as_ref();
106
107        // Find the manifest path
108        let manifest_path = if path.is_file() && path.file_name() == Some("Cargo.toml".as_ref()) {
109            path.to_path_buf()
110        } else if path.is_dir() {
111            let cargo_toml = path.join("Cargo.toml");
112            if cargo_toml.exists() {
113                cargo_toml
114            } else {
115                return Err(RustlocError::PathNotFound(path.to_path_buf()));
116            }
117        } else {
118            return Err(RustlocError::PathNotFound(path.to_path_buf()));
119        };
120
121        let metadata = MetadataCommand::new()
122            .manifest_path(&manifest_path)
123            .exec()
124            .map_err(|e| RustlocError::CargoMetadata(e.to_string()))?;
125
126        let root = metadata.workspace_root.into_std_path_buf();
127
128        // Get workspace members
129        let workspace_members: std::collections::HashSet<_> =
130            metadata.workspace_members.iter().collect();
131
132        let crates: Vec<CrateInfo> = metadata
133            .packages
134            .iter()
135            .filter(|p| workspace_members.contains(&p.id))
136            .map(CrateInfo::from_package)
137            .collect();
138
139        Ok(Self { root, crates })
140    }
141
142    /// Filter crates by name.
143    ///
144    /// Returns a new WorkspaceInfo containing only crates whose names match
145    /// any of the provided names.
146    pub fn filter_by_names(&self, names: &[&str]) -> Self {
147        let crates = self
148            .crates
149            .iter()
150            .filter(|c| names.contains(&c.name.as_str()))
151            .cloned()
152            .collect();
153
154        Self {
155            root: self.root.clone(),
156            crates,
157        }
158    }
159
160    /// Get a crate by name.
161    pub fn get_crate(&self, name: &str) -> Option<&CrateInfo> {
162        self.crates.iter().find(|c| c.name == name)
163    }
164
165    /// Get all crate names.
166    pub fn crate_names(&self) -> Vec<&str> {
167        self.crates.iter().map(|c| c.name.as_str()).collect()
168    }
169}
170
171/// Check if a path is within a Cargo workspace.
172pub fn is_cargo_project(path: impl AsRef<Path>) -> bool {
173    let path = path.as_ref();
174    if path.is_dir() {
175        path.join("Cargo.toml").exists()
176    } else {
177        path.file_name() == Some("Cargo.toml".as_ref()) && path.exists()
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_crate_info_all_dirs() {
187        // Test with a mock CrateInfo
188        let info = CrateInfo {
189            name: "test-crate".to_string(),
190            root: PathBuf::from("/project"),
191            src_dirs: vec![PathBuf::from("/project/src")],
192            tests_dir: Some(PathBuf::from("/project/tests")),
193            examples_dir: Some(PathBuf::from("/project/examples")),
194            benches_dir: None,
195        };
196
197        let dirs = info.all_dirs();
198        assert_eq!(dirs.len(), 3);
199        assert!(dirs.contains(&Path::new("/project/src")));
200        assert!(dirs.contains(&Path::new("/project/tests")));
201        assert!(dirs.contains(&Path::new("/project/examples")));
202    }
203
204    #[test]
205    fn test_workspace_filter_by_names() {
206        let workspace = WorkspaceInfo {
207            root: PathBuf::from("/workspace"),
208            crates: vec![
209                CrateInfo {
210                    name: "crate-a".to_string(),
211                    root: PathBuf::from("/workspace/crate-a"),
212                    src_dirs: vec![],
213                    tests_dir: None,
214                    examples_dir: None,
215                    benches_dir: None,
216                },
217                CrateInfo {
218                    name: "crate-b".to_string(),
219                    root: PathBuf::from("/workspace/crate-b"),
220                    src_dirs: vec![],
221                    tests_dir: None,
222                    examples_dir: None,
223                    benches_dir: None,
224                },
225                CrateInfo {
226                    name: "crate-c".to_string(),
227                    root: PathBuf::from("/workspace/crate-c"),
228                    src_dirs: vec![],
229                    tests_dir: None,
230                    examples_dir: None,
231                    benches_dir: None,
232                },
233            ],
234        };
235
236        let filtered = workspace.filter_by_names(&["crate-a", "crate-c"]);
237        assert_eq!(filtered.crates.len(), 2);
238        assert!(filtered.get_crate("crate-a").is_some());
239        assert!(filtered.get_crate("crate-b").is_none());
240        assert!(filtered.get_crate("crate-c").is_some());
241    }
242
243    #[test]
244    fn test_crate_names() {
245        let workspace = WorkspaceInfo {
246            root: PathBuf::from("/workspace"),
247            crates: vec![
248                CrateInfo {
249                    name: "alpha".to_string(),
250                    root: PathBuf::from("/workspace/alpha"),
251                    src_dirs: vec![],
252                    tests_dir: None,
253                    examples_dir: None,
254                    benches_dir: None,
255                },
256                CrateInfo {
257                    name: "beta".to_string(),
258                    root: PathBuf::from("/workspace/beta"),
259                    src_dirs: vec![],
260                    tests_dir: None,
261                    examples_dir: None,
262                    benches_dir: None,
263                },
264            ],
265        };
266
267        let names = workspace.crate_names();
268        assert_eq!(names, vec!["alpha", "beta"]);
269    }
270
271    #[test]
272    fn test_is_cargo_project() {
273        // Test with temp directory
274        let temp = tempfile::tempdir().unwrap();
275        let temp_path = temp.path();
276
277        // Not a cargo project initially
278        assert!(!is_cargo_project(temp_path));
279
280        // Create Cargo.toml
281        std::fs::write(temp_path.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
282
283        // Now it's a cargo project
284        assert!(is_cargo_project(temp_path));
285        assert!(is_cargo_project(temp_path.join("Cargo.toml")));
286    }
287}