Skip to main content

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}