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//!
11//! ## One plan target = one publish unit (ADR-0004)
12//!
13//! Each plan target publishes **exactly its own package** — one target ⇒ one
14//! `cargo publish -p <package>`. The [coordinator](super::super::coordinator) owns
15//! all cross-target ordering: it cuts same-ecosystem targets in dependency order
16//! (a dependency's target before its dependents'), so the adapter never re-orders
17//! or re-publishes another target's crate. This removes the earlier
18//! closure-per-target model, where two authorities (coordinator + adapter) each
19//! computed overlapping publish orderings and the crates.io publish→index lag
20//! between them could trigger a *duplicate* `cargo publish` of a shared dependency
21//! → a partial-publish trap.
22//!
23//! A workspace whose crates depend on one another still cannot publish in one
24//! shot: crates.io rejects a crate whose sibling dependency is not yet indexed
25//! (`no matching package named … found`). So before publishing its own package,
26//! the adapter discovers that package's publishable intra-workspace dependencies
27//! (read-only `cargo metadata`) and **waits for each to be crates.io-index-visible**
28//! (polling the injected [`RegistryQuery`](crate::ports::RegistryQuery), bounded by
29//! a timeout) — the dependency's own target, cut earlier, already published it, so
30//! this only closes the index-lag window. A crate with no publishable workspace
31//! dependencies publishes immediately with no wait.
32//!
33//! The consequence is a target model where **each publishable crate is its own
34//! declared target** (which is what `/oss-init` emits). A multi-crate workspace
35//! that wants every crate on crates.io declares every crate as a target; a target
36//! whose package depends on a workspace crate that is *not* itself a declared
37//! target — and whose required version is not already on the index — times out
38//! waiting for that crate, the signal that it must be declared. (If that
39//! dependency's version happens to already be published, the wait clears and the
40//! publish proceeds; the coverage check that would catch an under-declared plan up
41//! front is tracked separately, not owned by the adapter.)
42
43use std::collections::BTreeMap;
44use std::collections::HashSet;
45use std::time::Duration;
46
47use serde::Deserialize;
48
49use crate::contract::schema::{Adapter, Ecosystem, Registry};
50use crate::protocol::release::{BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt};
51
52use super::{make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, ReleaseAdapter};
53
54/// Wall-clock ceiling for a single crate's crates.io index-wait, in seconds.
55///
56/// crates.io's sparse index is usually visible within seconds of a publish, but
57/// the publish→index pipeline can lag under load; a generous per-crate ceiling
58/// avoids a spurious failure while still bounding a hung wait so it can never
59/// wedge a run.
60const INDEX_WAIT_TIMEOUT_SECS: u64 = 300;
61
62/// Interval between crates.io index polls while waiting for a just-published
63/// version to appear.
64const INDEX_POLL_INTERVAL: Duration = Duration::from_secs(3);
65
66/// Cargo's registry alias for crates.io. Used two ways: as the value of every
67/// `cargo publish/package --registry <alias>` this adapter emits (so the publish
68/// destination is pinned and never resolved from ambient registry config —
69/// `registry.default`, `.cargo/config.toml`, `CARGO_REGISTRY_DEFAULT`), and as the
70/// token a manifest's `publish` allow-list must contain to be crates.io-publishable
71/// (a member restricted to a *different* registry is excluded).
72const CRATES_IO_ALIAS: &str = "crates-io";
73
74/// The rust release adapter, operating as either `cargo-publish` or `cargo-dist`.
75pub struct CargoAdapter {
76 adapter: Adapter,
77}
78
79impl CargoAdapter {
80 /// Construct for a resolved rust adapter identity (`cargo-publish` /
81 /// `cargo-dist`).
82 #[must_use]
83 pub fn new(adapter: Adapter) -> Self {
84 debug_assert!(matches!(
85 adapter,
86 Adapter::CargoPublish | Adapter::CargoDist
87 ));
88 Self { adapter }
89 }
90
91 /// The cargo `--registry` alias to pin this target's `cargo publish`/`package`
92 /// invocations to, derived from the target's declared registry.
93 ///
94 /// crates.io is the only rust registry ossctl supports today, so any other
95 /// declared registry is a misconfiguration that must fail **fast, before any
96 /// external action** — never a silent publish to an unexpected destination.
97 /// Returns [`CRATES_IO_ALIAS`] for [`Registry::CratesIo`], else
98 /// [`AdapterError::UnsupportedRegistry`] tagged with **this** adapter's identity
99 /// (`self.adapter`, not a hard-coded value, so a `cargo-dist` caller could never
100 /// misreport itself). Threading the flag value through here (rather than
101 /// hard-coding it at each call site) keeps the destination tied to the contract's
102 /// `registry` field and the rejection in one place.
103 fn crates_io_registry(&self, t: &AdapterTarget) -> Result<&'static str, AdapterError> {
104 match t.target.registry {
105 Registry::CratesIo => Ok(CRATES_IO_ALIAS),
106 registry => Err(AdapterError::UnsupportedRegistry {
107 adapter: self.adapter,
108 registry,
109 }),
110 }
111 }
112}
113
114impl ReleaseAdapter for CargoAdapter {
115 fn adapter(&self) -> Adapter {
116 self.adapter
117 }
118
119 fn is_ci_delegated(&self) -> bool {
120 // `cargo-dist` builds distributables locally but its *upload* is the
121 // tag-triggered `release.yml` — the engine cannot (and must not) publish it
122 // from this host. `cargo-publish` is a real host publish and is not
123 // delegated. Consistent with `publish` returning `Unsupported` for
124 // `cargo-dist` only.
125 matches!(self.adapter, Adapter::CargoDist)
126 }
127
128 fn ci_owns_github_release(&self) -> bool {
129 // `cargo-dist`'s generated `release.yml` runs `gh release create <tag> …
130 // artifacts/*` — it creates AND finalizes the shared GitHub Release and
131 // uploads the cross-platform binaries. So the coordinator must not create
132 // the Release itself (a pre-existing Release makes `gh release create`
133 // error). `cargo-publish` owns no GitHub Release. See
134 // `coordinator-release-vs-cargo-dist-ownership`.
135 matches!(self.adapter, Adapter::CargoDist)
136 }
137
138 fn dry_run(
139 &self,
140 ctx: &EffectCtx<'_>,
141 t: &AdapterTarget,
142 ) -> Result<DryRunReport, AdapterError> {
143 if matches!(self.adapter, Adapter::CargoDist) {
144 return Ok(DryRunReport {
145 adapter: self.adapter,
146 planned_commands: vec![PlannedCommand::new(
147 "dist",
148 &["plan", "--output-format=json"],
149 )],
150 notes: vec![],
151 });
152 }
153 // Reject a non-crates.io target before doing anything — the dry-run
154 // preflight must exercise the exact registry-pinned build a real cut runs, and
155 // a misconfigured registry is a fail-fast error, not a plannable command.
156 let registry = self.crates_io_registry(t)?;
157 // FAITHFUL PREFLIGHT: actually run the SAME index-independent build gate the
158 // build phase runs — `cargo check -p <pkg>` (local workspace compile) then
159 // `cargo package --no-verify` (tarball) — so a plan that cannot compile or
160 // package fails HERE at dry-run-all, before any external effect, rather than
161 // passing dry-run and failing mid-cut in build-all. (The old dry-run only
162 // *described* a `cargo publish --dry-run` without running it, so a build-phase
163 // failure slipped past the preflight.) Neither command resolves the
164 // not-yet-published `=X.Y.Z` workspace dep against the crates.io index (the
165 // check uses the sibling `path`; `--no-verify` skips the index-resolving verify
166 // compile), so the preflight validates what *can* be validated pre-publish and
167 // never false-fails on the unpublished dep. See `build` for the full rationale
168 // of each step; the real end-to-end verify happens in publish-all's
169 // `cargo publish`, after the dep is index-visible.
170 //
171 // Both commands are local + self-overwriting (the package writes
172 // `target/package/`, the same artifact build-all produces; `check` only warms
173 // `target/`), so dry-run stays re-runnable and free of any *external* side
174 // effect (ADR-0002). One plan target = one publish unit: exactly this target's
175 // own package.
176 //
177 // `target_workspace_deps` runs read-only `cargo metadata` first, validating
178 // the target is a publishable member and listing the workspace dependencies a
179 // real cut waits to be index-visible before publishing.
180 let deps = target_workspace_deps(ctx, t)?;
181 let planned_commands = vec![
182 PlannedCommand::new("cargo", &["check", "-p", &t.package]),
183 PlannedCommand::new(
184 "cargo",
185 &[
186 "package",
187 "--registry",
188 registry,
189 "-p",
190 &t.package,
191 "--no-verify",
192 ],
193 ),
194 ];
195 run_all(ctx, &planned_commands)?;
196 let mut notes = vec![format!(
197 "publishes with `cargo publish --registry {registry} -p {}` in publish-all",
198 t.package
199 )];
200 if !deps.is_empty() {
201 let chain = deps
202 .iter()
203 .map(|m| format!("{}@{}", m.name, m.version))
204 .collect::<Vec<_>>()
205 .join(", ");
206 notes.push(format!(
207 "waits for these workspace dependencies to be crates.io-index-visible \
208 before publishing `{}`: {chain}",
209 t.package
210 ));
211 }
212 Ok(DryRunReport {
213 adapter: self.adapter,
214 planned_commands,
215 notes,
216 })
217 }
218
219 fn build(
220 &self,
221 ctx: &EffectCtx<'_>,
222 t: &AdapterTarget,
223 ) -> Result<BuildArtifacts, AdapterError> {
224 // `dist build` emits per-platform tarballs/installers, not a `.crate`;
225 // name the artifact set to match what each identity actually produces.
226 let (cmds, artifacts) = if matches!(self.adapter, Adapter::CargoDist) {
227 (
228 vec![PlannedCommand::new("dist", &["build"])],
229 vec!["dist/".to_string()],
230 )
231 } else {
232 // Pin `cargo package` to crates.io too (rejecting a non-crates.io
233 // target up front) so the build phase can never verify-package
234 // against a different registry than the publish phase will target.
235 let registry = self.crates_io_registry(t)?;
236 (
237 // A two-step, INDEX-INDEPENDENT build gate (see
238 // `release-cut-build-phase-dep-ordering`):
239 //
240 // 1. `cargo check -p <pkg>` compiles the crate through the WORKSPACE.
241 // There the `path = "…"` on an intra-workspace dep shadows its
242 // `version = "=X.Y.Z"` requirement, so the compile resolves the
243 // dependency against the sibling *on disk* — never the crates.io
244 // index. This is the PRE-PUBLISH SAFETY NET: it fails build-all on a
245 // genuine compile error (type/trait/API mismatch, missing item)
246 // HERE, before any irreversible publish. Without it, the only
247 // remaining compile check is `cargo publish`'s own verify build in
248 // publish-all — which runs AFTER the dependency crate is already
249 // published, so a compile error there would TEAR the release (the
250 // dependency live on crates.io, the dependent unpublishable). That
251 // is exactly the partial-publish trap ADR-0004 exists to prevent.
252 //
253 // 2. `cargo package --no-verify` produces the `.crate` tarball WITHOUT
254 // the isolated verify compile — and it is that verify build (not
255 // packaging itself) which resolves the `=X.Y.Z` dep against the
256 // *index* and fails during build-all, because the dep is only
257 // *published* later, in publish-all (the barrier is
258 // build-ALL → publish-ALL). The tarball is still produced. The full
259 // end-to-end verify against the real registry still happens in
260 // publish-all's `cargo publish`, by which point the dependency has
261 // been published AND index-waited (dep-ordered publish +
262 // `wait_for_index`, ADR-0004), so it resolves.
263 //
264 // Both commands are local + per-target, so no build-time cross-target
265 // ordering leaks into the adapter (ADR-0002/0004 preserved).
266 vec![
267 PlannedCommand::new("cargo", &["check", "-p", &t.package]),
268 PlannedCommand::new(
269 "cargo",
270 &[
271 "package",
272 "--registry",
273 registry,
274 "-p",
275 &t.package,
276 "--no-verify",
277 ],
278 ),
279 ],
280 vec![format!("{}-{}.crate", t.package, t.version)],
281 )
282 };
283 run_all(ctx, &cmds)?;
284 // SKELETON: a production build parses the exact packaged `.crate` /
285 // `dist-manifest.json` paths out of the command output; here we name the
286 // expected artifact set deterministically.
287 Ok(BuildArtifacts {
288 adapter: self.adapter,
289 artifacts,
290 notes: vec![],
291 })
292 }
293
294 fn publish(
295 &self,
296 ctx: &EffectCtx<'_>,
297 t: &AdapterTarget,
298 ) -> Result<PublishReceipt, AdapterError> {
299 // cargo-dist uploads via the CI release workflow, not from this host —
300 // `dist build` only builds. Report that honestly rather than returning a
301 // receipt for a publish that did not happen.
302 if matches!(self.adapter, Adapter::CargoDist) {
303 return Err(AdapterError::Unsupported {
304 adapter: self.adapter,
305 operation: "publish",
306 });
307 }
308 // Reject a non-crates.io target BEFORE the idempotency probe or any publish —
309 // the whole publish path (probe, index-wait, receipt URL) assumes crates.io,
310 // so a mismatched registry must fail closed here, never reach `cargo publish`.
311 let registry = self.crates_io_registry(t)?;
312 // PER-TARGET IRREVERSIBLE — drives the real `cargo publish` through the
313 // injected runner (the port is the safety seam under test). ADR-0004: one
314 // plan target = one publish unit, so this publishes ONLY `t.package`; the
315 // coordinator cut every dependency's target before this one. No
316 // `--no-verify`: a resume that enters publish without re-running build must
317 // still let cargo verify the package before it lands.
318 //
319 // IDEMPOTENT re-entry with TRI-STATE probing. On resume the coordinator
320 // re-enters this method from the top, so probe the registry first and skip
321 // an already-landed publish (a second `cargo publish` of an uploaded version
322 // hard-fails and would wedge every resume). Crucially, a probe that cannot
323 // reach the registry is NOT read as "not published" — that would permit a
324 // duplicate upload of a crate that in fact landed. It fails the publish
325 // closed ([`AdapterError::RegistryUnavailable`]), mirroring the reconcile
326 // layer's outage ⇒ `Unknown` ⇒ never-`Missing` discipline.
327 let ecosystem = t.ecosystem();
328 if is_published(ctx, ecosystem, &t.package, &t.version)? {
329 return Ok(make_receipt(ctx, t, None, Some(remote_url(t))));
330 }
331 // crates.io rejects a crate whose sibling dependency is not yet indexed, so
332 // wait for this package's own publishable workspace dependencies to be
333 // index-visible before publishing it. Each dependency's target was cut
334 // earlier by the coordinator; this only closes the publish→index lag window.
335 for dep in &target_workspace_deps(ctx, t)? {
336 wait_for_index(ctx, ecosystem, &dep.name, &dep.version)?;
337 }
338 run_all(
339 ctx,
340 &[PlannedCommand::new(
341 "cargo",
342 &["publish", "--registry", registry, "-p", &t.package],
343 )],
344 )?;
345 // SKELETON: a production publish parses the crates.io checksum from the
346 // `cargo publish` output for `digest`; the canonical URL is well-known. One
347 // target publishes exactly one crate, so the journal records exactly one
348 // receipt for this crate — `resume`/`verify` track it precisely.
349 Ok(make_receipt(ctx, t, None, Some(remote_url(t))))
350 }
351
352 fn timeout(&self) -> Duration {
353 Duration::from_secs(600)
354 }
355}
356
357/// The canonical crates.io URL for a target's own package at its version — the
358/// receipt's `remote_url`. Correct because the publish paths call this only after
359/// [`crates_io_registry`] has confirmed the target's registry is crates.io.
360fn remote_url(t: &AdapterTarget) -> String {
361 format!("https://crates.io/crates/{}/{}", t.package, t.version)
362}
363
364/// The publishable intra-workspace dependencies of the target's own package — the
365/// crates that must be crates.io-index-visible before `t.package` can publish
366/// (ADR-0004). Each has its own plan target, cut earlier by the coordinator.
367///
368/// Runs read-only `cargo metadata`, keeps only members publishable to crates.io
369/// (dropping `publish = false` and members restricted to another registry), and
370/// returns the direct workspace dependencies of `t.package` among them. Errors if
371/// `t.package` is not itself a publishable member (the plan approved a package this
372/// workspace cannot publish to crates.io). A crate with no publishable workspace
373/// dependencies resolves to an empty list — it publishes with no wait.
374fn target_workspace_deps(
375 ctx: &EffectCtx<'_>,
376 t: &AdapterTarget,
377) -> Result<Vec<Member>, AdapterError> {
378 let meta = load_metadata(ctx)?;
379 let members = publishable_members(&meta);
380 let by_name: BTreeMap<&str, &Member> = members.iter().map(|m| (m.name.as_str(), m)).collect();
381 let Some(target) = by_name.get(t.package.as_str()) else {
382 let available: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
383 return Err(AdapterError::Command {
384 command: "cargo metadata".to_string(),
385 code: None,
386 stderr: format!(
387 "target package `{}` is not a crates.io-publishable member of this workspace \
388 (publishable members: {available:?}); declare each publishable crate as its own \
389 target and check the contract `package` and each crate's `publish` setting",
390 t.package
391 ),
392 });
393 };
394 // `publishable_members` already restricted each member's `deps` to *other kept
395 // members*, so every dep name here resolves to a publishable member.
396 Ok(target
397 .deps
398 .iter()
399 .filter_map(|d| by_name.get(d.as_str()).map(|m| (*m).clone()))
400 .collect())
401}
402
403/// Run `cargo metadata` and parse the workspace graph. Errors on a command
404/// failure, on empty output (a real `cargo metadata` never succeeds with empty
405/// stdout — empty means a broken host/runner, which must not silently degrade the
406/// publish set), or on unparseable output.
407fn load_metadata(ctx: &EffectCtx<'_>) -> Result<CargoMetadata, AdapterError> {
408 let cmd = PlannedCommand::new("cargo", &["metadata", "--no-deps", "--format-version", "1"]);
409 let outputs = run_all(ctx, std::slice::from_ref(&cmd))?;
410 let stdout = outputs[0].stdout.trim();
411 if stdout.is_empty() {
412 return Err(AdapterError::Command {
413 command: cmd.rendered(),
414 code: None,
415 stderr: "`cargo metadata` succeeded but emitted no output — cannot resolve the \
416 workspace publish set"
417 .to_string(),
418 });
419 }
420 serde_json::from_str(stdout).map_err(|e| AdapterError::Command {
421 command: cmd.rendered(),
422 code: None,
423 stderr: format!("could not parse `cargo metadata` output: {e}"),
424 })
425}
426
427/// Project the metadata onto the crates.io-publishable members and their
428/// intra-workspace (non-dev) dependency edges.
429///
430/// A member is kept unless its manifest sets `publish = false` (which
431/// `cargo metadata` reports as an empty `publish` array) or restricts publishing
432/// to a registry set that does not include crates.io. Only edges to *other kept
433/// members* gate order; dev-dependencies are excluded (they never gate publish
434/// order and can form legitimate cycles, e.g. a lib crate that dev-depends on the
435/// CLI crate for integration tests).
436fn publishable_members(meta: &CargoMetadata) -> Vec<Member> {
437 let member_ids: HashSet<&str> = meta.workspace_members.iter().map(String::as_str).collect();
438 let pkgs: Vec<&MetaPackage> = meta
439 .packages
440 .iter()
441 .filter(|p| member_ids.contains(p.id.as_str()))
442 .filter(|p| publishable_to_crates_io(p.publish.as_deref()))
443 .collect();
444 let names: HashSet<&str> = pkgs.iter().map(|p| p.name.as_str()).collect();
445 pkgs.iter()
446 .map(|p| {
447 let mut deps: Vec<String> = p
448 .dependencies
449 .iter()
450 // Allow-list the ordering-relevant kinds (normal + build); a future
451 // dep kind is excluded rather than accidentally treated as ordering.
452 .filter(|d| matches!(d.kind.as_deref(), None | Some("build")))
453 .filter(|d| d.name != p.name && names.contains(d.name.as_str()))
454 .map(|d| d.name.clone())
455 .collect();
456 deps.sort();
457 deps.dedup();
458 Member {
459 name: p.name.clone(),
460 version: p.version.clone(),
461 deps,
462 }
463 })
464 .collect()
465}
466
467/// Whether a member's `publish` field permits crates.io. `None`/absent ⇒ any
468/// registry (yes); `Some([])` ⇒ `publish = false` (no); `Some([regs…])` ⇒ only if
469/// the list names crates.io.
470fn publishable_to_crates_io(publish: Option<&[String]>) -> bool {
471 match publish {
472 None => true,
473 Some(regs) => regs.iter().any(|r| r == CRATES_IO_ALIAS),
474 }
475}
476
477/// Whether `package@version` is already visible on crates.io — the idempotency
478/// probe run before `cargo publish` so a resumed cut skips a crate that already
479/// landed instead of hard-failing on a duplicate upload.
480///
481/// **Tri-state, fail-closed.** `Ok(true)` ⇒ already published (skip); `Ok(false)`
482/// ⇒ the registry answered and the version is definitively absent (safe to
483/// publish); `Err(RegistryUnavailable)` ⇒ the registry could not be reached, so
484/// the probe cannot prove the crate has *not* landed. A registry error is **never**
485/// read as "not published" (which would permit a duplicate, irreversible upload);
486/// the caller fails closed, mirroring the reconcile layer's outage ⇒ `Unknown`
487/// discipline.
488fn is_published(
489 ctx: &EffectCtx<'_>,
490 ecosystem: Ecosystem,
491 package: &str,
492 version: &str,
493) -> Result<bool, AdapterError> {
494 match ctx.registry.published_versions(ecosystem.as_str(), package) {
495 Ok(versions) => Ok(versions.iter().any(|v| v == version)),
496 Err(e) => Err(AdapterError::RegistryUnavailable {
497 package: package.to_string(),
498 version: version.to_string(),
499 source: e.to_string(),
500 }),
501 }
502}
503
504/// Poll the crates.io index (through the injected [`RegistryQuery`]) until
505/// `package@version` is visible, or the per-crate timeout elapses.
506///
507/// Between polls it waits [`INDEX_POLL_INTERVAL`] through the injected
508/// [`Clock::sleep`](crate::ports::Clock::sleep) — real time in production, a
509/// virtual advance under test — so the loop is bounded, never busy, and
510/// deterministic in tests. A transient lookup error is retried (waiting is
511/// reversible), but the outcome on timeout is **honest**: if the most recent poll
512/// *observed* the version absent, that is [`AdapterError::IndexTimeout`]; if the
513/// registry could not be reached at all (the last poll errored), that is
514/// [`AdapterError::RegistryUnavailable`] carrying the underlying error — a
515/// sustained outage is never masked as "did not index".
516fn wait_for_index(
517 ctx: &EffectCtx<'_>,
518 ecosystem: Ecosystem,
519 package: &str,
520 version: &str,
521) -> Result<(), AdapterError> {
522 let start = ctx.clock.now_unix();
523 // The last poll's registry error, if it errored; cleared on any successful
524 // observation. Assigned on every path of the match below before it is read at
525 // the timeout check, so it needs no initializer. Drives the classification.
526 let mut last_err: Option<String>;
527 loop {
528 match ctx.registry.published_versions(ecosystem.as_str(), package) {
529 Ok(versions) => {
530 if versions.iter().any(|v| v == version) {
531 return Ok(());
532 }
533 last_err = None;
534 }
535 Err(e) => last_err = Some(e.to_string()),
536 }
537 if ctx.clock.now_unix().saturating_sub(start) >= INDEX_WAIT_TIMEOUT_SECS {
538 return Err(match last_err {
539 Some(source) => AdapterError::RegistryUnavailable {
540 package: package.to_string(),
541 version: version.to_string(),
542 source,
543 },
544 None => AdapterError::IndexTimeout {
545 package: package.to_string(),
546 version: version.to_string(),
547 waited_secs: INDEX_WAIT_TIMEOUT_SECS,
548 },
549 });
550 }
551 ctx.clock.sleep(INDEX_POLL_INTERVAL);
552 }
553}
554
555/// A publishable workspace member with its version and its intra-workspace
556/// (non-dev) dependency names.
557#[derive(Clone)]
558struct Member {
559 name: String,
560 version: String,
561 deps: Vec<String>,
562}
563
564/// The subset of `cargo metadata --format-version 1 --no-deps` output the
565/// publish-order discovery reads.
566#[derive(Deserialize)]
567struct CargoMetadata {
568 /// Every package in the metadata; with `--no-deps` these are the workspace
569 /// members only.
570 packages: Vec<MetaPackage>,
571 /// The package ids that are workspace members (matched against
572 /// [`MetaPackage::id`] to be exact regardless of the id string format).
573 workspace_members: Vec<String>,
574}
575
576/// One package entry from `cargo metadata`.
577#[derive(Deserialize)]
578struct MetaPackage {
579 name: String,
580 version: String,
581 id: String,
582 #[serde(default)]
583 dependencies: Vec<MetaDep>,
584 /// `null`/absent ⇒ publishable to any registry; `[]` ⇒ `publish = false`;
585 /// `["<registry>",…]` ⇒ publishable to a restricted set (still publishable).
586 #[serde(default)]
587 publish: Option<Vec<String>>,
588}
589
590/// One dependency entry from `cargo metadata`.
591#[derive(Deserialize)]
592struct MetaDep {
593 name: String,
594 /// `null` (normal), `"dev"`, or `"build"`. Only normal/build deps gate
595 /// publish order; dev-deps never do.
596 #[serde(default)]
597 kind: Option<String>,
598}