ossctl-core 0.2.1

Core library for ossctl: contract normalizer, repo-fact detection, audit scoring, release engine, and the versioned protocol DTOs.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
//! Rust ecosystem adapter: `cargo-publish` (crates.io) and `cargo-dist`.
//!
//! `cargo-publish` publishes a crate to crates.io via `cargo publish`.
//! `cargo-dist` plans and builds distributable binaries locally (`dist`), but
//! its *upload* is the CI release workflow — so its publish body is
//! [`AdapterError::Unsupported`] from this host rather than a fabricated receipt
//! for a build-only command. `verify` (for `cargo-publish`) reconciles against
//! crates.io through [`RegistryQuery`](crate::ports::RegistryQuery) via the
//! adapter's default path.
//!
//! ## One plan target = one publish unit (ADR-0004)
//!
//! Each plan target publishes **exactly its own package** — one target ⇒ one
//! `cargo publish -p <package>`. The [coordinator](super::super::coordinator) owns
//! all cross-target ordering: it cuts same-ecosystem targets in dependency order
//! (a dependency's target before its dependents'), so the adapter never re-orders
//! or re-publishes another target's crate. This removes the earlier
//! closure-per-target model, where two authorities (coordinator + adapter) each
//! computed overlapping publish orderings and the crates.io publish→index lag
//! between them could trigger a *duplicate* `cargo publish` of a shared dependency
//! → a partial-publish trap.
//!
//! A workspace whose crates depend on one another still cannot publish in one
//! shot: crates.io rejects a crate whose sibling dependency is not yet indexed
//! (`no matching package named … found`). So before publishing its own package,
//! the adapter discovers that package's publishable intra-workspace dependencies
//! (read-only `cargo metadata`) and **waits for each to be crates.io-index-visible**
//! (polling the injected [`RegistryQuery`](crate::ports::RegistryQuery), bounded by
//! a timeout) — the dependency's own target, cut earlier, already published it, so
//! this only closes the index-lag window. A crate with no publishable workspace
//! dependencies publishes immediately with no wait.
//!
//! ## Deferred packaging for a `=`-pinned dependent (cargo-interleave, ADR-0002)
//!
//! A dependent crate that pins its workspace dependency by exact version
//! (`dep = "=X.Y.Z"`, the shape `/oss-init` emits) **cannot be `cargo package`d
//! before that dependency is published** — not even with `--no-verify`. `cargo
//! package` resolves the `=`-pinned dependency against the crates.io *index* while
//! preparing the upload (a published `.crate` cannot reference a `path` dep), and
//! that version only lands later, in publish-all. `--no-verify` skips the isolated
//! verify *compile*, but not this index resolution. So a strict `build-all` that
//! packaged every crate up front could never package such a dependent
//! (`release-cut-build-phase-dep-ordering`).
//!
//! The fix scopes the ADR-0002 phase barrier narrowly for cargo. `dry_run` /
//! `build` read the workspace graph and probe the registry (see
//! `unpublished_workspace_deps`), then branch on whether the target depends on a
//! workspace crate **not yet on the crates.io index**:
//!
//! - **No unpublished workspace dep** (a leaf, or a dependent whose workspace deps
//!   are already published — a re-cut): fully packaged pre-publish — `cargo check`
//!   (compile safety net) then `cargo package --no-verify` (produces the `.crate`,
//!   validating the manifest). It CAN be packaged: `cargo package` resolves the dep
//!   against the index it is already on.
//! - **Depends on a not-yet-published workspace crate**: its **packaging is deferred**
//!   to `cargo publish` in publish-all, which packages and publishes as one unit
//!   *after* the dependency is published and index-visible (`build` runs only the
//!   index-independent `cargo check`, which resolves the sibling via its `path`).
//!   This is the "build interleaves with publish" exception the coordinator relies
//!   on: the dependent's package step is intrinsic to its dep-ordered publish, not a
//!   premature global-build step. The pre-publish compile safety net (`cargo check`
//!   over every target) still runs as a global build-all barrier, so a compile error
//!   in the default host build fails before **any** irreversible publish.
//!
//! The registry probe is **fail-closed**: a dep the registry cannot confirm as
//! published defers, so a registry outage never risks a build-all `cargo package`
//! that resolves a `=`-pinned dep against an index it cannot reach.
//!
//! The consequence is a target model where **each publishable crate is its own
//! declared target** (which is what `/oss-init` emits). A multi-crate workspace
//! that wants every crate on crates.io declares every crate as a target; a target
//! whose package depends on a workspace crate that is *not* itself a declared
//! target — and whose required version is not already on the index — times out
//! waiting for that crate, the signal that it must be declared. (If that
//! dependency's version happens to already be published, the wait clears and the
//! publish proceeds; the coverage check that would catch an under-declared plan up
//! front is tracked separately, not owned by the adapter.)

use std::collections::BTreeMap;
use std::collections::HashSet;
use std::time::Duration;

use serde::Deserialize;

use crate::contract::schema::{Adapter, Ecosystem, Registry};
use crate::protocol::release::{BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt};

use super::{make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, ReleaseAdapter};

/// Wall-clock ceiling for a single crate's crates.io index-wait, in seconds.
///
/// crates.io's sparse index is usually visible within seconds of a publish, but
/// the publish→index pipeline can lag under load; a generous per-crate ceiling
/// avoids a spurious failure while still bounding a hung wait so it can never
/// wedge a run.
const INDEX_WAIT_TIMEOUT_SECS: u64 = 300;

/// Interval between crates.io index polls while waiting for a just-published
/// version to appear.
const INDEX_POLL_INTERVAL: Duration = Duration::from_secs(3);

/// Cargo's registry alias for crates.io. Used two ways: as the value of every
/// `cargo publish/package --registry <alias>` this adapter emits (so the publish
/// destination is pinned and never resolved from ambient registry config —
/// `registry.default`, `.cargo/config.toml`, `CARGO_REGISTRY_DEFAULT`), and as the
/// token a manifest's `publish` allow-list must contain to be crates.io-publishable
/// (a member restricted to a *different* registry is excluded).
const CRATES_IO_ALIAS: &str = "crates-io";

/// The rust release adapter, operating as either `cargo-publish` or `cargo-dist`.
pub struct CargoAdapter {
    adapter: Adapter,
}

impl CargoAdapter {
    /// Construct for a resolved rust adapter identity (`cargo-publish` /
    /// `cargo-dist`).
    #[must_use]
    pub fn new(adapter: Adapter) -> Self {
        debug_assert!(matches!(
            adapter,
            Adapter::CargoPublish | Adapter::CargoDist
        ));
        Self { adapter }
    }

    /// The cargo `--registry` alias to pin this target's `cargo publish`/`package`
    /// invocations to, derived from the target's declared registry.
    ///
    /// crates.io is the only rust registry ossctl supports today, so any other
    /// declared registry is a misconfiguration that must fail **fast, before any
    /// external action** — never a silent publish to an unexpected destination.
    /// Returns [`CRATES_IO_ALIAS`] for [`Registry::CratesIo`], else
    /// [`AdapterError::UnsupportedRegistry`] tagged with **this** adapter's identity
    /// (`self.adapter`, not a hard-coded value, so a `cargo-dist` caller could never
    /// misreport itself). Threading the flag value through here (rather than
    /// hard-coding it at each call site) keeps the destination tied to the contract's
    /// `registry` field and the rejection in one place.
    fn crates_io_registry(&self, t: &AdapterTarget) -> Result<&'static str, AdapterError> {
        match t.target.registry {
            Registry::CratesIo => Ok(CRATES_IO_ALIAS),
            registry => Err(AdapterError::UnsupportedRegistry {
                adapter: self.adapter,
                registry,
            }),
        }
    }
}

impl ReleaseAdapter for CargoAdapter {
    fn adapter(&self) -> Adapter {
        self.adapter
    }

    fn is_ci_delegated(&self) -> bool {
        // `cargo-dist` builds distributables locally but its *upload* is the
        // tag-triggered `release.yml` — the engine cannot (and must not) publish it
        // from this host. `cargo-publish` is a real host publish and is not
        // delegated. Consistent with `publish` returning `Unsupported` for
        // `cargo-dist` only.
        matches!(self.adapter, Adapter::CargoDist)
    }

    fn ci_owns_github_release(&self) -> bool {
        // `cargo-dist`'s generated `release.yml` runs `gh release create <tag> …
        // artifacts/*` — it creates AND finalizes the shared GitHub Release and
        // uploads the cross-platform binaries. So the coordinator must not create
        // the Release itself (a pre-existing Release makes `gh release create`
        // error). `cargo-publish` owns no GitHub Release. See
        // `coordinator-release-vs-cargo-dist-ownership`.
        matches!(self.adapter, Adapter::CargoDist)
    }

    fn dry_run(
        &self,
        ctx: &EffectCtx<'_>,
        t: &AdapterTarget,
    ) -> Result<DryRunReport, AdapterError> {
        if matches!(self.adapter, Adapter::CargoDist) {
            return Ok(DryRunReport {
                adapter: self.adapter,
                planned_commands: vec![PlannedCommand::new(
                    "dist",
                    &["plan", "--output-format=json"],
                )],
                notes: vec![],
            });
        }
        // Reject a non-crates.io target before doing anything — the dry-run
        // preflight must exercise the exact registry-pinned build a real cut runs, and
        // a misconfigured registry is a fail-fast error, not a plannable command.
        let registry = self.crates_io_registry(t)?;
        // FAITHFUL PREFLIGHT: actually run the SAME index-independent build gate the
        // build phase runs, so a plan that cannot compile (or, for a leaf, cannot
        // package) fails HERE at dry-run-all, before any external effect, rather than
        // passing dry-run and failing mid-cut in build-all. (The old dry-run only
        // *described* a `cargo publish --dry-run` without running it, so a build-phase
        // failure slipped past the preflight.)
        //
        // `target_workspace_deps` runs read-only `cargo metadata` first, validating
        // the target is a publishable member and listing its publishable workspace
        // dependencies. `unpublished_workspace_deps` then probes the registry for each
        // (fail-closed) to decide the gate ([`cargo_build_gate`]): a target with **no
        // workspace dep still absent from the index** is packaged now (`cargo package
        // --no-verify`, validating the manifest); a dependent on a **not-yet-published**
        // workspace crate DEFERS packaging to its own `cargo publish` — it cannot be
        // `cargo package`d until that dep is on the index — so the preflight is the
        // index-independent `cargo check` compile gate alone (the check resolves the
        // sibling via its on-disk `path`, never the index), and never false-fails on
        // the unpublished dep. The real end-to-end verify happens in publish-all's
        // `cargo publish`, after the dep is index-visible.
        //
        // The gate COMMANDS are local + self-overwriting (a package writes
        // `target/package/`, the same artifact build-all produces; `check` only warms
        // `target/`), so dry-run stays re-runnable and free of any *external* side
        // effect (ADR-0002). One plan target = one publish unit: exactly this target's
        // own package.
        let deps = target_workspace_deps(ctx, t)?;
        let deferred = unpublished_workspace_deps(ctx, t.ecosystem(), &deps);
        let defer_packaging = !deferred.is_empty();
        let (planned_commands, _artifacts) =
            cargo_build_gate(registry, &t.package, &t.version, defer_packaging);
        run_all(ctx, &planned_commands)?;
        let mut notes = vec![format!(
            "publishes with `cargo publish --registry {registry} -p {}` in publish-all",
            t.package
        )];
        if defer_packaging {
            let chain = deferred
                .iter()
                .map(|m| format!("{}@{}", m.name, m.version))
                .collect::<Vec<_>>()
                .join(", ");
            notes.push(format!(
                "packaging of `{}` is deferred to that publish: it depends on workspace \
                 crate(s) not yet on the crates.io index, and a dependent cannot be \
                 `cargo package`d until they are published",
                t.package
            ));
            notes.push(format!(
                "waits for these workspace dependencies to be crates.io-index-visible \
                 before publishing `{}`: {chain}",
                t.package
            ));
        }
        Ok(DryRunReport {
            adapter: self.adapter,
            planned_commands,
            notes,
        })
    }

    fn build(
        &self,
        ctx: &EffectCtx<'_>,
        t: &AdapterTarget,
    ) -> Result<BuildArtifacts, AdapterError> {
        // `dist build` emits per-platform tarballs/installers, not a `.crate`;
        // name the artifact set to match what each identity actually produces.
        let (cmds, artifacts, notes) = if matches!(self.adapter, Adapter::CargoDist) {
            (
                vec![PlannedCommand::new("dist", &["build"])],
                vec!["dist/".to_string()],
                vec![],
            )
        } else {
            // Pin `cargo package` to crates.io too (rejecting a non-crates.io target
            // up front) so the build phase can never verify-package against a
            // different registry than the publish phase will target. Reject BEFORE the
            // read-only `cargo metadata` probe so a misconfigured registry runs no
            // command at all.
            let registry = self.crates_io_registry(t)?;
            // Read the workspace graph, then probe the registry to decide the gate: a
            // target that depends on a workspace crate NOT YET on the index cannot be
            // packaged (packaging resolves the `=`-pinned dep against the index), so its
            // packaging is DEFERRED to `cargo publish` and build runs only the
            // index-independent `cargo check`; a target whose workspace deps are already
            // published (or has none) is packaged now. Fail-closed: an unreachable
            // registry defers (see [`unpublished_workspace_deps`]). See
            // [`cargo_build_gate`] and `release-cut-build-phase-dep-ordering`.
            let deps = target_workspace_deps(ctx, t)?;
            let deferred = unpublished_workspace_deps(ctx, t.ecosystem(), &deps);
            let defer_packaging = !deferred.is_empty();
            let (cmds, artifacts) =
                cargo_build_gate(registry, &t.package, &t.version, defer_packaging);
            let notes = if defer_packaging {
                let chain = deferred
                    .iter()
                    .map(|m| format!("{}@{}", m.name, m.version))
                    .collect::<Vec<_>>()
                    .join(", ");
                vec![format!(
                    "packaging of `{}` deferred to `cargo publish` in publish-all (it \
                     depends on workspace crate(s) not yet on the crates.io index: {chain})",
                    t.package
                )]
            } else {
                vec![]
            };
            (cmds, artifacts, notes)
        };
        run_all(ctx, &cmds)?;
        // SKELETON: a production build parses the exact packaged `.crate` /
        // `dist-manifest.json` paths out of the command output; here we name the
        // expected artifact set deterministically.
        Ok(BuildArtifacts {
            adapter: self.adapter,
            artifacts,
            notes,
        })
    }

    fn publish(
        &self,
        ctx: &EffectCtx<'_>,
        t: &AdapterTarget,
    ) -> Result<PublishReceipt, AdapterError> {
        // cargo-dist uploads via the CI release workflow, not from this host —
        // `dist build` only builds. Report that honestly rather than returning a
        // receipt for a publish that did not happen.
        if matches!(self.adapter, Adapter::CargoDist) {
            return Err(AdapterError::Unsupported {
                adapter: self.adapter,
                operation: "publish",
            });
        }
        // Reject a non-crates.io target BEFORE the idempotency probe or any publish —
        // the whole publish path (probe, index-wait, receipt URL) assumes crates.io,
        // so a mismatched registry must fail closed here, never reach `cargo publish`.
        let registry = self.crates_io_registry(t)?;
        // PER-TARGET IRREVERSIBLE — drives the real `cargo publish` through the
        // injected runner (the port is the safety seam under test). ADR-0004: one
        // plan target = one publish unit, so this publishes ONLY `t.package`; the
        // coordinator cut every dependency's target before this one. No
        // `--no-verify`: a resume that enters publish without re-running build must
        // still let cargo verify the package before it lands.
        //
        // IDEMPOTENT re-entry with TRI-STATE probing. On resume the coordinator
        // re-enters this method from the top, so probe the registry first and skip
        // an already-landed publish (a second `cargo publish` of an uploaded version
        // hard-fails and would wedge every resume). Crucially, a probe that cannot
        // reach the registry is NOT read as "not published" — that would permit a
        // duplicate upload of a crate that in fact landed. It fails the publish
        // closed ([`AdapterError::RegistryUnavailable`]), mirroring the reconcile
        // layer's outage ⇒ `Unknown` ⇒ never-`Missing` discipline.
        let ecosystem = t.ecosystem();
        if is_published(ctx, ecosystem, &t.package, &t.version)? {
            return Ok(make_receipt(ctx, t, None, Some(remote_url(t))));
        }
        // crates.io rejects a crate whose sibling dependency is not yet indexed, so
        // wait for this package's own publishable workspace dependencies to be
        // index-visible before publishing it. Each dependency's target was cut
        // earlier by the coordinator; this only closes the publish→index lag window.
        for dep in &target_workspace_deps(ctx, t)? {
            wait_for_index(ctx, ecosystem, &dep.name, &dep.version)?;
        }
        run_all(
            ctx,
            &[PlannedCommand::new(
                "cargo",
                &["publish", "--registry", registry, "-p", &t.package],
            )],
        )?;
        // SKELETON: a production publish parses the crates.io checksum from the
        // `cargo publish` output for `digest`; the canonical URL is well-known. One
        // target publishes exactly one crate, so the journal records exactly one
        // receipt for this crate — `resume`/`verify` track it precisely.
        Ok(make_receipt(ctx, t, None, Some(remote_url(t))))
    }

    fn timeout(&self) -> Duration {
        Duration::from_secs(600)
    }
}

/// The build/preflight gate for a `cargo-publish` target — the commands `dry_run`
/// and `build` both run — plus the build artifacts it produces.
///
/// A pure function of the caller's `defer_packaging` decision (computed identically
/// by `dry_run` and `build` from [`unpublished_workspace_deps`], so the two stay in
/// lockstep — a faithful preflight):
///
/// - **`defer_packaging == false`** (a leaf, or a dependent whose workspace deps are
///   already on the index — so it CAN be packaged): `cargo check -p <pkg>` (compile
///   safety net) then `cargo package --registry <r> -p <pkg> --no-verify`. The
///   package validates the manifest and produces the `.crate`, the single build
///   artifact. `--no-verify` skips only the isolated verify *compile* (redundant with
///   the `cargo check` above and re-run for real by `cargo publish` in publish-all).
/// - **`defer_packaging == true`** (a dependent on a workspace crate NOT yet on the
///   index): `cargo check -p <pkg>` **alone** — an index-independent compile (the
///   sibling resolves via its on-disk `path`, never the index). It is the pre-publish
///   safety net that fails a genuine compile error (type/trait/API mismatch, missing
///   item) before any irreversible publish — the partial-publish trap ADR-0004 exists
///   to prevent. Packaging is **deferred** to `cargo publish` in publish-all, which
///   packages+publishes as one unit *after* the dependency is published and
///   index-visible: `cargo package` (even `--no-verify`) resolves the `=X.Y.Z` dep
///   against the crates.io *index* when preparing the upload, so it cannot run until
///   that dependency is published (`release-cut-build-phase-dep-ordering`). No
///   `.crate` is produced here, so the artifact set is empty.
///
/// The gate commands are local + per-target, so no build-time cross-target ordering
/// leaks into the adapter (ADR-0002/0004 preserved); the coordinator alone orders
/// the publishes.
fn cargo_build_gate(
    registry: &str,
    package: &str,
    version: &str,
    defer_packaging: bool,
) -> (Vec<PlannedCommand>, Vec<String>) {
    let mut cmds = vec![PlannedCommand::new("cargo", &["check", "-p", package])];
    if defer_packaging {
        // Deferred packaging: only the index-independent compile gate runs now.
        return (cmds, Vec::new());
    }
    cmds.push(PlannedCommand::new(
        "cargo",
        &[
            "package",
            "--registry",
            registry,
            "-p",
            package,
            "--no-verify",
        ],
    ));
    (cmds, vec![format!("{package}-{version}.crate")])
}

/// The canonical crates.io URL for a target's own package at its version — the
/// receipt's `remote_url`. Correct because the publish paths call this only after
/// [`crates_io_registry`] has confirmed the target's registry is crates.io.
fn remote_url(t: &AdapterTarget) -> String {
    format!("https://crates.io/crates/{}/{}", t.package, t.version)
}

/// The publishable intra-workspace dependencies of the target's own package — the
/// crates that must be crates.io-index-visible before `t.package` can publish
/// (ADR-0004). Each has its own plan target, cut earlier by the coordinator.
///
/// Runs read-only `cargo metadata`, keeps only members publishable to crates.io
/// (dropping `publish = false` and members restricted to another registry), and
/// returns the direct workspace dependencies of `t.package` among them. Errors if
/// `t.package` is not itself a publishable member (the plan approved a package this
/// workspace cannot publish to crates.io). A crate with no publishable workspace
/// dependencies resolves to an empty list — it publishes with no wait.
fn target_workspace_deps(
    ctx: &EffectCtx<'_>,
    t: &AdapterTarget,
) -> Result<Vec<Member>, AdapterError> {
    let meta = load_metadata(ctx)?;
    let members = publishable_members(&meta);
    let by_name: BTreeMap<&str, &Member> = members.iter().map(|m| (m.name.as_str(), m)).collect();
    let Some(target) = by_name.get(t.package.as_str()) else {
        let available: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
        return Err(AdapterError::Command {
            command: "cargo metadata".to_string(),
            code: None,
            stderr: format!(
                "target package `{}` is not a crates.io-publishable member of this workspace \
                 (publishable members: {available:?}); declare each publishable crate as its own \
                 target and check the contract `package` and each crate's `publish` setting",
                t.package
            ),
        });
    };
    // `publishable_members` already restricted each member's `deps` to *other kept
    // members*, so every dep name here resolves to a publishable member.
    Ok(target
        .deps
        .iter()
        .filter_map(|d| by_name.get(d.as_str()).map(|m| (*m).clone()))
        .collect())
}

/// Run `cargo metadata` and parse the workspace graph. Errors on a command
/// failure, on empty output (a real `cargo metadata` never succeeds with empty
/// stdout — empty means a broken host/runner, which must not silently degrade the
/// publish set), or on unparseable output.
fn load_metadata(ctx: &EffectCtx<'_>) -> Result<CargoMetadata, AdapterError> {
    let cmd = PlannedCommand::new("cargo", &["metadata", "--no-deps", "--format-version", "1"]);
    let outputs = run_all(ctx, std::slice::from_ref(&cmd))?;
    let stdout = outputs[0].stdout.trim();
    if stdout.is_empty() {
        return Err(AdapterError::Command {
            command: cmd.rendered(),
            code: None,
            stderr: "`cargo metadata` succeeded but emitted no output — cannot resolve the \
                     workspace publish set"
                .to_string(),
        });
    }
    serde_json::from_str(stdout).map_err(|e| AdapterError::Command {
        command: cmd.rendered(),
        code: None,
        stderr: format!("could not parse `cargo metadata` output: {e}"),
    })
}

/// Project the metadata onto the crates.io-publishable members and their
/// intra-workspace (non-dev) dependency edges.
///
/// A member is kept unless its manifest sets `publish = false` (which
/// `cargo metadata` reports as an empty `publish` array) or restricts publishing
/// to a registry set that does not include crates.io. Only edges to *other kept
/// members* gate order; dev-dependencies are excluded (they never gate publish
/// order and can form legitimate cycles, e.g. a lib crate that dev-depends on the
/// CLI crate for integration tests).
fn publishable_members(meta: &CargoMetadata) -> Vec<Member> {
    let member_ids: HashSet<&str> = meta.workspace_members.iter().map(String::as_str).collect();
    let pkgs: Vec<&MetaPackage> = meta
        .packages
        .iter()
        .filter(|p| member_ids.contains(p.id.as_str()))
        .filter(|p| publishable_to_crates_io(p.publish.as_deref()))
        .collect();
    let names: HashSet<&str> = pkgs.iter().map(|p| p.name.as_str()).collect();
    pkgs.iter()
        .map(|p| {
            let mut deps: Vec<String> = p
                .dependencies
                .iter()
                // Allow-list the ordering-relevant kinds (normal + build); a future
                // dep kind is excluded rather than accidentally treated as ordering.
                .filter(|d| matches!(d.kind.as_deref(), None | Some("build")))
                .filter(|d| d.name != p.name && names.contains(d.name.as_str()))
                .map(|d| d.name.clone())
                .collect();
            deps.sort();
            deps.dedup();
            Member {
                name: p.name.clone(),
                version: p.version.clone(),
                deps,
            }
        })
        .collect()
}

/// Whether a member's `publish` field permits crates.io. `None`/absent ⇒ any
/// registry (yes); `Some([])` ⇒ `publish = false` (no); `Some([regs…])` ⇒ only if
/// the list names crates.io.
fn publishable_to_crates_io(publish: Option<&[String]>) -> bool {
    match publish {
        None => true,
        Some(regs) => regs.iter().any(|r| r == CRATES_IO_ALIAS),
    }
}

/// The subset of the target's publishable workspace dependencies whose **exact
/// release version is not yet visible on the crates.io index** — the dependencies
/// that make the target unpackageable *now*, so its packaging must defer to
/// `cargo publish` (which packages after those deps publish + index).
///
/// **Fail-closed:** a dependency the registry cannot confirm as published — absent
/// (`Ok(false)`) **or** a registry error (`Err`) — counts as not-yet-published, so
/// packaging defers rather than risk a build-all `cargo package` that resolves a
/// `=`-pinned dep against an index that is missing it or cannot be reached. A
/// dependency **already on the index** (`Ok(true)`) is dropped, so a re-cut whose
/// dependency was published by an earlier release still packages — and manifest-
/// validates — the dependent in build-all (it can: `cargo package` resolves that
/// dep against the index it is already on). This is the precise predicate: "defer
/// iff a workspace dep is not yet on the index", not the coarser "has any workspace
/// dep".
fn unpublished_workspace_deps(
    ctx: &EffectCtx<'_>,
    ecosystem: Ecosystem,
    deps: &[Member],
) -> Vec<Member> {
    deps.iter()
        .filter(|d| !matches!(is_published(ctx, ecosystem, &d.name, &d.version), Ok(true)))
        .cloned()
        .collect()
}

/// Whether `package@version` is already visible on crates.io — the idempotency
/// probe run before `cargo publish` so a resumed cut skips a crate that already
/// landed instead of hard-failing on a duplicate upload.
///
/// **Tri-state, fail-closed.** `Ok(true)` ⇒ already published (skip); `Ok(false)`
/// ⇒ the registry answered and the version is definitively absent (safe to
/// publish); `Err(RegistryUnavailable)` ⇒ the registry could not be reached, so
/// the probe cannot prove the crate has *not* landed. A registry error is **never**
/// read as "not published" (which would permit a duplicate, irreversible upload);
/// the caller fails closed, mirroring the reconcile layer's outage ⇒ `Unknown`
/// discipline.
fn is_published(
    ctx: &EffectCtx<'_>,
    ecosystem: Ecosystem,
    package: &str,
    version: &str,
) -> Result<bool, AdapterError> {
    match ctx.registry.published_versions(ecosystem.as_str(), package) {
        Ok(versions) => Ok(versions.iter().any(|v| v == version)),
        Err(e) => Err(AdapterError::RegistryUnavailable {
            package: package.to_string(),
            version: version.to_string(),
            source: e.to_string(),
        }),
    }
}

/// Poll the crates.io index (through the injected [`RegistryQuery`]) until
/// `package@version` is visible, or the per-crate timeout elapses.
///
/// Between polls it waits [`INDEX_POLL_INTERVAL`] through the injected
/// [`Clock::sleep`](crate::ports::Clock::sleep) — real time in production, a
/// virtual advance under test — so the loop is bounded, never busy, and
/// deterministic in tests. A transient lookup error is retried (waiting is
/// reversible), but the outcome on timeout is **honest**: if the most recent poll
/// *observed* the version absent, that is [`AdapterError::IndexTimeout`]; if the
/// registry could not be reached at all (the last poll errored), that is
/// [`AdapterError::RegistryUnavailable`] carrying the underlying error — a
/// sustained outage is never masked as "did not index".
fn wait_for_index(
    ctx: &EffectCtx<'_>,
    ecosystem: Ecosystem,
    package: &str,
    version: &str,
) -> Result<(), AdapterError> {
    let start = ctx.clock.now_unix();
    // The last poll's registry error, if it errored; cleared on any successful
    // observation. Assigned on every path of the match below before it is read at
    // the timeout check, so it needs no initializer. Drives the classification.
    let mut last_err: Option<String>;
    loop {
        match ctx.registry.published_versions(ecosystem.as_str(), package) {
            Ok(versions) => {
                if versions.iter().any(|v| v == version) {
                    return Ok(());
                }
                last_err = None;
            }
            Err(e) => last_err = Some(e.to_string()),
        }
        if ctx.clock.now_unix().saturating_sub(start) >= INDEX_WAIT_TIMEOUT_SECS {
            return Err(match last_err {
                Some(source) => AdapterError::RegistryUnavailable {
                    package: package.to_string(),
                    version: version.to_string(),
                    source,
                },
                None => AdapterError::IndexTimeout {
                    package: package.to_string(),
                    version: version.to_string(),
                    waited_secs: INDEX_WAIT_TIMEOUT_SECS,
                },
            });
        }
        ctx.clock.sleep(INDEX_POLL_INTERVAL);
    }
}

/// A publishable workspace member with its version and its intra-workspace
/// (non-dev) dependency names.
#[derive(Clone)]
struct Member {
    name: String,
    version: String,
    deps: Vec<String>,
}

/// The subset of `cargo metadata --format-version 1 --no-deps` output the
/// publish-order discovery reads.
#[derive(Deserialize)]
struct CargoMetadata {
    /// Every package in the metadata; with `--no-deps` these are the workspace
    /// members only.
    packages: Vec<MetaPackage>,
    /// The package ids that are workspace members (matched against
    /// [`MetaPackage::id`] to be exact regardless of the id string format).
    workspace_members: Vec<String>,
}

/// One package entry from `cargo metadata`.
#[derive(Deserialize)]
struct MetaPackage {
    name: String,
    version: String,
    id: String,
    #[serde(default)]
    dependencies: Vec<MetaDep>,
    /// `null`/absent ⇒ publishable to any registry; `[]` ⇒ `publish = false`;
    /// `["<registry>",…]` ⇒ publishable to a restricted set (still publishable).
    #[serde(default)]
    publish: Option<Vec<String>>,
}

/// One dependency entry from `cargo metadata`.
#[derive(Deserialize)]
struct MetaDep {
    name: String,
    /// `null` (normal), `"dev"`, or `"build"`. Only normal/build deps gate
    /// publish order; dev-deps never do.
    #[serde(default)]
    kind: Option<String>,
}