1use std::collections::HashMap;
35use std::path::{Path, PathBuf};
36
37use crate::health::RaysenseConfig;
38
39#[derive(Debug, Clone, Default)]
44pub struct WorkspaceMap {
45 pub members_by_crate: HashMap<String, MemberCrate>,
46 pub member_src_dirs: Vec<PathBuf>,
47}
48
49#[derive(Debug, Clone)]
52pub struct MemberCrate {
53 pub crate_name: String,
54 pub manifest_dir: PathBuf,
55 pub src_dir: PathBuf,
56}
57
58pub fn discover(root: &Path, config: &RaysenseConfig) -> WorkspaceMap {
63 let mut map = WorkspaceMap::default();
64 for plugin in &config.scan.plugins {
65 for file_name in &plugin.workspace_manifest_files {
66 if file_name.eq_ignore_ascii_case("Cargo.toml") {
67 discover_cargo_workspace(root, &mut map);
68 }
69 }
70 }
71 let cargo_manifest = root.join("Cargo.toml");
74 if cargo_manifest.is_file() && map.members_by_crate.is_empty() {
75 discover_cargo_workspace(root, &mut map);
76 }
77 map
78}
79
80fn discover_cargo_workspace(root: &Path, map: &mut WorkspaceMap) {
81 let manifest_path = root.join("Cargo.toml");
82 let Ok(text) = std::fs::read_to_string(&manifest_path) else {
83 return;
84 };
85 let Ok(parsed) = text.parse::<toml::Table>() else {
86 return;
87 };
88
89 let mut member_paths: Vec<PathBuf> = Vec::new();
90 if let Some(workspace) = parsed.get("workspace").and_then(|v| v.as_table()) {
91 if let Some(members) = workspace.get("members").and_then(|v| v.as_array()) {
92 for entry in members {
93 let Some(member) = entry.as_str() else {
94 continue;
95 };
96 if member.contains('*') {
97 continue;
101 }
102 member_paths.push(PathBuf::from(member));
103 }
104 }
105 }
106
107 if parsed.get("package").is_some() {
111 member_paths.push(PathBuf::from("."));
112 }
113
114 for relative in member_paths {
115 let manifest_dir_abs = root.join(&relative);
116 let member_manifest = manifest_dir_abs.join("Cargo.toml");
117 let Ok(member_text) = std::fs::read_to_string(&member_manifest) else {
118 continue;
119 };
120 let Ok(member_parsed) = member_text.parse::<toml::Table>() else {
121 continue;
122 };
123 let Some(crate_name) = member_parsed
124 .get("package")
125 .and_then(|v| v.as_table())
126 .and_then(|t| t.get("name"))
127 .and_then(|v| v.as_str())
128 else {
129 continue;
130 };
131 if !manifest_dir_abs.join("src").is_dir() {
132 continue;
133 }
134 let src_dir = if relative == PathBuf::from(".") {
139 PathBuf::from("src")
140 } else {
141 relative.join("src")
142 };
143 let member = MemberCrate {
144 crate_name: crate_name.to_string(),
145 manifest_dir: relative.clone(),
146 src_dir: src_dir.clone(),
147 };
148 map.members_by_crate.insert(crate_name.to_string(), member);
149 map.member_src_dirs.push(src_dir);
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::health::RaysenseConfig;
157 use std::fs;
158 use std::time::{SystemTime, UNIX_EPOCH};
159
160 fn temp_workspace_root(name: &str) -> PathBuf {
161 let nanos = SystemTime::now()
162 .duration_since(UNIX_EPOCH)
163 .unwrap()
164 .as_nanos();
165 let path = std::env::temp_dir().join(format!("raysense_workspace_{name}_{nanos}"));
166 let _ = fs::remove_dir_all(&path);
167 fs::create_dir_all(&path).unwrap();
168 path
169 }
170
171 #[test]
172 fn discovers_cargo_workspace_members() {
173 let root = temp_workspace_root("cargo_members");
174 fs::write(
175 root.join("Cargo.toml"),
176 "[workspace]\nmembers = [\"crates/alpha\", \"crates/beta\"]\n",
177 )
178 .unwrap();
179 for (member, name) in [("crates/alpha", "alpha"), ("crates/beta", "beta")] {
180 let manifest_dir = root.join(member);
181 fs::create_dir_all(manifest_dir.join("src")).unwrap();
182 fs::write(
183 manifest_dir.join("Cargo.toml"),
184 format!("[package]\nname = \"{name}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n"),
185 )
186 .unwrap();
187 fs::write(manifest_dir.join("src/lib.rs"), "").unwrap();
188 }
189
190 let map = discover(&root, &RaysenseConfig::default());
191 let alpha = map
192 .members_by_crate
193 .get("alpha")
194 .expect("alpha member is discovered");
195 assert_eq!(alpha.crate_name, "alpha");
196 assert!(alpha.src_dir.ends_with("crates/alpha/src"));
197 assert!(map.members_by_crate.contains_key("beta"));
198
199 fs::remove_dir_all(&root).unwrap();
200 }
201
202 #[test]
203 fn discovers_single_crate_root_package() {
204 let root = temp_workspace_root("cargo_single");
205 fs::write(
206 root.join("Cargo.toml"),
207 "[package]\nname = \"solo\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
208 )
209 .unwrap();
210 fs::create_dir_all(root.join("src")).unwrap();
211 fs::write(root.join("src/lib.rs"), "").unwrap();
212
213 let map = discover(&root, &RaysenseConfig::default());
214 let solo = map
215 .members_by_crate
216 .get("solo")
217 .expect("root [package] is treated as a workspace member");
218 assert_eq!(solo.manifest_dir, PathBuf::from("."));
219
220 fs::remove_dir_all(&root).unwrap();
221 }
222
223 #[test]
224 fn skips_glob_workspace_members_for_now() {
225 let root = temp_workspace_root("cargo_glob");
226 fs::write(
227 root.join("Cargo.toml"),
228 "[workspace]\nmembers = [\"crates/*\"]\n",
229 )
230 .unwrap();
231 fs::create_dir_all(root.join("crates/alpha/src")).unwrap();
232 fs::write(
233 root.join("crates/alpha/Cargo.toml"),
234 "[package]\nname = \"alpha\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
235 )
236 .unwrap();
237
238 let map = discover(&root, &RaysenseConfig::default());
239 assert!(
240 map.members_by_crate.is_empty(),
241 "glob expansion is intentionally deferred to a follow-up slice"
242 );
243
244 fs::remove_dir_all(&root).unwrap();
245 }
246}