1use cargo_metadata::{MetadataCommand, TargetKind};
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct CargoBinary {
30 pub name: String,
32 pub src_path: PathBuf,
34}
35
36#[derive(Debug, Clone)]
59pub struct CargoProject {
60 pub name: String,
62 pub version: String,
64 pub manifest_path: PathBuf,
66 pub package_dir: PathBuf,
68 pub workspace_root: PathBuf,
70 pub binaries: Vec<CargoBinary>,
72 pub default_binary: String,
76}
77
78impl CargoProject {
79 pub fn discover(project_dir: &Path) -> crate::Result<Self> {
92 let manifest_path = project_dir.join("Cargo.toml");
93 tracing::debug!(path = %manifest_path.display(), "running cargo metadata");
94
95 let metadata = MetadataCommand::new()
96 .manifest_path(&manifest_path)
97 .no_deps()
98 .exec()
99 .map_err(|e| crate::Error::CargoMetadata {
100 manifest_path: manifest_path.clone(),
101 detail: e.to_string(),
102 })?;
103
104 let workspace_root = PathBuf::from(metadata.workspace_root.as_std_path());
105
106 let canonical_dir =
108 project_dir
109 .canonicalize()
110 .map_err(|e| crate::Error::ProjectDirResolve {
111 path: project_dir.to_path_buf(),
112 source: e,
113 })?;
114
115 let package = metadata
117 .packages
118 .iter()
119 .find(|p| {
120 p.manifest_path
121 .as_std_path()
122 .parent()
123 .and_then(|d| match d.canonicalize() {
124 Ok(c) => Some(c),
125 Err(e) => {
126 tracing::warn!(
127 path = %d.display(),
128 error = %e,
129 "failed to canonicalize manifest parent; skipping package"
130 );
131 None
132 }
133 })
134 .is_some_and(|d| d == canonical_dir)
135 })
136 .ok_or_else(|| crate::Error::NoPackageInDir {
137 dir: canonical_dir.clone(),
138 workspace_members: metadata
139 .packages
140 .iter()
141 .filter(|p| metadata.workspace_members.contains(&p.id))
142 .map(|p| p.name.clone())
143 .collect(),
144 })?;
145
146 let binaries: Vec<CargoBinary> = package
148 .targets
149 .iter()
150 .filter(|t| t.kind.contains(&TargetKind::Bin))
151 .map(|t| CargoBinary {
152 name: t.name.clone(),
153 src_path: PathBuf::from(t.src_path.as_std_path()),
154 })
155 .collect();
156
157 let default_binary =
159 Self::resolve_default_binary(&binaries, package.default_run.as_deref(), &package.name)?;
160
161 let pkg_manifest = PathBuf::from(package.manifest_path.as_std_path());
162 let pkg_dir = pkg_manifest
163 .parent()
164 .expect("manifest_path from cargo metadata is always absolute")
165 .to_path_buf();
166
167 tracing::debug!(
168 name = %package.name,
169 version = %package.version,
170 binary = %default_binary,
171 binaries = binaries.len(),
172 workspace_root = %workspace_root.display(),
173 "cargo project discovered"
174 );
175
176 Ok(Self {
177 name: package.name.clone(),
178 version: package.version.to_string(),
179 manifest_path: pkg_manifest,
180 package_dir: pkg_dir,
181 workspace_root,
182 binaries,
183 default_binary,
184 })
185 }
186
187 fn resolve_default_binary(
195 binaries: &[CargoBinary],
196 default_run: Option<&str>,
197 package_name: &str,
198 ) -> crate::Result<String> {
199 if let Some(name) = default_run
201 && binaries.iter().any(|b| b.name == name)
202 {
203 return Ok(name.to_owned());
204 }
205
206 match binaries.len() {
207 0 => Err(crate::Error::NoBinaryTarget {
208 package: package_name.to_owned(),
209 }),
210 1 => Ok(binaries[0].name.clone()),
211 _ => {
212 if binaries.iter().any(|b| b.name == package_name) {
214 return Ok(package_name.to_owned());
215 }
216 Err(crate::Error::MultipleBinaries {
217 names: binaries.iter().map(|b| b.name.clone()).collect(),
218 })
219 }
220 }
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 fn bin(name: &str) -> CargoBinary {
231 CargoBinary {
232 name: name.to_owned(),
233 src_path: PathBuf::from(format!("src/bin/{name}.rs")),
234 }
235 }
236
237 #[test]
238 fn resolve_single_binary() {
239 let bins = vec![bin("my-server")];
240 let result = CargoProject::resolve_default_binary(&bins, None, "my-pkg");
241 assert_eq!(result.unwrap(), "my-server");
242 }
243
244 #[test]
245 fn resolve_default_run_takes_priority() {
246 let bins = vec![bin("server"), bin("worker")];
247 let result = CargoProject::resolve_default_binary(&bins, Some("worker"), "my-pkg");
248 assert_eq!(result.unwrap(), "worker");
249 }
250
251 #[test]
252 fn resolve_multiple_prefers_package_name() {
253 let bins = vec![bin("my-pkg"), bin("worker")];
254 let result = CargoProject::resolve_default_binary(&bins, None, "my-pkg");
255 assert_eq!(result.unwrap(), "my-pkg");
256 }
257
258 #[test]
259 fn resolve_no_binaries_errors() {
260 let result = CargoProject::resolve_default_binary(&[], None, "lib-only");
261 assert!(result.is_err());
262 let err = result.unwrap_err().to_string();
263 assert!(err.contains("no binary target"), "got: {err}");
264 }
265
266 #[test]
267 fn resolve_ambiguous_multiple_errors() {
268 let bins = vec![bin("server"), bin("worker")];
269 let result = CargoProject::resolve_default_binary(&bins, None, "my-pkg");
270 assert!(result.is_err());
271 let err = result.unwrap_err().to_string();
272 assert!(err.contains("server"), "got: {err}");
273 assert!(err.contains("worker"), "got: {err}");
274 }
275
276 #[test]
277 fn resolve_default_run_ignored_if_not_in_binaries() {
278 let bins = vec![bin("server")];
279 let result = CargoProject::resolve_default_binary(&bins, Some("ghost"), "my-pkg");
281 assert_eq!(result.unwrap(), "server");
282 }
283
284 mod proptests {
287 use super::*;
288 use proptest::prelude::*;
289
290 fn crate_name() -> impl Strategy<Value = String> {
292 "[a-z][a-z0-9-]{0,19}".prop_filter("no trailing hyphen", |s| !s.ends_with('-'))
293 }
294
295 fn bin_names(max: usize) -> impl Strategy<Value = Vec<String>> {
297 proptest::collection::hash_set(crate_name(), 0..=max)
298 .prop_map(|s| s.into_iter().collect::<Vec<_>>())
299 }
300
301 fn bins_from_names(names: &[String]) -> Vec<CargoBinary> {
302 names.iter().map(|n| bin(n)).collect()
303 }
304
305 proptest! {
306 #[test]
307 fn never_panics(
308 names in bin_names(5),
309 default_run in proptest::option::of(crate_name()),
310 pkg_name in crate_name(),
311 ) {
312 let bins = bins_from_names(&names);
313 let _ = CargoProject::resolve_default_binary(
314 &bins,
315 default_run.as_deref(),
316 &pkg_name,
317 );
318 }
319
320 #[test]
321 fn default_run_in_binaries_always_selected(
322 extra_names in bin_names(4),
323 chosen in crate_name(),
324 ) {
325 let mut names: Vec<String> = extra_names
326 .into_iter()
327 .filter(|n| *n != chosen)
328 .collect();
329 names.push(chosen.clone());
330 let bins = bins_from_names(&names);
331
332 let result = CargoProject::resolve_default_binary(
333 &bins,
334 Some(&chosen),
335 "unrelated-pkg",
336 );
337 prop_assert_eq!(result.unwrap(), chosen);
338 }
339
340 #[test]
341 fn empty_binaries_always_errors(
342 default_run in proptest::option::of(crate_name()),
343 pkg_name in crate_name(),
344 ) {
345 let result = CargoProject::resolve_default_binary(
346 &[],
347 default_run.as_deref(),
348 &pkg_name,
349 );
350 prop_assert!(result.is_err());
351 }
352
353 #[test]
354 fn single_binary_always_succeeds(
355 name in crate_name(),
356 default_run in proptest::option::of(crate_name()),
357 pkg_name in crate_name(),
358 ) {
359 let bins = vec![bin(&name)];
360 let result = CargoProject::resolve_default_binary(
361 &bins,
362 default_run.as_deref(),
363 &pkg_name,
364 );
365 prop_assert!(result.is_ok());
367 }
368
369 #[test]
370 fn result_is_always_from_binaries(
371 names in bin_names(5).prop_filter("non-empty", |v| !v.is_empty()),
372 default_run in proptest::option::of(crate_name()),
373 pkg_name in crate_name(),
374 ) {
375 let bins = bins_from_names(&names);
376 let result = CargoProject::resolve_default_binary(
377 &bins,
378 default_run.as_deref(),
379 &pkg_name,
380 );
381 if let Ok(selected) = result {
382 prop_assert!(
383 names.contains(&selected),
384 "selected '{}' not in binaries {:?}",
385 selected,
386 names,
387 );
388 }
389 }
390 }
391 }
392}