ossctl_core/release/target_id.rs
1//! Stable, per-target journal ids for a release plan's targets.
2//!
3//! The event-sourced journal (ADR-0003) keys every per-target fact —
4//! `dry_run` / `built` / `published` — by a short string id, and the coordinator,
5//! the resume reconciler, and the CLI's `RunCreated.targets` list must all derive
6//! that id **identically** or a resume looks up the wrong key (and, worst case,
7//! re-publishes an already-landed target). This module is the one place the id is
8//! derived, so those three callers cannot drift.
9//!
10//! ## Why not just the ecosystem string
11//!
12//! Historically a cut carried at most one target per ecosystem (the normalizer
13//! expanded `ecosystems` 1:1), so the ecosystem wire string (`rust`, `node`, …)
14//! was itself a unique key. A contract may now declare **several** targets in one
15//! ecosystem — e.g. `ossctl`'s own two crates.io crates (`ossctl-core` then
16//! `ossctl`), plus a `gh-releases` and a `homebrew` target all under `rust` — so
17//! the ecosystem alone collides. [`journal_target_ids`] disambiguates only as far
18//! as it must: a lone-in-its-ecosystem target keeps the bare ecosystem id (so
19//! single-target cuts, and every existing journal, are byte-for-byte unchanged);
20//! an ecosystem with several targets qualifies each with the least of
21//! `package` → `package:registry` → `package:registry:adapter` that makes the
22//! group's ids distinct.
23//!
24//! ## Determinism (and its coupling to `plan_id`)
25//!
26//! The ids are a pure function of the target list (which is itself the
27//! normalizer's canonical order), computed through a `BTreeMap` group scan — no
28//! wall-clock, no `HashMap` iteration — so the same plan always yields the same
29//! ids, in the same positions. The ids are journal keys only; they are **not**
30//! part of the content-addressed `plan_id` (that hashes the target *fields*), so
31//! this derivation never affects plan identity or drift detection.
32//!
33//! That exclusion is load-bearing for resume safety, and it holds only because
34//! this derivation reads **exactly** the target fields (`ecosystem`, `package`,
35//! `registry`, `adapter`, and their order) that `plan_id` also seals
36//! ([`crate::release::plan`]'s `SealInput`). The coordinator writes the journal
37//! keyed by these ids and resume re-derives them from the (drift-checked) plan; a
38//! matching `plan_id` therefore guarantees byte-identical ids, so resume looks up
39//! the same receipt the cut wrote and never re-publishes a landed target. If a
40//! future edit made this function read a field `plan_id` does *not* seal (or vice
41//! versa), two plans could share a `plan_id` yet key their journals differently —
42//! a silent re-publish hazard. Keep the two field sets in lockstep, and bump
43//! [`crate::release::plan`]'s `SEAL_VERSION` if the covered fields change.
44//!
45//! ## Id stability across contract edits (a documented non-guarantee)
46//!
47//! A target's id is stable for a given plan, **not** across contract revisions.
48//! Adding a *second* target to an ecosystem that previously had one flips the
49//! first target's id on the next cut from the bare `"rust"` to a qualified
50//! `"rust:<disc>"`. Old runs' journals keep their `"rust"` keys forever (they are
51//! never rewritten); only new runs use the qualified form. Downstream consumers
52//! (`release show --json`, dashboards, log queries) must therefore not assume a
53//! per-target journal id is stable across contract edits.
54
55use std::collections::{BTreeMap, BTreeSet};
56
57use crate::protocol::plan::PlanTarget;
58
59/// The qualification levels a same-ecosystem group is disambiguated through, in
60/// increasing verbosity. `package` alone suffices for the common multi-crate case
61/// (two crates.io crates); `registry` separates same-package channels
62/// (`crates.io` vs `gh-releases` vs `homebrew` for one crate); `adapter` is the
63/// last resort before two targets are genuinely identical.
64const MAX_LEVEL: u8 = 3;
65
66/// Assign a stable, unique journal id to each target in `targets`, returned
67/// positionally aligned with the input.
68///
69/// A target that is the only one in its ecosystem gets the bare ecosystem wire
70/// string (`"rust"`); an ecosystem carrying several targets gets each of them
71/// `"<ecosystem>:<discriminator>"`, where the discriminator is the shortest of
72/// `package` / `package:registry` / `package:registry:adapter` that is distinct
73/// across that ecosystem's targets.
74///
75/// If two targets are *byte-identical* (same ecosystem, package, registry, and
76/// adapter) their ids still collide even at the fullest qualification — a
77/// degenerate duplicate the caller ([`crate::release::coordinator::validate_plan`])
78/// rejects rather than papering over. Ids across *different* ecosystems never
79/// collide (the ecosystem prefix differs), and a bare-ecosystem id never equals a
80/// qualified `"<ecosystem>:…"` id.
81#[must_use]
82pub fn journal_target_ids(targets: &[PlanTarget]) -> Vec<String> {
83 // Group target indices by ecosystem (BTreeMap keeps the scan deterministic).
84 let mut groups: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
85 for (i, t) in targets.iter().enumerate() {
86 groups.entry(t.ecosystem.as_str()).or_default().push(i);
87 }
88
89 let mut ids = vec![String::new(); targets.len()];
90 for (eco, idxs) in &groups {
91 if idxs.len() == 1 {
92 // Lone target: the ecosystem string is already a unique key (and keeps
93 // single-target cuts identical to how they journalled before).
94 ids[idxs[0]] = (*eco).to_string();
95 continue;
96 }
97 let level = minimal_level(targets, idxs);
98 for &i in idxs {
99 ids[i] = format!("{eco}:{}", discriminator(&targets[i], level));
100 }
101 }
102 ids
103}
104
105/// The least qualification level (`1..=`[`MAX_LEVEL`]) at which every target in
106/// `idxs` has a distinct [`discriminator`]. Falls back to [`MAX_LEVEL`] when even
107/// the fullest form collides (two identical targets) — the caller detects the
108/// resulting duplicate id and refuses the plan.
109fn minimal_level(targets: &[PlanTarget], idxs: &[usize]) -> u8 {
110 // Inclusive of `MAX_LEVEL`: the fullest form (`package:registry:adapter`) is a
111 // real candidate that separates same-package/same-registry channels that differ
112 // only by adapter — it is not merely an untested fallback.
113 for level in 1..=MAX_LEVEL {
114 // A `BTreeSet` keeps the membership test O(log n) without introducing any
115 // ordering-unstable iteration (we read only `insert`'s bool, never iterate).
116 let mut seen = BTreeSet::new();
117 if idxs
118 .iter()
119 .all(|&i| seen.insert(discriminator(&targets[i], level)))
120 {
121 return level;
122 }
123 }
124 MAX_LEVEL
125}
126
127/// The `level`-deep discriminator for one target: `package`, then
128/// `package:registry`, then `package:registry:adapter`. A target with no resolved
129/// package name contributes an empty package segment (an unresolved target is not
130/// executable anyway — the coordinator refuses it before any external action).
131fn discriminator(target: &PlanTarget, level: u8) -> String {
132 let package = target.package.as_deref().unwrap_or("");
133 match level {
134 1 => package.to_string(),
135 2 => format!("{package}:{}", target.registry.as_str()),
136 _ => format!(
137 "{package}:{}:{}",
138 target.registry.as_str(),
139 target.adapter.as_str()
140 ),
141 }
142}
143
144#[cfg(test)]
145mod tests;