Skip to main content

propel_core/
cargo.rs

1//! Cargo project discovery via `cargo metadata`.
2//!
3//! Replaces manual `Cargo.toml` TOML parsing with the official metadata
4//! protocol, correctly handling:
5//!
6//! - Workspace `version.workspace = true` inheritance
7//! - Multiple binary targets with `default-run` selection
8//! - Workspace member identification
9//! - Accurate manifest and directory paths
10
11use cargo_metadata::{MetadataCommand, TargetKind};
12use std::path::{Path, PathBuf};
13
14/// A binary target in a Cargo package.
15///
16/// # Examples
17///
18/// ```
19/// use propel_core::CargoBinary;
20/// use std::path::PathBuf;
21///
22/// let bin = CargoBinary {
23///     name: "my-server".to_owned(),
24///     src_path: PathBuf::from("src/main.rs"),
25/// };
26/// assert_eq!(bin.name, "my-server");
27/// ```
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct CargoBinary {
30    /// Binary name (used with `cargo build --bin <name>`)
31    pub name: String,
32    /// Absolute path to the source file
33    pub src_path: PathBuf,
34}
35
36/// Cargo project metadata, discovered via `cargo metadata --no-deps`.
37///
38/// This is the primary domain entity for Rust project management.
39/// All fields are resolved by Cargo itself, ensuring accuracy even
40/// for workspace members with inherited fields.
41///
42/// # Construction
43///
44/// Use [`CargoProject::discover()`] to create instances from real Cargo
45/// projects. Direct struct construction is available for testing but
46/// callers must ensure `default_binary` exists in `binaries`.
47///
48/// # Examples
49///
50/// ```no_run
51/// use propel_core::CargoProject;
52/// use std::path::Path;
53///
54/// let project = CargoProject::discover(Path::new(".")).unwrap();
55/// println!("Deploying {} v{}", project.name, project.version);
56/// println!("Binary: {}", project.default_binary);
57/// ```
58#[derive(Debug, Clone)]
59pub struct CargoProject {
60    /// Package name from `[package].name`
61    pub name: String,
62    /// Resolved version (handles `version.workspace = true`)
63    pub version: String,
64    /// Absolute path to the package's `Cargo.toml`
65    pub manifest_path: PathBuf,
66    /// Absolute path to the package directory (parent of `manifest_path`)
67    pub package_dir: PathBuf,
68    /// Absolute path to the workspace root directory
69    pub workspace_root: PathBuf,
70    /// All binary targets in this package
71    pub binaries: Vec<CargoBinary>,
72    /// The binary selected for deployment.
73    ///
74    /// **Invariant:** must match a name in [`binaries`](Self::binaries).
75    pub default_binary: String,
76}
77
78impl CargoProject {
79    /// Discover the Cargo project at the given directory.
80    ///
81    /// Runs `cargo metadata --no-deps` and locates the package whose
82    /// manifest lives in `project_dir`. For single-package projects this
83    /// is the only package; for workspaces the matching member is selected.
84    ///
85    /// # Errors
86    ///
87    /// - [`crate::Error::CargoMetadata`] if `cargo metadata` fails (e.g. cargo not installed)
88    /// - [`crate::Error::NoPackageInDir`] if `project_dir` is a workspace root without `[package]`
89    /// - [`crate::Error::NoBinaryTarget`] if the package has no binary targets
90    /// - [`crate::Error::MultipleBinaries`] if multiple binaries exist and none is selected
91    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        // Canonicalize project_dir for reliable path comparison
107        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        // Find the package whose Cargo.toml is in project_dir
116        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        // Extract binary targets
147        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        // Determine default binary
158        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    /// Select the binary to use for deployment.
188    ///
189    /// Priority:
190    /// 1. `default-run` from Cargo.toml (explicit user choice)
191    /// 2. Single binary (unambiguous)
192    /// 3. Binary matching the package name (Cargo convention)
193    /// 4. Error with guidance
194    fn resolve_default_binary(
195        binaries: &[CargoBinary],
196        default_run: Option<&str>,
197        package_name: &str,
198    ) -> crate::Result<String> {
199        // 1. Explicit default-run
200        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                // Multiple binaries: prefer the one matching the package name
213                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    // ── resolve_default_binary unit tests ──
229
230    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        // default_run points to a non-existent binary: fall back to single-binary rule
280        let result = CargoProject::resolve_default_binary(&bins, Some("ghost"), "my-pkg");
281        assert_eq!(result.unwrap(), "server");
282    }
283
284    // ── Property-based tests ──
285
286    mod proptests {
287        use super::*;
288        use proptest::prelude::*;
289
290        /// Strategy: valid crate name (lowercase ascii + hyphens, 1-20 chars)
291        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        /// Strategy: vec of 0-5 unique binary names
296        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                // Single binary: either default_run matches it, or fallback picks it
366                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}