Skip to main content

codehelion_core/discovery/
cargo.rs

1//! Read-only recognition of Cargo package layout.
2//!
3//! Cargo manifests are parsed as plain TOML to attribute each source file to a
4//! package and target kind. Nothing here runs `cargo`, build scripts or
5//! procedural macros: a manifest is data, read and discarded. Package
6//! membership uses the nearest enclosing manifest with a `[package]` section, so
7//! workspaces are handled without resolving member globs.
8
9use 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/// One Cargo package, rooted at the directory holding its manifest.
35#[derive(Debug, Clone)]
36struct Package {
37    root: PathBuf,
38    name: String,
39    /// The `[lib] name`, when the manifest set one.
40    lib_name: Option<String>,
41    /// Absolute path of an explicit `[lib] path`, if the manifest set one.
42    lib_path: Option<PathBuf>,
43    /// Explicit `[[bin]]` entries that gave both a name and a path.
44    bins: Vec<Binary>,
45    /// Absolute paths of explicit `[[bin]] path` entries.
46    bin_paths: Vec<PathBuf>,
47}
48
49/// An explicitly declared binary target.
50#[derive(Debug, Clone)]
51struct Binary {
52    name: String,
53    path: PathBuf,
54}
55
56/// The recognised Cargo packages in a scanned tree.
57#[derive(Debug, Clone, Default)]
58pub struct CargoLayout {
59    /// Packages sorted by descending root-path depth, so the first ancestor
60    /// match is the nearest enclosing package.
61    packages: Vec<Package>,
62}
63
64/// A package discovered in the tree, for reporting.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct PackageInfo {
67    /// Package name from the manifest.
68    pub name: String,
69    /// Package root directory.
70    pub root: PathBuf,
71}
72
73impl CargoLayout {
74    /// Build a layout from a set of `Cargo.toml` paths.
75    ///
76    /// Manifests without a `[package]` section (pure `[workspace]` roots) or
77    /// that fail to parse are skipped; a malformed manifest never aborts
78    /// discovery.
79    #[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        // Deeper roots first: a nested package wins over an enclosing workspace.
126        packages.sort_by_key(|p| std::cmp::Reverse(p.root.components().count()));
127        Self { packages }
128    }
129
130    /// The recognised packages, ordered by name.
131    #[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    /// Attribute an absolute file path to its package name and target kind.
146    ///
147    /// Files outside every recognised package resolve to
148    /// `(None, TargetKind::Unknown)`.
149    #[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    /// The name a compiler knows `absolute_path`'s crate by, when the layout
163    /// says which crate that is.
164    ///
165    /// A compiler names a crate after the *target*, not the package: one
166    /// package is a library, some binaries and a test crate per test file, and
167    /// asking about a file under the wrong one gets an answer about somebody
168    /// else's code. Dashes become underscores because that mapping is the
169    /// compiler's — a crate name is an identifier and a package name need not
170    /// be — rather than a guess about spelling.
171    ///
172    /// `None` where the layout does not settle it: a file under `tests/` that
173    /// is not a target's own entry point is a module of one of them, and which
174    /// one is written in the code rather than in the manifest. Asking under a
175    /// guessed crate would produce an answer about a crate the file is not in,
176    /// which is worse than reporting that nobody asked.
177    #[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
189/// A crate name as the compiler spells it.
190fn 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    /// Which of this package's targets holds `absolute_path`, by target name.
213    fn crate_name(&self, absolute_path: &Path) -> Option<String> {
214        // A declared target settles it whatever the path looks like, which is
215        // the point of declaring one.
216        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            // The default binary is the one target named after the package
233            // rather than after a file.
234            ["src", "main.rs"] => Some(self.name.clone()),
235            // A binary, test, bench or example is its own crate, named after
236            // the file that is its entry point — and only that file is one.
237            ["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            // A module of one of the binaries, and which one is written in the
243            // code rather than in the manifest.
244            ["src", "bin", ..] => None,
245            // Everything else under `src` is a module of the library. A
246            // package whose only target is a binary under another name gets
247            // the wrong name here, and the answer to a name that names no
248            // crate is that there is no build information for it — which is
249            // the safe direction to be wrong in.
250            ["src", ..] => Some(self.lib_name.clone().unwrap_or_else(|| self.name.clone())),
251            _ => None,
252        }
253    }
254}
255
256/// The target name a single-file entry point carries, or `None` when the file
257/// is not a Rust source at all.
258fn stem(entry: &str) -> Option<&str> {
259    entry.strip_suffix(".rs")
260}
261
262/// Classify a package-relative path by Cargo's default layout conventions.
263fn 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    /// A compiler is asked about a crate, and a package is not one. Every
348    /// entry point below is a crate of its own, and a module file belongs to
349    /// the crate whose tree it sits in.
350    #[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            // A module of some test crate; which one is written in the code.
365            ("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    /// A declared target says what it is called, and a name a manifest gives
379    /// is not derivable from any path.
380    #[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        // The library's own name reaches its modules too.
401        assert_eq!(
402            layout
403                .crate_name(&dir.path().join("src/parse.rs"))
404                .as_deref(),
405            Some("engine")
406        );
407    }
408
409    /// Cargo lets a package be called what a compiler cannot: the crate is
410    /// known by the identifier, and that mapping belongs to the compiler
411    /// rather than to a guess about how names are spelled here.
412    #[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}