|
| 1 | +// SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | +// Copyright (C) 2026 Mohamed Hammad |
| 3 | + |
| 4 | +//! [`Cargo`] trait + production [`LiveCargo`] implementation. |
| 5 | +
|
| 6 | +use crate::errors::CargoError; |
| 7 | +use crate::inventory::parse_install_list; |
| 8 | +use pearlite_schema::CargoInventory; |
| 9 | +use std::path::{Path, PathBuf}; |
| 10 | +use std::process::Command; |
| 11 | + |
| 12 | +/// Trait the rest of the workspace consumes to talk to `cargo`. |
| 13 | +/// |
| 14 | +/// At M1 only [`inventory`](Self::inventory) is implemented. Install and |
| 15 | +/// uninstall arrive with M2's apply-engine wiring per Plan §7.3. |
| 16 | +pub trait Cargo: Send + Sync { |
| 17 | + /// Snapshot the `cargo install --list` output as a |
| 18 | + /// [`CargoInventory`]. |
| 19 | + /// |
| 20 | + /// # Errors |
| 21 | + /// Implementations propagate adapter-specific failures via |
| 22 | + /// [`CargoError`]. |
| 23 | + fn inventory(&self) -> Result<CargoInventory, CargoError>; |
| 24 | +} |
| 25 | + |
| 26 | +/// Production [`Cargo`] backed by the `cargo` binary. |
| 27 | +/// |
| 28 | +/// Uses argv-array subprocess invocation per CLAUDE.md hard invariant |
| 29 | +/// #5: never `sh -c`, never string interpolation into a shell string. |
| 30 | +#[derive(Clone, Debug)] |
| 31 | +pub struct LiveCargo { |
| 32 | + binary: PathBuf, |
| 33 | +} |
| 34 | + |
| 35 | +impl LiveCargo { |
| 36 | + /// Construct a [`LiveCargo`] that resolves `cargo` from `PATH`. |
| 37 | + #[must_use] |
| 38 | + pub fn new() -> Self { |
| 39 | + Self { |
| 40 | + binary: PathBuf::from("cargo"), |
| 41 | + } |
| 42 | + } |
| 43 | + |
| 44 | + /// Construct a [`LiveCargo`] with a caller-supplied binary path. |
| 45 | + pub fn with_binary(binary: impl Into<PathBuf>) -> Self { |
| 46 | + Self { |
| 47 | + binary: binary.into(), |
| 48 | + } |
| 49 | + } |
| 50 | + |
| 51 | + /// Path of the `cargo` binary this adapter invokes. |
| 52 | + #[must_use] |
| 53 | + pub fn binary(&self) -> &Path { |
| 54 | + &self.binary |
| 55 | + } |
| 56 | +} |
| 57 | + |
| 58 | +impl Default for LiveCargo { |
| 59 | + fn default() -> Self { |
| 60 | + Self::new() |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +impl Cargo for LiveCargo { |
| 65 | + fn inventory(&self) -> Result<CargoInventory, CargoError> { |
| 66 | + let output = match Command::new(&self.binary) |
| 67 | + .args(["install", "--list"]) |
| 68 | + .output() |
| 69 | + { |
| 70 | + Ok(o) => o, |
| 71 | + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { |
| 72 | + return Err(CargoError::NotInPath { |
| 73 | + hint: "paru -S rustup", |
| 74 | + }); |
| 75 | + } |
| 76 | + Err(e) => return Err(CargoError::Io(e)), |
| 77 | + }; |
| 78 | + |
| 79 | + if !output.status.success() { |
| 80 | + let code = output.status.code().unwrap_or(-1); |
| 81 | + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); |
| 82 | + return Err(CargoError::InvocationFailed { code, stderr }); |
| 83 | + } |
| 84 | + |
| 85 | + let stdout = String::from_utf8(output.stdout)?; |
| 86 | + Ok(parse_install_list(&stdout)) |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +#[cfg(test)] |
| 91 | +#[allow( |
| 92 | + clippy::expect_used, |
| 93 | + clippy::unwrap_used, |
| 94 | + clippy::panic, |
| 95 | + reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md" |
| 96 | +)] |
| 97 | +mod tests { |
| 98 | + use super::*; |
| 99 | + |
| 100 | + #[test] |
| 101 | + fn cargo_not_in_path_error_class() { |
| 102 | + let live = LiveCargo::with_binary("/nonexistent/path/cargo-binary-12345"); |
| 103 | + let err = live.inventory().expect_err("must fail"); |
| 104 | + assert!(matches!(err, CargoError::NotInPath { .. }), "got {err:?}"); |
| 105 | + } |
| 106 | + |
| 107 | + /// Plan §6.7 acceptance: `cargo install --list` parses correctly. CI |
| 108 | + /// has cargo installed via dtolnay/rust-toolchain; locally cargo is |
| 109 | + /// always present (we're inside a Rust workspace). The test asserts |
| 110 | + /// the call succeeds; whatever crates happen to be installed don't |
| 111 | + /// matter for parser coverage. |
| 112 | + #[test] |
| 113 | + fn live_cargo_inventory_succeeds_in_a_rust_environment() { |
| 114 | + let live = LiveCargo::new(); |
| 115 | + let probe = Command::new(live.binary()).arg("--version").output(); |
| 116 | + if !matches!(&probe, Ok(o) if o.status.success()) { |
| 117 | + return; |
| 118 | + } |
| 119 | + let inv = live.inventory().expect("inventory"); |
| 120 | + // Every crate name is a valid cargo package name (kebab-case |
| 121 | + // alphanumerics + a few extras). Defensive sanity check. |
| 122 | + for name in inv.crates.keys() { |
| 123 | + assert!(!name.is_empty(), "empty crate name in inventory"); |
| 124 | + assert!(!name.contains(' '), "crate name has whitespace: {name}"); |
| 125 | + } |
| 126 | + } |
| 127 | +} |
0 commit comments