Skip to main content

onevcs_testing/
state.rs

1//! What a provider knows, in a shape a journey can write down and read back.
2//!
3//! One state type per interface, shared by both flavours of it — the in-memory
4//! provider and the file-backed one differ in where the state lives and in nothing
5//! else, so a scenario seeded for one is the same scenario for the other.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10
11use onevcs::rules::{Approvals, Drafts};
12use onevcs::{
13    ChangeId, ChangeRequest, Check, CheckSource, DraftReason, Error, Identity, Landed,
14    MergeOutcome, Recoverable, Result,
15};
16use onevcs::{MergePolicy, Publication, RequiredChecks, Session, SessionRequest, SessionToken};
17
18use crate::events;
19use crate::store::Checked;
20
21/// The version of the state document this build writes.
22///
23/// A file-backed state outlives the process that wrote it and is read by the next
24/// one, which makes it a stored contract like `onevcs`'s own registry document —
25/// and like that document, the version a document declares decides what is done
26/// with it rather than being guessed at. [`STATE_VERSION`] is the shape the goldens in
27/// `tests/golden/` hold, and those goldens are compared byte for byte, so a change
28/// to the document cannot reach a consumer without the diff saying so.
29///
30/// `2` was what both sides learned when publishing and closing a session came
31/// through the interface: [`VcsState::policy`], [`VcsState::closed_sessions`],
32/// [`VcsState::publications`], and [`HostState::titles`]. `3` is
33/// [`HostState::bodies`], the body a change request was opened with. `4` is the two
34/// fields a `Recoverable` gained inside [`VcsState::preserved`] — the live session
35/// holding a branch, and the lines it would land when it removes more than it adds.
36/// `5` is one more of those: whether the branch's work reached its base, and what
37/// says so. `6` is the two a `Check` gained inside [`HostState::checks`] — the
38/// commit the host attached that check to, and where the check is on the host. `7`
39/// is the two a draft change request needs — [`HostState::drafts`], the reason each
40/// change request was opened as a draft with, and [`HostState::made_ready`], the
41/// `ready_for_review` calls it took. `8` is the two an [`Identity`] inside
42/// [`VcsState::identities`] **lost** — `workflow` and `repo_type`, the registry's
43/// inferences from whether an origin had a host, which every decision they made now
44/// takes from the resolved publication policy instead. `9` is the draft reason inside
45/// [`HostState::drafts`] becoming one of two kinds and saying which — `kind` is
46/// `awaiting-release` or `held` — and [`HostState::described`], the descriptions a
47/// host was handed for a change request after it was opened. `10` is the failure
48/// vocabulary a publication's outcome inside [`VcsState::publications`] may name
49/// widening by one kind, `host-prerequisite` — no field moved, but a document naming
50/// it is one a version 9 build cannot read, and the version is how it says so. `11` is
51/// one more field a `Recoverable` gained inside [`VcsState::preserved`], the same way
52/// `4` and `5` were: where `onevcs preserve` put that branch on its identity's origin,
53/// and the commit the origin carries it at. `12` is the labels a session is opened
54/// with — [`VcsState::session_labels`], keyed by token the way its identity is — and
55/// the two fields a `Recoverable` gained inside [`VcsState::preserved`] to carry them:
56/// the session that answers for the row, and that session's labels. `13` is one more
57/// field a `Recoverable` gained there: the branch's retirement classification — whether
58/// it provably holds nothing beyond its base, was superseded by a retry that landed, or
59/// is kept, and the evidence for it. No provider here classifies a row, so a row this
60/// crate writes carries none; a scenario may seed one. `14` is the draft lifecycle:
61/// [`HostState::awaiting_checks`], the change requests opened as a draft while their
62/// required checks run, [`HostState::checks_after_lift`], the checks a host registers
63/// only once a change is lifted, and [`HostState::required_checks`], what the host
64/// says a merge into a base requires — beside [`VcsState::approvals`] and
65/// [`VcsState::drafts`], the two policy fields a provider's publication now routes
66/// the lifecycle on. A publication outcome inside [`VcsState::publications`] may also
67/// be `change-review-draft`. `15` is the field a `Session` gained inside
68/// [`VcsState::sessions`]: `conflict`, the merge a continued branch's session opened
69/// over and left in progress. No provider here merges a base into a branch, so a
70/// session this crate opens carries none; a scenario may seed one. `16` is the failure
71/// vocabulary a publication's outcome inside [`VcsState::publications`] may name
72/// widening by one kind, `cancelled`, the way `10` widened it — no field moved, but a
73/// document naming it is one a version 15 build cannot read. No provider here waits on
74/// anything, so a publication this crate runs is never cancelled; a scenario may seed
75/// one.
76///
77/// **Every change to the document is versioned, an added field included.** A field
78/// that only ever appears when it holds something is *compatible* — that is what
79/// [`OLDEST_READABLE_VERSION`] is for — but compatible is not the same as
80/// unremarkable: the version is how a consumer's checked-in scenario says which
81/// shape it was written against, and a document that gained a field without saying
82/// so leaves nothing able to tell "this build wrote no body" from "this document
83/// predates bodies". The two answers differ for exactly the journey this crate
84/// exists to support.
85pub const STATE_VERSION: u32 = 16;
86
87/// The oldest document version this build reads.
88///
89/// Every version from here to [`STATE_VERSION`] is read and carried forward on the
90/// way in, which is how `onevcs`'s own registry document treats its older
91/// versions: what separates a readable older version from a refused one is whether
92/// its every field still means here what it meant there. `2` to `3` added a field
93/// and changed none, so a version 2 document reads as one whose change requests
94/// were opened with no body — which is what they were; `3` to `4` added two the same
95/// way, so a version 3 document reads as one whose preserved rows say nothing about a
96/// hold or a line count, which is what they said. `4` to `5` added one that every row
97/// answers rather than one that appears when it holds something, so a version 4
98/// document's rows are carried forward as the answer that list *was*: preserved work
99/// nobody published, which is work that did not land. `5` to `6` added two that
100/// appear only when they hold something, so a version 5 document's checks read as
101/// checks whose commit and address that build never recorded — which is what they
102/// were, and the one thing that must not be filled in from the change request's own
103/// head. `6` to `7` added two more of that kind, so a version 6 document reads as one
104/// whose change requests were opened before this crate could draft one — which is what
105/// they were: no draft reason recorded for any of them, and no lift ever asked for.
106/// `7` to `8` *removed* two from each identity, and a version 7 document reads past
107/// them: an identity is its origin and its gate here as it is in `onevcs`, and the two
108/// keys beside them are ones this build has no opinion on. `8` to `9` gave every
109/// draft reason a `kind`, and a reason written without one is read as the one kind
110/// there was — `awaiting-release`, whose four fields it carries unchanged — which is
111/// `drafts_of_any_version`'s whole job; the descriptions beside it are absent from a
112/// document written before them, which is the answer, since that build could describe
113/// nothing. `9` to `10` changed no field and no meaning: every failure kind a version 9
114/// document can hold is spelled here as it was there and means what it meant, so it
115/// reads unchanged and needs no carrying forward beyond its version. `10` to `11` added
116/// one that appears only when it holds something, so a version 10 document's preserved
117/// rows read as rows saying nothing about an origin — which is what they said, since
118/// that build had no verb that could put a branch on one. `11` to `12` added the
119/// labels, which appear only where a session was opened with some, and the two row
120/// fields that carry them, which a version 11 row reads as absent: a session nobody
121/// labelled, and a row that names no session — which is what that build's rows said,
122/// since it recorded neither. `12` to `13` added the row's retirement, which appears only
123/// where a row holds one, so a version 12 row reads as one nothing classified — which
124/// is what it was, since that build had no classification to record. `13` to `14`
125/// added fields that appear only when they hold something, so a version 13 document
126/// reads as one whose change requests were opened before the lifecycle drafted any,
127/// whose checks never change on a lift, whose host declares its required checks by
128/// the checks it was seeded with, and whose policy leaves approvals and drafts at
129/// their defaults — which is what that build published under. `14` to `15` added the
130/// session's conflict, which appears only where a session holds one, so a version 14
131/// document's sessions read as sessions that opened clean — which is what they were,
132/// since that build opened none over a merge. `15` to `16` changed no field and no
133/// meaning, as `9` to `10` did not: every failure kind a version 15 document can hold is
134/// spelled here as it was there, so it reads unchanged.
135///
136/// `1` is refused rather than read for the opposite reason: it describes a provider
137/// that could not publish, and every session in it would read back as open — a
138/// wrong answer where a journey asserting on a session it had closed needs a
139/// refusal.
140pub const OLDEST_READABLE_VERSION: u32 = 2;
141
142/// Everything the repository side of a run knows about itself.
143///
144/// Every field is public and serializable, so a journey both seeds a scenario and
145/// asserts on what a run left behind. Everything but the version is omitted when
146/// it holds nothing, so a hand-written document names only the part of a scenario
147/// that matters — and a document written by a build that knew fewer fields still
148/// reads here.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(default)]
151pub struct VcsState {
152    /// The schema version this state was written at. A document that names none is
153    /// the one this build writes.
154    // llmlint: ignore[boundary_inputs_validated] deciding what to do with a version this
155    // build does not read is the whole of the check — and it is in `Checked::check` below,
156    // where a document is read, rather than here where serde only proves the shape.
157    pub version: u32,
158    /// The repository identities this provider can resolve. A
159    /// [`SessionRequest::repo`] naming none of them is refused, the way an
160    /// unregistered repository is.
161    #[serde(skip_serializing_if = "Vec::is_empty")]
162    pub identities: Vec<Identity>,
163    /// Every session opened or seeded, in the order they were opened.
164    #[serde(skip_serializing_if = "Vec::is_empty")]
165    pub sessions: Vec<Session>,
166    /// Which identity each session belongs to.
167    ///
168    /// Beyond the sketch this crate was specified from, and unavoidable: a
169    /// [`Session`] carries no identity, and a [`Recoverable`] must name one — so
170    /// preserving a session's branch could not answer the question `recoverable`
171    /// asks without this. `open_session` records it; nothing else writes it.
172    // llmlint: ignore[invalid_states_unrepresentable] an identity key is a `String`
173    // everywhere the crate this mirrors spells one — `Recoverable.identity`,
174    // `Identity.origin`, the registry document's own map key — and a newtype here would
175    // make a seeded state disagree with the types it is made of. Every value written to
176    // this map came out of `identity_of`, so it names an identity this provider holds.
177    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
178    pub session_identities: BTreeMap<SessionToken, String>,
179    /// The labels each session was opened with, for the sessions opened with any.
180    ///
181    /// Keyed by token the way [`session_identities`](Self::session_identities) is,
182    /// and for the same reason: a [`Session`] carries no labels, and both `session
183    /// holders` and every `recoverable` row report them. `open_session` records
184    /// them; nothing else writes it.
185    // llmlint: ignore[invalid_states_unrepresentable] keyed by session token, exactly as
186    // `session_identities` above is and for the same reason it gives.
187    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
188    pub session_labels: BTreeMap<SessionToken, BTreeMap<String, String>>,
189    /// Preserved work, newest last, as `recoverable` reports it.
190    ///
191    /// [`Recoverable`] rather than `PreservedBranch` — it *contains* the preserved
192    /// branch and adds the identity, the checkout, why the workstream stopped, and
193    /// the command that lands it, none of which are derivable from the branch
194    /// alone. One list rather than two that could disagree.
195    #[serde(skip_serializing_if = "Vec::is_empty")]
196    pub preserved: Vec<Recoverable>,
197    /// The sessions that have been closed. Every other session here is open.
198    ///
199    /// One way to say closed rather than two, so a scenario written by hand names
200    /// only the sessions whose lifecycle is not the one they were opened in.
201    // llmlint: ignore[invalid_states_unrepresentable] keyed by session token, exactly as
202    // `session_identities` above is and for the same reason: this document is a scenario
203    // somebody writes by hand, and a session is named once under `sessions` with the rest
204    // of the state keyed to it rather than nested inside a shape that could hold only one
205    // arrangement. A token here that names no opened session is refused in
206    // `Checked::check`, where the document is read — the same trust boundary every other
207    // cross-reference in it is checked at.
208    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
209    pub closed_sessions: BTreeSet<SessionToken>,
210    /// The policy this provider publishes under.
211    ///
212    /// The answer a rules file gives the real implementation, which a provider has
213    /// none of — so a journey states it, and unset is the policy the contract's own
214    /// `default:` names ([`DEFAULT_PUBLICATION`](crate::DEFAULT_PUBLICATION)). A
215    /// per-run policy narrows it through [`MergePolicy::narrow`], which is the
216    /// rules system's rule rather than a restatement of it here.
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub policy: Option<MergePolicy>,
219    /// Whether this provider's changes need an approval, which is the other half of
220    /// what decides a green draft's ending: `change-open` with approvals required
221    /// keeps it for its user's review. Unset is the contract's own `default:`,
222    /// [`DEFAULT_APPROVALS`](crate::DEFAULT_APPROVALS).
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub approvals: Option<Approvals>,
225    /// The `drafts:` a rules file would give this provider's identity. Unset, and
226    /// each key it leaves unset, is the shipped default: the lifecycle on and the
227    /// early-lift warning on.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub drafts: Option<Drafts>,
230    /// Every publication this provider performed, in the order it performed them.
231    ///
232    /// Both a record a journey asserts on and the answer to "has this session been
233    /// published already": a second publication of a session that landed has
234    /// nothing the base does not already carry, which is what the real
235    /// implementation reports for the same reason.
236    // llmlint: ignore[invalid_states_unrepresentable] this holds `onevcs::Publication`
237    // verbatim — the value `Vcs::publish` handed back, carrying its own session and branch
238    // — so a journey asserts on exactly what a caller would receive. A shape that made
239    // "this publication is of some other session's branch" unrepresentable could not hold
240    // that type, and would be a second spelling of the answer the crate next door already
241    // has. The cross-reference is checked in `Checked::check` instead, where the document
242    // is read.
243    #[serde(skip_serializing_if = "Vec::is_empty")]
244    pub publications: Vec<Publication>,
245}
246
247/// A repository side that knows nothing, at the version this build writes.
248impl Default for VcsState {
249    fn default() -> Self {
250        Self {
251            version: STATE_VERSION,
252            identities: Vec::new(),
253            sessions: Vec::new(),
254            session_identities: BTreeMap::new(),
255            session_labels: BTreeMap::new(),
256            preserved: Vec::new(),
257            closed_sessions: BTreeSet::new(),
258            policy: None,
259            approvals: None,
260            drafts: None,
261            publications: Vec::new(),
262        }
263    }
264}
265
266/// Everything the remote-host side of a run knows about itself.
267///
268/// Omitted-when-empty and versioned for the same reasons [`VcsState`] is.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(default)]
271pub struct HostState {
272    /// The schema version this state was written at. A document that names none is
273    /// the one this build writes.
274    // llmlint: ignore[boundary_inputs_validated] as on `VcsState::version`: the decision
275    // about an unreadable version is made in `Checked::check`, where the document is read.
276    pub version: u32,
277    /// Who the host says is calling. Empty is refused, exactly as a `gh` that
278    /// reports no authenticated user is.
279    // llmlint: ignore[invalid_states_unrepresentable] the interface this satisfies is
280    // `authenticated_user() -> Result<String>`, so the login is a `String` by contract and
281    // the one unusable value — a host that names nobody — is refused where it is read
282    // rather than made unrepresentable in a state a journey writes by hand.
283    pub authenticated_user: String,
284    /// Every change request that has been opened or seeded.
285    #[serde(skip_serializing_if = "Vec::is_empty")]
286    pub changes: Vec<ChangeRequest>,
287    /// The head branch each change request was opened from.
288    ///
289    /// Beyond the sketch, and unavoidable: [`ChangeRequest`] records only the base
290    /// it targets, and `find_changes` matches on the head as well.
291    // llmlint: ignore[invalid_states_unrepresentable] the matching `ChangeSpec.head` and
292    // `ChangeRequest.base` are `String` in the contract this mirrors, and a validated ref
293    // type here would disagree with them. Every value written to this map went through
294    // `addressable` in `open_change` first, which is the same refusal the real
295    // implementation makes at the same point.
296    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
297    pub heads: BTreeMap<ChangeId, String>,
298    /// The title each change request was opened under.
299    ///
300    /// Beyond the sketch, and for the same reason [`heads`](HostState::heads) is:
301    /// [`ChangeRequest`] records neither, and the title is what a publication's
302    /// commit subject becomes — so a journey asserting that the subject it asked
303    /// for is the one the host was given has nowhere else to read it.
304    // llmlint: ignore[invalid_states_unrepresentable] this records the `ChangeSpec.title`
305    // the contract fixes as a `String`, so a validated type here would disagree with the
306    // one it mirrors. `Subject` is not that type: it is `onevcs`'s rule for a *commit
307    // subject*, and a host's own limit is its own — spelling it here would
308    // refuse a seeded title a real host accepts, which is drift in the direction that
309    // looks like rigour. What the host itself refuses is a title that names nothing, and
310    // that is refused below and in `open_change`, at the boundary the value arrives at.
311    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
312    pub titles: BTreeMap<ChangeId, String>,
313    /// The body each change request was opened with, for the change requests that
314    /// were opened with one.
315    ///
316    /// Beside [`titles`](HostState::titles) and for its reason: the body is what a
317    /// caller passes on the `PublishRequest` and nothing else records it, so a
318    /// journey asserting that the body it drafted is the one the host was given has
319    /// nowhere else to read it. A change request with no entry was opened with no
320    /// body at all, which is what a publication nobody gave one does — the two are
321    /// different scenarios, and an empty string is the first rather than the second.
322    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
323    pub bodies: BTreeMap<ChangeId, String>,
324    /// The checks the host reports on each change request. A change with no entry
325    /// has no checks, which is what a repository with no CI reports.
326    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
327    pub checks: BTreeMap<ChangeId, Vec<Check>>,
328    /// The log the host hands over for a check, keyed by change request and then by
329    /// check name. Beyond the sketch: `check_log` is one of the six methods, and
330    /// without this the only log a journey could asssert on is a synthesized one.
331    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
332    pub check_logs: BTreeMap<ChangeId, BTreeMap<String, String>>,
333    /// The reason each change request that was opened as a **draft** was drafted
334    /// with, for the change requests that were.
335    ///
336    /// Beside [`bodies`](HostState::bodies) and for its reason: the reason is what a
337    /// caller passes on the `PublishRequest` and this is where a journey reads back
338    /// that the host was given it. A change with no entry is not a draft, which is
339    /// what every change request opened without one is.
340    ///
341    /// Nothing is refused about the reason itself here. Its shape — that every field
342    /// of it renders as the one line it is printed on — is `onevcs`'s own rule, and
343    /// it is applied where a publication takes one in, before any host is asked.
344    #[serde(
345        skip_serializing_if = "BTreeMap::is_empty",
346        deserialize_with = "drafts_of_any_version"
347    )]
348    pub drafts: BTreeMap<ChangeId, DraftReason>,
349    /// Every `ready_for_review` call this host has taken, in the order it took them
350    /// — the lifts it was asked to perform, never a request for a *reviewer*.
351    ///
352    /// A list rather than a set, and beyond the sketch for a reason a set could not
353    /// serve: lifting a draft is required to be *idempotent*, so what a journey
354    /// asserts is that a second publication asked the host for nothing — and only a
355    /// record of the calls themselves can say that. A change named here is not a
356    /// draft any more, whatever [`drafts`](HostState::drafts) says it was opened as.
357    #[serde(skip_serializing_if = "Vec::is_empty")]
358    pub made_ready: Vec<ChangeId>,
359    /// The change requests opened as a draft **while their required checks run** —
360    /// the lifecycle's draft, which no caller gave a reason for and so is not in
361    /// [`drafts`](HostState::drafts). A change named here is a draft until
362    /// [`made_ready`](HostState::made_ready) names it.
363    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
364    pub awaiting_checks: BTreeSet<ChangeId>,
365    /// The checks the host reports on a change request once it has been lifted out of
366    /// its draft, in place of [`checks`](HostState::checks).
367    ///
368    /// What a repository whose workflow skips drafts looks like from outside: the
369    /// draft's checks are skipped or absent, and the run the lift triggers registers
370    /// after it. A change with no entry reports the same checks either side of a lift.
371    ///
372    /// A publication tells the run a lift triggered from the draft's by the run's own
373    /// identity, as it would on a real host: a check named here that the draft also
374    /// reported is a new run — running or settled — only where its
375    /// [`started_at`](Check::started_at) is one no draft-era run of that check reported. So seed a re-run with a start of its own — an entry repeating
376    /// the draft's start, or reporting none, is the draft's run still standing.
377    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
378    pub checks_after_lift: BTreeMap<ChangeId, Vec<Check>>,
379    /// What this host answers when asked which checks a merge into a base requires.
380    ///
381    /// Unset is a host whose branch protection names exactly the checks it was seeded
382    /// as required, on any change request — a complete answer, and an empty one where
383    /// none is seeded required. Seeded, it is the answer for every base: an empty
384    /// complete one is a repository declaring no required check, and one naming an
385    /// `unconsulted` source is the incomplete answer a narrower credential gets.
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub required_checks: Option<RequiredChecks>,
388    /// Every `describe_change` call this host has taken, in the order it took them:
389    /// which change request, the title it was handed when it was handed one, and the
390    /// body.
391    ///
392    /// The calls themselves, for the reason [`made_ready`](HostState::made_ready) is:
393    /// what a journey asks is what a closeout *wrote*, call by call, and the current
394    /// title and body — which each call also writes into
395    /// [`titles`](HostState::titles) and [`bodies`](HostState::bodies), so
396    /// `change_description` answers from the same record `open_change` wrote — can
397    /// only say where the description ended up.
398    #[serde(skip_serializing_if = "Vec::is_empty")]
399    pub described: Vec<Described>,
400    /// Which sources this host answers about its checks from, which is what the
401    /// real implementation reports alongside them.
402    ///
403    /// Unset is a credential allowed to read everything: the whole rollup, exactly
404    /// what a host with nothing to hide reports. A journey states a narrower set to
405    /// be the credential the real one meets in CI — a fine-grained token, which
406    /// cannot read check runs at all and sees GitHub Actions and nothing else — and
407    /// states an *empty* one to be a credential that can read no source at all,
408    /// which is a refusal rather than "no checks". Unset and empty are therefore
409    /// different scenarios, which is why this is an `Option` rather than a set whose
410    /// emptiness means "not stated".
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub check_sources: Option<BTreeSet<CheckSource>>,
413    /// What merging each change request did.
414    ///
415    /// Both a script and a record: an entry seeded here is what `merge` answers,
416    /// whatever the policy asks for — which is how a journey expresses a host that
417    /// queues or refuses — and a merge the policy decided is written back here.
418    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
419    pub merges: BTreeMap<ChangeId, MergeOutcome>,
420}
421
422/// One description a host was handed for a change request after it was opened.
423///
424/// What `RemoteHost::describe_change` was given, verbatim: the title only where the
425/// call carried one, because a call that replaced the body alone left the title as
426/// it was, and a record that filled it in would say the caller wrote a title nobody
427/// wrote.
428// llmlint: ignore[invalid_states_unrepresentable] the title and body are the `&str`s the
429// seam hands over — a host places no shape on prose, and `onevcs`'s own `Subject` rule
430// was applied where the description was composed, before any host was asked.
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
432pub struct Described {
433    /// The change request the description was written to.
434    pub id: ChangeId,
435    /// The title the call replaced the change request's with, when it replaced one.
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub title: Option<String>,
438    /// The body the call replaced the change request's with.
439    pub body: String,
440}
441
442/// The `drafts` map of a document at any readable version.
443///
444/// A document written before version 9 spells a draft reason as the four fields of
445/// the one kind there was and no `kind` beside them; this build's reason is one of
446/// two kinds and says which. So a reason arriving without a `kind` is read as
447/// `awaiting-release` — exactly what it was — rather than refused, which is what
448/// keeps a consumer's checked-in scenario readable across the bump. A reason that
449/// *names* a kind is read as it names it, so nothing written by this build is
450/// reinterpreted.
451fn drafts_of_any_version<'de, D>(
452    deserializer: D,
453) -> std::result::Result<BTreeMap<ChangeId, DraftReason>, D::Error>
454where
455    D: serde::Deserializer<'de>,
456{
457    let raw: BTreeMap<ChangeId, serde_json::Map<String, serde_json::Value>> =
458        Deserialize::deserialize(deserializer)?;
459    raw.into_iter()
460        .map(|(id, mut fields)| {
461            fields
462                .entry("kind")
463                .or_insert_with(|| serde_json::Value::from("awaiting-release"));
464            serde_json::from_value::<DraftReason>(serde_json::Value::Object(fields))
465                .map(|reason| (id, reason))
466                .map_err(serde::de::Error::custom)
467        })
468        .collect()
469}
470
471/// Who a host with nothing seeded says is calling.
472///
473/// A host that answers nobody is refused by the real implementation, so a default
474/// state that answered nobody would be a provider that cannot run a publication
475/// until it is configured.
476pub const DEFAULT_AUTHENTICATED_USER: &str = "onevcs-testing";
477
478impl Default for HostState {
479    fn default() -> Self {
480        Self {
481            version: STATE_VERSION,
482            authenticated_user: DEFAULT_AUTHENTICATED_USER.to_owned(),
483            changes: Vec::new(),
484            heads: BTreeMap::new(),
485            titles: BTreeMap::new(),
486            bodies: BTreeMap::new(),
487            drafts: BTreeMap::new(),
488            made_ready: Vec::new(),
489            awaiting_checks: BTreeSet::new(),
490            checks_after_lift: BTreeMap::new(),
491            required_checks: None,
492            described: Vec::new(),
493            checks: BTreeMap::new(),
494            check_logs: BTreeMap::new(),
495            check_sources: None,
496            merges: BTreeMap::new(),
497        }
498    }
499}
500
501/// The identity a session request names, or the reason none of them is it.
502///
503/// Three ways to name one, mirroring what the registry accepts: the identity key
504/// itself, the `owner/name` tail of it, or the bare repository name.
505pub(crate) fn identity_of<'a>(state: &'a VcsState, origin_or_path: &str) -> Option<&'a Identity> {
506    let wanted = origin_or_path.trim_end_matches('/');
507    state
508        .identities
509        .iter()
510        .find(|identity| identity.origin == wanted)
511        .or_else(|| {
512            state.identities.iter().find(|identity| {
513                identity
514                    .origin
515                    .rsplit('/')
516                    .next()
517                    .is_some_and(|name| name == wanted)
518                    || identity.origin.ends_with(&format!("/{wanted}"))
519            })
520        })
521}
522
523/// The known identities, as a refusal names them.
524pub(crate) fn known(state: &VcsState) -> String {
525    if state.identities.is_empty() {
526        return "this provider was seeded with no identities".to_owned();
527    }
528    let names: Vec<&str> = state
529        .identities
530        .iter()
531        .map(|identity| identity.origin.as_str())
532        .collect();
533    format!("it knows {}", names.join(", "))
534}
535
536/// The identity a session belongs to, or the reason this provider cannot say.
537///
538/// `open_session` records it and nothing else writes it, so a session that arrived
539/// in a hand-written scenario without one is refused here rather than published
540/// against a repository nobody named.
541pub(crate) fn identity_for(state: &VcsState, token: &SessionToken) -> Result<String> {
542    state
543        .session_identities
544        .get(token)
545        .cloned()
546        .ok_or_else(|| Error::Invalid {
547            reason: format!(
548                "this provider has no record of session {:?}, so it cannot say which identity \
549                 its work belongs to",
550                token.0
551            ),
552        })
553}
554
555/// The session a token names.
556pub(crate) fn session_of<'a>(state: &'a VcsState, token: &SessionToken) -> Option<&'a Session> {
557    state
558        .sessions
559        .iter()
560        .find(|session| session.token == *token)
561}
562
563/// The branch a request asks for, or the one that is derived from the token.
564///
565/// `branch` and `branch_name` are two answers to one question — *continue this* and
566/// *cut one at this* — so a request naming both is refused here as the real
567/// implementation refuses it: that refusal is about the shape of the request rather
568/// than about anything a repository does, and a consumer proving it must meet the
569/// same answer on either side.
570///
571/// **What a provider deliberately does not do to `branch_name` is the rest**, and it
572/// is not approximated: the host's branch prefix is read out of an `onevcs` state
573/// root no provider has, and the first-free suffix is searched against an identity's
574/// checkouts and its origin, which a provider has neither of. So the name is taken as
575/// it was proposed and refused — naming it — where git would refuse it, rather than
576/// being sanitized by a second copy of a grammar that would then have to be held to
577/// the first. A consumer proving what the prefix, the sanitizer and the collision
578/// suffix do drives the real `onevcs`, which is where all three live.
579pub(crate) fn requested_branch(req: &SessionRequest, token: &SessionToken) -> Result<String> {
580    if let (Some(pinned), Some(proposed)) = (&req.branch, &req.branch_name) {
581        return Err(Error::Invalid {
582            reason: format!(
583                "this request names both a branch to continue ({pinned:?}) and a name to cut \
584                 ({proposed:?}), which are two answers to one question"
585            ),
586        });
587    }
588    let name = req
589        .branch
590        .clone()
591        .or_else(|| req.branch_name.clone())
592        .unwrap_or_else(|| format!("onevcs/{}", token.0));
593    named_branch(&name, "the branch")?;
594    Ok(name)
595}
596
597/// A branch name, refused here if git would refuse it.
598///
599/// The real implementation asks `git check-ref-format`, which is the parser that
600/// decides; a provider with no git carries `git-check-ref-format(1)`'s rules
601/// instead. That is a restatement, so it is gated rather than trusted:
602/// `refs.rs` in the suite runs both this and git itself over a table of names
603/// and holds them to each other, because a copy of somebody else's grammar with
604/// no gate is a copy that drifts.
605///
606/// One deliberate difference, and the gate knows about it: a leading `-` is
607/// refused here even though git accepts it as a ref, because such a name reaches
608/// a command line as an option rather than as the branch it spells.
609pub(crate) fn named_branch(value: &str, what: &str) -> Result<()> {
610    // Rule 1 is per slash-separated component; the rest are about the whole name.
611    let components_usable = !value.is_empty()
612        && value.split('/').all(|component| {
613            !component.is_empty() && !component.starts_with('.') && !component.ends_with(".lock")
614        });
615    let usable = components_usable
616        && !value.starts_with('-')
617        && !value.contains("..")
618        && !value.contains("@{")
619        && !value.ends_with('.')
620        && !value.ends_with('/')
621        && !value.chars().any(|c| {
622            c.is_whitespace() || c.is_ascii_control() || c == '\u{7f}' || "~^:?*[\\".contains(c)
623        });
624    if !usable {
625        return Err(Error::Invalid {
626            reason: format!("{what} {value:?} is a name git would not accept"),
627        });
628    }
629    Ok(())
630}
631
632/// A label, refused here where the real implementation refuses one: a key is one or
633/// more of `A-Z`, `a-z`, `0-9`, `_` and `-`, and a value is one line. The same rule
634/// spelled twice on purpose, for the reason [`named_branch`] gives — a provider with
635/// no `onevcs` state root carries the grammar, and the journeys over both providers
636/// hold the two to each other.
637pub(crate) fn label_pair(key: &str, value: &str) -> Result<()> {
638    let key_usable = !key.is_empty()
639        && key
640            .chars()
641            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
642    if !key_usable {
643        return Err(Error::Invalid {
644            reason: format!(
645                "{key:?} is not a label key; a key is one or more of A-Z, a-z, 0-9, `_` and `-`"
646            ),
647        });
648    }
649    if value.contains('\n') {
650        return Err(Error::Invalid {
651            reason: format!(
652                "the value of label {key:?} holds a newline; a label's value is one line"
653            ),
654        });
655    }
656    Ok(())
657}
658
659/// A seeded repository side is refused if it holds a session nothing could act on.
660impl Checked for VcsState {
661    fn check(&self) -> Result<()> {
662        readable_version(self.version)?;
663        for session in &self.sessions {
664            // The token names a file under the state root, and a branch goes on to
665            // spell a ref; both arrive from whoever wrote the document.
666            if !events::is_safe_name(&session.token.0) {
667                return Err(Error::Invalid {
668                    reason: format!("{:?} is not a session token", session.token.0),
669                });
670            }
671            named_branch(&session.branch, "the branch")?;
672            named_branch(&session.base, "the base")?;
673        }
674        for row in &self.preserved {
675            known_identity(self, &row.identity, "preserved work")?;
676            named_branch(&row.branch.branch, "the preserved branch")?;
677            named_branch(&row.branch.base, "the preserved branch's base")?;
678            // The hold names a session, so it is checked like the three below: a row
679            // held by a session nobody opened is the same fiction, and the costly one
680            // — it is the session an operator is told to wait for or close.
681            if let Some(held) = &row.held_by {
682                opened(self, &held.token, "holding preserved work")?;
683            }
684        }
685        // Both of these name a session, so both are checked twice over: the token
686        // has to be a plain name, because it goes on to spell the file its stream is
687        // written in, and it has to name a session this state actually holds. A
688        // document that closes or publishes a session nobody opened describes a run
689        // that could not have happened, and answering `recoverable` or `session`
690        // from it would be answering from a fiction rather than refusing one.
691        for token in &self.closed_sessions {
692            opened(self, token, "closed")?;
693        }
694        for (token, labels) in &self.session_labels {
695            opened(self, token, "labelled")?;
696            for (key, value) in labels {
697                label_pair(key, value)?;
698            }
699        }
700        for (token, origin) in &self.session_identities {
701            opened(self, token, "given an identity")?;
702            // The value as well as the key: this is what `identity_for` answers with,
703            // and it goes on to spell the slug a change request is opened against and
704            // the label every one of that session's events carries. An identity this
705            // provider does not know is one it could not have opened the session for.
706            known_identity(self, origin, &format!("session {:?}", token.0))?;
707        }
708        for publication in &self.publications {
709            let session = opened(self, &publication.session, "published")?;
710            named_branch(&publication.branch, "the published branch")?;
711            // A publication of some other branch than the one the session is on is
712            // the same kind of fiction, and the harder one to spot afterwards: the
713            // branch is what a journey asserts the publication was of.
714            if publication.branch != session.branch {
715                return Err(Error::Invalid {
716                    reason: format!(
717                        "the publication of session {:?} names branch {:?}, but that session is \
718                         on {:?}",
719                        publication.session.0, publication.branch, session.branch
720                    ),
721                });
722            }
723        }
724        Ok(())
725    }
726
727    /// Every field an older readable document could hold means here what it meant
728    /// there, and the two the repository side gained in version 4 are absent from one
729    /// written before them — which is the answer, since that build held no hold and
730    /// counted no lines.
731    ///
732    /// Version 5's field is the exception, and it is carried rather than left at its
733    /// default: a document written before it *listed preserved work nobody had
734    /// published*, which is the `no` this build spells out. Left at the default it
735    /// would read as "nothing here can say", and a consumer's checked-in scenario
736    /// would start reporting work it had always called unpublished as work that may
737    /// have landed.
738    fn carry_forward(&mut self) {
739        if self.version < 5 {
740            for row in &mut self.preserved {
741                row.landed = Landed::No;
742            }
743        }
744        self.version = STATE_VERSION;
745    }
746}
747
748/// Refuse a record kept about a change request this state does not hold.
749fn opened_change(state: &HostState, id: &ChangeId, what: &str) -> Result<()> {
750    if state.changes.iter().any(|change| change.id == *id) {
751        return Ok(());
752    }
753    Err(Error::Invalid {
754        reason: format!(
755            "{what} is recorded for change request {:?}, but no change request by that \
756             identifier was opened",
757            id.0
758        ),
759    })
760}
761
762/// Refuse an identity key this provider was not seeded with.
763fn known_identity(state: &VcsState, origin: &str, what: &str) -> Result<()> {
764    if state
765        .identities
766        .iter()
767        .any(|identity| identity.origin == origin)
768    {
769        return Ok(());
770    }
771    Err(Error::Invalid {
772        reason: format!(
773            "{what} belongs to identity {origin:?}, which this provider does not know; {}",
774            known(state)
775        ),
776    })
777}
778
779/// A change request's title, refused when it names nothing.
780///
781/// The one thing a real host refuses about a title, and the only one this provider
782/// may: how long a title may be is the host's own rule rather than `onevcs`'s
783/// commit-subject rule, and a provider applying the stricter of the two would
784/// refuse what the host it stands in for accepts.
785pub(crate) fn titled(title: &str) -> Result<()> {
786    if title.trim().is_empty() {
787        return Err(Error::Invalid {
788            reason: "a change request's title is blank, so it names no change".to_owned(),
789        });
790    }
791    Ok(())
792}
793
794/// The session a token names, refused when this state does not hold one.
795fn opened<'a>(state: &'a VcsState, token: &SessionToken, what: &str) -> Result<&'a Session> {
796    if !events::is_safe_name(&token.0) {
797        return Err(Error::Invalid {
798            reason: format!("{:?} is not a session token", token.0),
799        });
800    }
801    session_of(state, token).ok_or_else(|| Error::Invalid {
802        reason: format!(
803            "session {:?} is {what} here, but no session by that token was opened",
804            token.0
805        ),
806    })
807}
808
809/// A seeded host side is refused if it holds a change nothing could address.
810impl Checked for HostState {
811    fn check(&self) -> Result<()> {
812        readable_version(self.version)?;
813        for change in &self.changes {
814            named_branch(&change.base, "the base of a seeded change request")?;
815            if change.id.0.is_empty() {
816                return Err(Error::Invalid {
817                    reason: "a seeded change request carries no identifier".to_owned(),
818                });
819            }
820            // The commit a change request's checks are reported against is the whole
821            // evidence that a change reached anything, and the real implementation
822            // refuses a host answer that names none rather than passing a blank one
823            // through. A seeded one is refused for the same reason.
824            if change.head_sha.0.trim().is_empty() {
825                return Err(Error::Invalid {
826                    reason: format!(
827                        "the seeded change request {:?} names no commit its checks are \
828                         reported against",
829                        change.id.0
830                    ),
831                });
832            }
833        }
834        // Both of these are recorded *about* a change request, by `open_change` and
835        // by nothing else, and both are read back by the id they are keyed under. An
836        // entry for a change nobody opened is one no call could ever reach, so it is
837        // refused rather than carried — unlike the checks, logs, and merge outcomes
838        // below it, which a journey deliberately seeds for a change it has not
839        // opened yet.
840        for (id, head) in &self.heads {
841            opened_change(self, id, "a head")?;
842            named_branch(head, "the head of a seeded change request")?;
843        }
844        for (id, title) in &self.titles {
845            opened_change(self, id, "a title")?;
846            // The real host refuses a title that names nothing, so a seeded one is
847            // refused for the same reason.
848            titled(title)?;
849        }
850        for id in self.bodies.keys() {
851            // Nothing is refused about the body itself: a host places no shape on
852            // prose, and a change request opened with an empty one is a scenario.
853            opened_change(self, id, "a body")?;
854        }
855        for (id, reason) in &self.drafts {
856            opened_change(self, id, "a draft reason")?;
857            // The publication rule the crate next door states, applied rather than
858            // restated: a seeded reason no publication could have carried is refused
859            // where the document is read, not answered from later.
860            reason.checked()?;
861        }
862        for id in &self.made_ready {
863            opened_change(self, id, "a lifted draft")?;
864        }
865        for id in &self.awaiting_checks {
866            opened_change(self, id, "a draft awaiting its checks")?;
867        }
868        for described in &self.described {
869            opened_change(self, &described.id, "a description")?;
870            // The real host refuses a title that names nothing, so a seeded one is
871            // refused for the same reason — and a body is prose, refused for nothing.
872            if let Some(title) = &described.title {
873                titled(title)?;
874            }
875        }
876        Ok(())
877    }
878
879    /// A version 2 document held no bodies, and a version 5 document's checks named
880    /// no commit; each reads as what it was — change requests opened with none, and
881    /// checks whose head that build never recorded. There is nothing to fill in — an
882    /// absent field is already that answer, and a check's commit is the one thing
883    /// that must never be inferred — so this is the version and nothing else. The one
884    /// carry a version 8 document needs, the `kind` its draft reasons lack, is done
885    /// where those are read (`drafts_of_any_version`), because it is a fact about
886    /// one value's shape rather than about the document.
887    fn carry_forward(&mut self) {
888        self.version = STATE_VERSION;
889    }
890}
891
892/// Refuse a document written at a version this build does not read.
893///
894/// Named rather than guessed at: a state whose shape is a later build's reads one
895/// way here and another way where that version is understood, and for a seeded
896/// scenario those two readings are two different tests. The refusal names the
897/// range as well as the version, because "too old" and "too new" are two different
898/// things to do about it — one is a document to re-seed, the other a build to
899/// update.
900fn readable_version(declared: u32) -> Result<()> {
901    if !(OLDEST_READABLE_VERSION..=STATE_VERSION).contains(&declared) {
902        return Err(Error::Invalid {
903            reason: format!(
904                "the document declares version {declared}; this build reads versions \
905                 {OLDEST_READABLE_VERSION} to {STATE_VERSION}"
906            ),
907        });
908    }
909    Ok(())
910}