Skip to content

Commit e1333fa

Browse files
feat(cargo): land Cargo trait, LiveCargo, MockCargo, install-list parser (#11)
Plan §6.7 — second M1 Week 2 adapter probe path. Inventory only at M1; install/uninstall arrive in M2 with the rest of the apply-engine adapters per Plan §7.3. Public surface: - pub trait Cargo { fn inventory(&self) -> Result<CargoInventory, CargoError>; } - pub struct LiveCargo with new() / with_binary() / binary(). - pub struct MockCargo (test-mocks feature) with new() / with_inventory(). - pub fn parse_install_list(&str) -> CargoInventory — exposed because parsing is purely textual and useful from tests / probes. - pub enum CargoError — NotInPath (hint: paru -S rustup), Io, InvocationFailed { code, stderr }, NotUtf8. Parser handles: - standard `crate-name vX.Y.Z:` header - registry source spec `(registry+...)` after the version - git source spec `(https://...#sha)` after the version - version without `v` prefix (defensive against future cargo releases) - indented binary listings (skipped — Pearlite tracks crates, not bins) Tests (10 passing): - inventory: parse_known_output, empty_output_yields_empty_inventory, parses_registry_source_spec, parses_git_source_spec, ignores_binary_lines, version_without_v_prefix_still_parses. - live: cargo_not_in_path_error_class, live_cargo_inventory_succeeds_in_a_rust_environment. - mock: empty_mock_yields_empty_inventory, with_inventory_round_trips. Cargo dep on pearlite-schema pinned to "0.1" per the cargo-deny wildcard rule. Verification: - cargo test -p pearlite-cargo (10 passed; 0 failed) - cargo clippy --workspace --all-targets -- -D warnings (zero warnings) - cargo fmt --all --check - scripts/ci/check-spdx.sh - pearlite-audit check . (1 check, 0 violations) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d067e5b commit e1333fa

6 files changed

Lines changed: 399 additions & 0 deletions

File tree

crates/pearlite-cargo/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,9 @@ repository.workspace = true
1111
[lints]
1212
workspace = true
1313

14+
[features]
15+
test-mocks = []
16+
1417
[dependencies]
18+
pearlite-schema = { path = "../pearlite-schema", version = "0.1" }
19+
thiserror = { workspace = true }
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! Errors emitted by `pearlite-cargo`.
5+
6+
use thiserror::Error;
7+
8+
/// Errors emitted while invoking the `cargo` binary or parsing its
9+
/// output.
10+
#[derive(Debug, Error)]
11+
pub enum CargoError {
12+
/// The configured `cargo` binary could not be spawned. Usually a
13+
/// Class 1 preflight failure at the engine level — the user does
14+
/// not have `rustup` installed.
15+
#[error("cargo binary not found: {hint}")]
16+
NotInPath {
17+
/// Hint string with a runnable command (`paru -S rustup`).
18+
hint: &'static str,
19+
},
20+
/// `std::io` error during spawn or stdout capture.
21+
#[error("I/O error invoking cargo: {0}")]
22+
Io(#[from] std::io::Error),
23+
/// `cargo install --list` exited non-zero.
24+
#[error("cargo exited with code {code}:\n{stderr}")]
25+
InvocationFailed {
26+
/// Process exit code.
27+
code: i32,
28+
/// Captured stderr verbatim.
29+
stderr: String,
30+
},
31+
/// `cargo` stdout was not valid UTF-8.
32+
#[error("cargo stdout was not valid UTF-8: {0}")]
33+
NotUtf8(#[from] std::string::FromUtf8Error),
34+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! `cargo install --list` output parsing.
5+
6+
use pearlite_schema::CargoInventory;
7+
use std::collections::BTreeMap;
8+
9+
/// Parse the stdout of `cargo install --list` into a [`CargoInventory`].
10+
///
11+
/// The format is:
12+
///
13+
/// ```text
14+
/// crate-name vX.Y.Z:
15+
/// binary-name
16+
/// other-binary
17+
/// crate-name2 vA.B.C (registry+https://...):
18+
/// binary
19+
/// ```
20+
///
21+
/// Crate header lines start in column 0 and end with a colon. Binary
22+
/// names are indented and ignored — Pearlite tracks installed crates,
23+
/// not the binaries they ship.
24+
///
25+
/// Unknown lines are skipped silently; the parser is forgiving so a
26+
/// future `cargo` release that adds a banner or a footer doesn't break
27+
/// the probe.
28+
#[must_use]
29+
pub fn parse_install_list(stdout: &str) -> CargoInventory {
30+
let mut crates = BTreeMap::new();
31+
for line in stdout.lines() {
32+
// Indented lines list binary names.
33+
if line.starts_with(' ') || line.starts_with('\t') {
34+
continue;
35+
}
36+
let trimmed = line.trim_end().trim_end_matches(':');
37+
if trimmed.is_empty() {
38+
continue;
39+
}
40+
let Some((name, rest)) = trimmed.split_once(' ') else {
41+
continue;
42+
};
43+
// `rest` is `vX.Y.Z` optionally followed by ` (source...)`.
44+
let Some(version_field) = rest.split_whitespace().next() else {
45+
continue;
46+
};
47+
let version = version_field.strip_prefix('v').unwrap_or(version_field);
48+
if name.is_empty() || version.is_empty() {
49+
continue;
50+
}
51+
crates.insert(name.to_owned(), version.to_owned());
52+
}
53+
CargoInventory { crates }
54+
}
55+
56+
#[cfg(test)]
57+
#[allow(
58+
clippy::expect_used,
59+
clippy::unwrap_used,
60+
clippy::panic,
61+
reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md"
62+
)]
63+
mod tests {
64+
use super::*;
65+
66+
#[test]
67+
fn parse_known_output() {
68+
let stdout = "\
69+
cargo-machete v0.7.0:
70+
cargo-machete
71+
nickel-lang-cli v1.10.0 (registry+https://github.com/rust-lang/crates.io-index):
72+
nickel
73+
ripgrep-all v0.10.6:
74+
rga
75+
rga-fzf
76+
rga-preproc
77+
zellij v0.41.2:
78+
zellij
79+
";
80+
let inv = parse_install_list(stdout);
81+
assert_eq!(inv.crates.len(), 4);
82+
assert_eq!(inv.crates.get("cargo-machete"), Some(&"0.7.0".to_owned()));
83+
assert_eq!(
84+
inv.crates.get("nickel-lang-cli"),
85+
Some(&"1.10.0".to_owned())
86+
);
87+
assert_eq!(inv.crates.get("ripgrep-all"), Some(&"0.10.6".to_owned()));
88+
assert_eq!(inv.crates.get("zellij"), Some(&"0.41.2".to_owned()));
89+
}
90+
91+
#[test]
92+
fn empty_output_yields_empty_inventory() {
93+
let inv = parse_install_list("");
94+
assert!(inv.crates.is_empty());
95+
}
96+
97+
#[test]
98+
fn parses_registry_source_spec() {
99+
let stdout = "\
100+
foo v1.2.3 (registry+https://github.com/rust-lang/crates.io-index):
101+
foo
102+
";
103+
let inv = parse_install_list(stdout);
104+
assert_eq!(inv.crates.get("foo"), Some(&"1.2.3".to_owned()));
105+
}
106+
107+
#[test]
108+
fn parses_git_source_spec() {
109+
let stdout = "\
110+
bar v0.1.0 (https://github.com/example/bar#abc123):
111+
bar
112+
";
113+
let inv = parse_install_list(stdout);
114+
assert_eq!(inv.crates.get("bar"), Some(&"0.1.0".to_owned()));
115+
}
116+
117+
#[test]
118+
fn ignores_binary_lines() {
119+
let stdout = "\
120+
crate-a v1.0.0:
121+
binary-a
122+
other
123+
crate-b v2.0.0:
124+
\tbinary-b
125+
";
126+
let inv = parse_install_list(stdout);
127+
assert_eq!(inv.crates.len(), 2);
128+
assert!(!inv.crates.contains_key("binary-a"));
129+
assert!(!inv.crates.contains_key("binary-b"));
130+
}
131+
132+
#[test]
133+
fn version_without_v_prefix_still_parses() {
134+
// Defensive: future cargo release might drop the 'v' prefix.
135+
let stdout = "\
136+
crate-x 1.0.0:
137+
crate-x
138+
";
139+
let inv = parse_install_list(stdout);
140+
assert_eq!(inv.crates.get("crate-x"), Some(&"1.0.0".to_owned()));
141+
}
142+
}

crates/pearlite-cargo/src/lib.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,23 @@
22
// Copyright (C) 2026 Mohamed Hammad
33

44
//! cargo adapter: list installed crates; install and uninstall on apply.
5+
//!
6+
//! At M1 only the read side ([`Cargo::inventory`]) is implemented.
7+
//! Install/uninstall land in M2 with the rest of the apply-engine
8+
//! adapters per Plan §7.3.
9+
10+
mod errors;
11+
mod inventory;
12+
mod live;
13+
#[cfg(any(test, feature = "test-mocks"))]
14+
mod mock;
15+
16+
pub use errors::CargoError;
17+
pub use inventory::parse_install_list;
18+
pub use live::{Cargo, LiveCargo};
19+
20+
#[cfg(feature = "test-mocks")]
21+
pub use mock::MockCargo;
22+
23+
#[doc(no_inline)]
24+
pub use pearlite_schema::CargoInventory;

crates/pearlite-cargo/src/live.rs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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

Comments
 (0)