codehelion_core/discovery/
cargo.rs1use std::path::{Path, PathBuf};
10
11use serde::Deserialize;
12
13use super::source_unit::TargetKind;
14
15#[derive(Debug, Deserialize)]
16struct Manifest {
17 package: Option<PackageSection>,
18 lib: Option<TargetSection>,
19 #[serde(default)]
20 bin: Vec<TargetSection>,
21}
22
23#[derive(Debug, Deserialize)]
24struct PackageSection {
25 name: String,
26}
27
28#[derive(Debug, Deserialize)]
29struct TargetSection {
30 name: Option<String>,
31 path: Option<String>,
32}
33
34#[derive(Debug, Clone)]
36struct Package {
37 root: PathBuf,
38 name: String,
39 lib_name: Option<String>,
41 lib_path: Option<PathBuf>,
43 bins: Vec<Binary>,
45 bin_paths: Vec<PathBuf>,
47}
48
49#[derive(Debug, Clone)]
51struct Binary {
52 name: String,
53 path: PathBuf,
54}
55
56#[derive(Debug, Clone, Default)]
58pub struct CargoLayout {
59 packages: Vec<Package>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct PackageInfo {
67 pub name: String,
69 pub root: PathBuf,
71}
72
73impl CargoLayout {
74 #[must_use]
80 pub fn from_manifests(manifests: &[PathBuf]) -> Self {
81 let mut packages = Vec::new();
82 for manifest_path in manifests {
83 let Some(root) = manifest_path.parent() else {
84 continue;
85 };
86 let Ok(text) = std::fs::read_to_string(manifest_path) else {
87 continue;
88 };
89 let Ok(manifest) = toml::from_str::<Manifest>(&text) else {
90 continue;
91 };
92 let Some(package) = manifest.package else {
93 continue;
94 };
95 let lib = manifest.lib.unwrap_or(TargetSection {
96 name: None,
97 path: None,
98 });
99 let lib_path = lib.path.map(|path| root.join(path));
100 let bins: Vec<Binary> = manifest
101 .bin
102 .iter()
103 .filter_map(|bin| {
104 Some(Binary {
105 name: bin.name.clone()?,
106 path: root.join(bin.path.clone()?),
107 })
108 })
109 .collect();
110 let bin_paths = manifest
111 .bin
112 .into_iter()
113 .filter_map(|bin| bin.path)
114 .map(|path| root.join(path))
115 .collect();
116 packages.push(Package {
117 root: root.to_path_buf(),
118 name: package.name,
119 lib_name: lib.name,
120 lib_path,
121 bins,
122 bin_paths,
123 });
124 }
125 packages.sort_by_key(|p| std::cmp::Reverse(p.root.components().count()));
127 Self { packages }
128 }
129
130 #[must_use]
132 pub fn packages(&self) -> Vec<PackageInfo> {
133 let mut infos: Vec<PackageInfo> = self
134 .packages
135 .iter()
136 .map(|p| PackageInfo {
137 name: p.name.clone(),
138 root: p.root.clone(),
139 })
140 .collect();
141 infos.sort_by(|a, b| a.name.cmp(&b.name));
142 infos
143 }
144
145 #[must_use]
150 pub fn classify(&self, absolute_path: &Path) -> (Option<String>, TargetKind) {
151 let Some(package) = self
152 .packages
153 .iter()
154 .find(|p| absolute_path.starts_with(&p.root))
155 else {
156 return (None, TargetKind::Unknown);
157 };
158 let kind = package.target_kind(absolute_path);
159 (Some(package.name.clone()), kind)
160 }
161
162 #[must_use]
178 pub fn crate_name(&self, absolute_path: &Path) -> Option<String> {
179 let package = self
180 .packages
181 .iter()
182 .find(|p| absolute_path.starts_with(&p.root))?;
183 package
184 .crate_name(absolute_path)
185 .map(|name| identifier(&name))
186 }
187}
188
189fn identifier(name: &str) -> String {
191 name.replace('-', "_")
192}
193
194impl Package {
195 fn target_kind(&self, absolute_path: &Path) -> TargetKind {
196 if self
197 .lib_path
198 .as_ref()
199 .is_some_and(|lib| lib == absolute_path)
200 {
201 return TargetKind::Library;
202 }
203 if self.bin_paths.iter().any(|bin| bin == absolute_path) {
204 return TargetKind::Binary;
205 }
206 let Ok(rel) = absolute_path.strip_prefix(&self.root) else {
207 return TargetKind::Unknown;
208 };
209 classify_by_convention(rel)
210 }
211
212 fn crate_name(&self, absolute_path: &Path) -> Option<String> {
214 if let Some(bin) = self.bins.iter().find(|bin| bin.path == absolute_path) {
217 return Some(bin.name.clone());
218 }
219 if self
220 .lib_path
221 .as_ref()
222 .is_some_and(|lib| lib == absolute_path)
223 {
224 return Some(self.lib_name.clone().unwrap_or_else(|| self.name.clone()));
225 }
226 let rel = absolute_path.strip_prefix(&self.root).ok()?;
227 let components: Vec<&str> = rel
228 .components()
229 .filter_map(|c| c.as_os_str().to_str())
230 .collect();
231 match components.as_slice() {
232 ["src", "main.rs"] => Some(self.name.clone()),
235 ["src", "bin", entry] | ["tests" | "benches" | "examples", entry] => {
238 Some(stem(entry)?.to_string())
239 }
240 ["src", "bin", target, "main.rs"]
241 | ["tests" | "benches" | "examples", target, "main.rs"] => Some((*target).to_string()),
242 ["src", "bin", ..] => None,
245 ["src", ..] => Some(self.lib_name.clone().unwrap_or_else(|| self.name.clone())),
251 _ => None,
252 }
253 }
254}
255
256fn stem(entry: &str) -> Option<&str> {
259 entry.strip_suffix(".rs")
260}
261
262fn classify_by_convention(rel: &Path) -> TargetKind {
264 let components: Vec<&str> = rel
265 .components()
266 .filter_map(|c| c.as_os_str().to_str())
267 .collect();
268 match components.as_slice() {
269 ["build.rs"] => TargetKind::BuildScript,
270 ["src", "main.rs"] | ["src", "bin", ..] => TargetKind::Binary,
271 ["src", ..] => TargetKind::Library,
272 ["tests", ..] => TargetKind::Test,
273 ["benches", ..] => TargetKind::Bench,
274 ["examples", ..] => TargetKind::Example,
275 _ => TargetKind::Unknown,
276 }
277}
278
279#[cfg(test)]
280#[allow(clippy::unwrap_used, clippy::expect_used)]
281mod tests {
282 use super::*;
283
284 fn write_manifest(dir: &Path, body: &str) -> PathBuf {
285 let path = dir.join("Cargo.toml");
286 std::fs::write(&path, body).unwrap();
287 path
288 }
289
290 #[test]
291 fn conventional_paths_map_to_target_kinds() {
292 let dir = tempfile::tempdir().unwrap();
293 let manifest = write_manifest(dir.path(), "[package]\nname = \"demo\"\n");
294 let layout = CargoLayout::from_manifests(&[manifest]);
295
296 let cases = [
297 ("src/lib.rs", TargetKind::Library),
298 ("src/engine/mod.rs", TargetKind::Library),
299 ("src/main.rs", TargetKind::Binary),
300 ("src/bin/tool.rs", TargetKind::Binary),
301 ("tests/it.rs", TargetKind::Test),
302 ("benches/bench.rs", TargetKind::Bench),
303 ("examples/demo.rs", TargetKind::Example),
304 ("build.rs", TargetKind::BuildScript),
305 ];
306 for (rel, expected) in cases {
307 let (pkg, kind) = layout.classify(&dir.path().join(rel));
308 assert_eq!(pkg.as_deref(), Some("demo"), "{rel}");
309 assert_eq!(kind, expected, "{rel}");
310 }
311 }
312
313 #[test]
314 fn nested_package_wins_over_enclosing_workspace() {
315 let root = tempfile::tempdir().unwrap();
316 let workspace = write_manifest(root.path(), "[workspace]\nmembers = [\"inner\"]\n");
317 let inner_dir = root.path().join("inner");
318 std::fs::create_dir_all(&inner_dir).unwrap();
319 let inner = write_manifest(&inner_dir, "[package]\nname = \"inner\"\n");
320 let layout = CargoLayout::from_manifests(&[workspace, inner]);
321
322 let (pkg, kind) = layout.classify(&inner_dir.join("src/lib.rs"));
323 assert_eq!(pkg.as_deref(), Some("inner"));
324 assert_eq!(kind, TargetKind::Library);
325 }
326
327 #[test]
328 fn explicit_lib_path_is_honoured() {
329 let dir = tempfile::tempdir().unwrap();
330 let manifest = write_manifest(
331 dir.path(),
332 "[package]\nname = \"demo\"\n[lib]\npath = \"lib/entry.rs\"\n",
333 );
334 let layout = CargoLayout::from_manifests(&[manifest]);
335 let (_, kind) = layout.classify(&dir.path().join("lib/entry.rs"));
336 assert_eq!(kind, TargetKind::Library);
337 }
338
339 #[test]
340 fn files_outside_any_package_are_unknown() {
341 let layout = CargoLayout::default();
342 let (pkg, kind) = layout.classify(Path::new("/tmp/loose/file.rs"));
343 assert_eq!(pkg, None);
344 assert_eq!(kind, TargetKind::Unknown);
345 }
346
347 #[test]
351 fn each_target_is_the_crate_its_files_belong_to() {
352 let dir = tempfile::tempdir().unwrap();
353 let manifest = write_manifest(dir.path(), "[package]\nname = \"demo\"\n");
354 let layout = CargoLayout::from_manifests(&[manifest]);
355 let cases = [
356 ("src/lib.rs", Some("demo")),
357 ("src/engine/mod.rs", Some("demo")),
358 ("src/main.rs", Some("demo")),
359 ("src/bin/tool.rs", Some("tool")),
360 ("src/bin/tool/main.rs", Some("tool")),
361 ("tests/it.rs", Some("it")),
362 ("benches/speed.rs", Some("speed")),
363 ("examples/demo.rs", Some("demo")),
364 ("tests/common/helper.rs", None),
366 ("src/bin/tool/helper.rs", None),
367 ("build.rs", None),
368 ];
369 for (rel, expected) in cases {
370 assert_eq!(
371 layout.crate_name(&dir.path().join(rel)).as_deref(),
372 expected,
373 "{rel}"
374 );
375 }
376 }
377
378 #[test]
381 fn a_declared_target_is_known_by_the_name_it_declared() {
382 let dir = tempfile::tempdir().unwrap();
383 let manifest = write_manifest(
384 dir.path(),
385 "[package]\nname = \"demo\"\n\
386 [lib]\nname = \"engine\"\npath = \"lib/entry.rs\"\n\
387 [[bin]]\nname = \"tool\"\npath = \"cmd/run.rs\"\n",
388 );
389 let layout = CargoLayout::from_manifests(&[manifest]);
390 assert_eq!(
391 layout
392 .crate_name(&dir.path().join("lib/entry.rs"))
393 .as_deref(),
394 Some("engine")
395 );
396 assert_eq!(
397 layout.crate_name(&dir.path().join("cmd/run.rs")).as_deref(),
398 Some("tool")
399 );
400 assert_eq!(
402 layout
403 .crate_name(&dir.path().join("src/parse.rs"))
404 .as_deref(),
405 Some("engine")
406 );
407 }
408
409 #[test]
413 fn a_dash_in_a_package_name_is_an_underscore_in_the_crate_name() {
414 let dir = tempfile::tempdir().unwrap();
415 let manifest = write_manifest(dir.path(), "[package]\nname = \"my-crate\"\n");
416 let layout = CargoLayout::from_manifests(&[manifest]);
417 assert_eq!(
418 layout.crate_name(&dir.path().join("src/lib.rs")).as_deref(),
419 Some("my_crate")
420 );
421 }
422
423 #[test]
424 fn a_file_outside_every_package_belongs_to_no_crate() {
425 let layout = CargoLayout::default();
426 assert_eq!(layout.crate_name(Path::new("/tmp/loose/file.rs")), None);
427 }
428
429 #[test]
430 fn workspace_only_manifest_yields_no_packages() {
431 let dir = tempfile::tempdir().unwrap();
432 let manifest = write_manifest(dir.path(), "[workspace]\nmembers = []\n");
433 let layout = CargoLayout::from_manifests(&[manifest]);
434 assert!(layout.packages().is_empty());
435 }
436}