ossctl_core/release/adapters/cargo.rs
1//! Rust ecosystem adapter: `cargo-publish` (crates.io) and `cargo-dist`.
2//!
3//! `cargo-publish` publishes a crate to crates.io via `cargo publish`.
4//! `cargo-dist` plans and builds distributable binaries locally (`dist`), but
5//! its *upload* is the CI release workflow — so its publish body is
6//! [`AdapterError::Unsupported`] from this host rather than a fabricated receipt
7//! for a build-only command. `verify` (for `cargo-publish`) reconciles against
8//! crates.io through [`RegistryQuery`](crate::ports::RegistryQuery) via the
9//! adapter's default path.
10
11use std::time::Duration;
12
13use crate::contract::schema::Adapter;
14use crate::protocol::release::{BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt};
15
16use super::{make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, ReleaseAdapter};
17
18/// The rust release adapter, operating as either `cargo-publish` or `cargo-dist`.
19pub struct CargoAdapter {
20 adapter: Adapter,
21}
22
23impl CargoAdapter {
24 /// Construct for a resolved rust adapter identity (`cargo-publish` /
25 /// `cargo-dist`).
26 #[must_use]
27 pub fn new(adapter: Adapter) -> Self {
28 debug_assert!(matches!(
29 adapter,
30 Adapter::CargoPublish | Adapter::CargoDist
31 ));
32 Self { adapter }
33 }
34}
35
36impl ReleaseAdapter for CargoAdapter {
37 fn adapter(&self) -> Adapter {
38 self.adapter
39 }
40
41 fn dry_run(
42 &self,
43 _ctx: &EffectCtx<'_>,
44 t: &AdapterTarget,
45 ) -> Result<DryRunReport, AdapterError> {
46 let planned_commands = match self.adapter {
47 Adapter::CargoDist => vec![PlannedCommand::new(
48 "dist",
49 &["plan", "--output-format=json"],
50 )],
51 _ => vec![PlannedCommand::new(
52 "cargo",
53 &["publish", "-p", &t.package, "--dry-run"],
54 )],
55 };
56 Ok(DryRunReport {
57 adapter: self.adapter,
58 planned_commands,
59 notes: vec![],
60 })
61 }
62
63 fn build(
64 &self,
65 ctx: &EffectCtx<'_>,
66 t: &AdapterTarget,
67 ) -> Result<BuildArtifacts, AdapterError> {
68 // `dist build` emits per-platform tarballs/installers, not a `.crate`;
69 // name the artifact set to match what each identity actually produces.
70 let (cmds, artifacts) = match self.adapter {
71 Adapter::CargoDist => (
72 vec![PlannedCommand::new("dist", &["build"])],
73 vec!["dist/".to_string()],
74 ),
75 _ => (
76 vec![PlannedCommand::new("cargo", &["package", "-p", &t.package])],
77 vec![format!("{}-{}.crate", t.package, t.version)],
78 ),
79 };
80 run_all(ctx, &cmds)?;
81 // SKELETON: a production build parses the exact packaged `.crate` /
82 // `dist-manifest.json` paths out of the command output; here we name the
83 // expected artifact set deterministically.
84 Ok(BuildArtifacts {
85 adapter: self.adapter,
86 artifacts,
87 notes: vec![],
88 })
89 }
90
91 fn publish(
92 &self,
93 ctx: &EffectCtx<'_>,
94 t: &AdapterTarget,
95 ) -> Result<PublishReceipt, AdapterError> {
96 // cargo-dist uploads via the CI release workflow, not from this host —
97 // `dist build` only builds. Report that honestly rather than returning a
98 // receipt for a publish that did not happen.
99 if matches!(self.adapter, Adapter::CargoDist) {
100 return Err(AdapterError::Unsupported {
101 adapter: self.adapter,
102 operation: "publish",
103 });
104 }
105 // PER-TARGET IRREVERSIBLE — drives the real `cargo publish` through the
106 // injected runner (the port is the safety seam under test). No
107 // `--no-verify`: a resume that enters publish without re-running build
108 // must still let cargo verify the package before it lands.
109 run_all(
110 ctx,
111 &[PlannedCommand::new("cargo", &["publish", "-p", &t.package])],
112 )?;
113 // SKELETON: a production publish parses the crates.io checksum from the
114 // `cargo publish` output for `digest`; the canonical URL is well-known.
115 let remote_url = Some(format!(
116 "https://crates.io/crates/{}/{}",
117 t.package, t.version
118 ));
119 Ok(make_receipt(ctx, t, None, remote_url))
120 }
121
122 fn timeout(&self) -> Duration {
123 Duration::from_secs(600)
124 }
125}