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, CommentId, CommentKind, DraftReason, Error,
14    Identity, Landed, MergeOutcome, ReadCost, Recoverable, Result, Sha,
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. `17` is the field a `Retirement` gained inside a row of
76/// [`VcsState::preserved`]: `cause`, the read that failed for a branch kept as
77/// `unknown`, which every retirement writes — `null` for every other reason. No
78/// provider here classifies a branch, so a row this crate writes carries none; a
79/// scenario may seed one. `18` is what the record of a gate run and of a landing needs
80/// from a host: the field a `Check` gained inside [`HostState::checks`] —
81/// `completed_at`, when the host says that run completed — and
82/// [`HostState::merge_times`], when each merge this host performed reached its base.
83///
84/// **Every change to the document is versioned, an added field included.** A field
85/// that only ever appears when it holds something is *compatible* — that is what
86/// [`OLDEST_READABLE_VERSION`] is for — but compatible is not the same as
87/// unremarkable: the version is how a consumer's checked-in scenario says which
88/// shape it was written against, and a document that gained a field without saying
89/// so leaves nothing able to tell "this build wrote no body" from "this document
90/// predates bodies". The two answers differ for exactly the journey this crate
91/// exists to support.
92/// `19` adds the recovery branch tip; older rows default it to `None`. `20` is the
93/// review feedback a host holds: [`HostState::review_comments`], each change
94/// request's comments of every kind, [`HostState::replies`], every reply this host
95/// posted, and [`HostState::review_charges`], what it charges a read. `21` is what a
96/// stack of drafts needs from a host while people merge some of them on it:
97/// [`HostState::closed`], the change requests closed without merging,
98/// [`HostState::deleted_branches`], the branches gone from the origin,
99/// [`HostState::head_checks`], each required check's state per head commit, and
100/// [`HostState::ledger`], every call the host answered with what GitHub charges for it.
101/// `22` is what an elapsed watch arms: [`HostState::up_to_date_required`],
102/// [`HostState::allow_auto_merge`] and [`HostState::armed`], and a failed publication's
103/// armed merge. `23` is what a draft's watch reads per poll:
104/// [`HostState::check_readings`] and [`HostState::check_readings_after_lift`], the
105/// checks each reading of a change request reports in turn on each side of its lift,
106/// and [`HostState::head_activity`], what the host answers in turn about the work
107/// still running on its head. `24` is a ledger that may name `merged_head`, the
108/// question a retirement asks a host before it calls a branch merged.
109pub const STATE_VERSION: u32 = 24;
110
111/// The oldest document version this build reads.
112///
113/// Every version from here to [`STATE_VERSION`] is read and carried forward on the
114/// way in, which is how `onevcs`'s own registry document treats its older
115/// versions: what separates a readable older version from a refused one is whether
116/// its every field still means here what it meant there. `2` to `3` added a field
117/// and changed none, so a version 2 document reads as one whose change requests
118/// were opened with no body — which is what they were; `3` to `4` added two the same
119/// way, so a version 3 document reads as one whose preserved rows say nothing about a
120/// hold or a line count, which is what they said. `4` to `5` added one that every row
121/// answers rather than one that appears when it holds something, so a version 4
122/// document's rows are carried forward as the answer that list *was*: preserved work
123/// nobody published, which is work that did not land. `5` to `6` added two that
124/// appear only when they hold something, so a version 5 document's checks read as
125/// checks whose commit and address that build never recorded — which is what they
126/// were, and the one thing that must not be filled in from the change request's own
127/// head. `6` to `7` added two more of that kind, so a version 6 document reads as one
128/// whose change requests were opened before this crate could draft one — which is what
129/// they were: no draft reason recorded for any of them, and no lift ever asked for.
130/// `7` to `8` *removed* two from each identity, and a version 7 document reads past
131/// them: an identity is its origin and its gate here as it is in `onevcs`, and the two
132/// keys beside them are ones this build has no opinion on. `8` to `9` gave every
133/// draft reason a `kind`, and a reason written without one is read as the one kind
134/// there was — `awaiting-release`, whose four fields it carries unchanged — which is
135/// `drafts_of_any_version`'s whole job; the descriptions beside it are absent from a
136/// document written before them, which is the answer, since that build could describe
137/// nothing. `9` to `10` changed no field and no meaning: every failure kind a version 9
138/// document can hold is spelled here as it was there and means what it meant, so it
139/// reads unchanged and needs no carrying forward beyond its version. `10` to `11` added
140/// one that appears only when it holds something, so a version 10 document's preserved
141/// rows read as rows saying nothing about an origin — which is what they said, since
142/// that build had no verb that could put a branch on one. `11` to `12` added the
143/// labels, which appear only where a session was opened with some, and the two row
144/// fields that carry them, which a version 11 row reads as absent: a session nobody
145/// labelled, and a row that names no session — which is what that build's rows said,
146/// since it recorded neither. `12` to `13` added the row's retirement, which appears only
147/// where a row holds one, so a version 12 row reads as one nothing classified — which
148/// is what it was, since that build had no classification to record. `13` to `14`
149/// added fields that appear only when they hold something, so a version 13 document
150/// reads as one whose change requests were opened before the lifecycle drafted any,
151/// whose checks never change on a lift, whose host declares its required checks by
152/// the checks it was seeded with, and whose policy leaves approvals and drafts at
153/// their defaults — which is what that build published under. `14` to `15` added the
154/// session's conflict, which appears only where a session holds one, so a version 14
155/// document's sessions read as sessions that opened clean — which is what they were,
156/// since that build opened none over a merge. `15` to `16` changed no field and no
157/// meaning, as `9` to `10` did not: every failure kind a version 15 document can hold is
158/// spelled here as it was there, so it reads unchanged. `16` to `17` added the
159/// retirement's cause, which a version 16 retirement reads as `null` — which is what it
160/// said, since that build named no read that failed. `17` to `18` added two fields that
161/// appear only when they hold something, so a version 17 document's checks read as
162/// checks whose completion that build never recorded, and its merges as merges whose
163/// time it never recorded — which is what they were. `19` to `20` added three fields
164/// that appear only when they hold something, so a version 19 document reads as a
165/// host whose change requests carry no review feedback and that has posted no reply —
166/// which is what that build could hold. `20` to `21` added four more of that kind, so a
167/// version 20 document reads as a host on which nobody closed or deleted anything, whose
168/// checks are its change requests' own, and which has answered no call — which is what
169/// that build recorded. `21` to `22` added three host fields of that kind and one field
170/// of a failed publication, so a version 21 document reads as a host that requires no
171/// up-to-date branch, allows auto-merge and holds no armed merge, and as publications
172/// none of which left a merge armed — which is what that build could do. `22` to `23`
173/// added three host fields of that kind, so a version 22 document reads as a host whose
174/// checks hold still between readings and that reports nothing running on any head
175/// beyond its checks — which is what that build could answer. `23` to `24` added a word a
176/// ledger entry may name and no field, so a version 23 document reads as it was — a
177/// build that could not be asked which head merged recorded no such call.
178///
179/// `1` is refused rather than read for the opposite reason: it describes a provider
180/// that could not publish, and every session in it would read back as open — a
181/// wrong answer where a journey asserting on a session it had closed needs a
182/// refusal.
183pub const OLDEST_READABLE_VERSION: u32 = 2;
184
185/// Everything the repository side of a run knows about itself.
186///
187/// Every field is public and serializable, so a journey both seeds a scenario and
188/// asserts on what a run left behind. Everything but the version is omitted when
189/// it holds nothing, so a hand-written document names only the part of a scenario
190/// that matters — and a document written by a build that knew fewer fields still
191/// reads here.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(default)]
194pub struct VcsState {
195    /// The schema version this state was written at. A document that names none is
196    /// the one this build writes.
197    // llmlint: ignore[boundary_inputs_validated] deciding what to do with a version this
198    // build does not read is the whole of the check — and it is in `Checked::check` below,
199    // where a document is read, rather than here where serde only proves the shape.
200    pub version: u32,
201    /// The repository identities this provider can resolve. A
202    /// [`SessionRequest::repo`] naming none of them is refused, the way an
203    /// unregistered repository is.
204    #[serde(skip_serializing_if = "Vec::is_empty")]
205    pub identities: Vec<Identity>,
206    /// Every session opened or seeded, in the order they were opened.
207    #[serde(skip_serializing_if = "Vec::is_empty")]
208    pub sessions: Vec<Session>,
209    /// Which identity each session belongs to.
210    ///
211    /// Beyond the sketch this crate was specified from, and unavoidable: a
212    /// [`Session`] carries no identity, and a [`Recoverable`] must name one — so
213    /// preserving a session's branch could not answer the question `recoverable`
214    /// asks without this. `open_session` records it; nothing else writes it.
215    // llmlint: ignore[invalid_states_unrepresentable] an identity key is a `String`
216    // everywhere the crate this mirrors spells one — `Recoverable.identity`,
217    // `Identity.origin`, the registry document's own map key — and a newtype here would
218    // make a seeded state disagree with the types it is made of. Every value written to
219    // this map came out of `identity_of`, so it names an identity this provider holds.
220    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
221    pub session_identities: BTreeMap<SessionToken, String>,
222    /// The labels each session was opened with, for the sessions opened with any.
223    ///
224    /// Keyed by token the way [`session_identities`](Self::session_identities) is,
225    /// and for the same reason: a [`Session`] carries no labels, and both `session
226    /// holders` and every `recoverable` row report them. `open_session` records
227    /// them; nothing else writes it.
228    // llmlint: ignore[invalid_states_unrepresentable] keyed by session token, exactly as
229    // `session_identities` above is and for the same reason it gives.
230    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
231    pub session_labels: BTreeMap<SessionToken, BTreeMap<String, String>>,
232    /// Preserved work, newest last, as `recoverable` reports it.
233    ///
234    /// [`Recoverable`] rather than `PreservedBranch` — it *contains* the preserved
235    /// branch and adds the identity, the checkout, why the workstream stopped, and
236    /// the command that lands it, none of which are derivable from the branch
237    /// alone. One list rather than two that could disagree.
238    #[serde(skip_serializing_if = "Vec::is_empty")]
239    pub preserved: Vec<Recoverable>,
240    /// The sessions that have been closed. Every other session here is open.
241    ///
242    /// One way to say closed rather than two, so a scenario written by hand names
243    /// only the sessions whose lifecycle is not the one they were opened in.
244    // llmlint: ignore[invalid_states_unrepresentable] keyed by session token, exactly as
245    // `session_identities` above is and for the same reason: this document is a scenario
246    // somebody writes by hand, and a session is named once under `sessions` with the rest
247    // of the state keyed to it rather than nested inside a shape that could hold only one
248    // arrangement. A token here that names no opened session is refused in
249    // `Checked::check`, where the document is read — the same trust boundary every other
250    // cross-reference in it is checked at.
251    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
252    pub closed_sessions: BTreeSet<SessionToken>,
253    /// The policy this provider publishes under.
254    ///
255    /// The answer a rules file gives the real implementation, which a provider has
256    /// none of — so a journey states it, and unset is the policy the contract's own
257    /// `default:` names ([`DEFAULT_PUBLICATION`](crate::DEFAULT_PUBLICATION)). A
258    /// per-run policy narrows it through [`MergePolicy::narrow`], which is the
259    /// rules system's rule rather than a restatement of it here.
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub policy: Option<MergePolicy>,
262    /// Whether this provider's changes need an approval, which is the other half of
263    /// what decides a green draft's ending: `change-open` with approvals required
264    /// keeps it for its user's review. Unset is the contract's own `default:`,
265    /// [`DEFAULT_APPROVALS`](crate::DEFAULT_APPROVALS).
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub approvals: Option<Approvals>,
268    /// The `drafts:` a rules file would give this provider's identity. Unset, and
269    /// each key it leaves unset, is the shipped default: the lifecycle on and the
270    /// early-lift warning on.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub drafts: Option<Drafts>,
273    /// Every publication this provider performed, in the order it performed them.
274    ///
275    /// Both a record a journey asserts on and the answer to "has this session been
276    /// published already": a second publication of a session that landed has
277    /// nothing the base does not already carry, which is what the real
278    /// implementation reports for the same reason.
279    // llmlint: ignore[invalid_states_unrepresentable] this holds `onevcs::Publication`
280    // verbatim — the value `Vcs::publish` handed back, carrying its own session and branch
281    // — so a journey asserts on exactly what a caller would receive. A shape that made
282    // "this publication is of some other session's branch" unrepresentable could not hold
283    // that type, and would be a second spelling of the answer the crate next door already
284    // has. The cross-reference is checked in `Checked::check` instead, where the document
285    // is read.
286    #[serde(skip_serializing_if = "Vec::is_empty")]
287    pub publications: Vec<Publication>,
288}
289
290/// A repository side that knows nothing, at the version this build writes.
291impl Default for VcsState {
292    fn default() -> Self {
293        Self {
294            version: STATE_VERSION,
295            identities: Vec::new(),
296            sessions: Vec::new(),
297            session_identities: BTreeMap::new(),
298            session_labels: BTreeMap::new(),
299            preserved: Vec::new(),
300            closed_sessions: BTreeSet::new(),
301            policy: None,
302            approvals: None,
303            drafts: None,
304            publications: Vec::new(),
305        }
306    }
307}
308
309/// Everything the remote-host side of a run knows about itself.
310///
311/// Omitted-when-empty and versioned for the same reasons [`VcsState`] is.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(default)]
314pub struct HostState {
315    /// The schema version this state was written at. A document that names none is
316    /// the one this build writes.
317    // llmlint: ignore[boundary_inputs_validated] as on `VcsState::version`: the decision
318    // about an unreadable version is made in `Checked::check`, where the document is read.
319    pub version: u32,
320    /// Who the host says is calling. Empty is refused, exactly as a `gh` that
321    /// reports no authenticated user is.
322    // llmlint: ignore[invalid_states_unrepresentable] the interface this satisfies is
323    // `authenticated_user() -> Result<String>`, so the login is a `String` by contract and
324    // the one unusable value — a host that names nobody — is refused where it is read
325    // rather than made unrepresentable in a state a journey writes by hand.
326    pub authenticated_user: String,
327    /// Every change request that has been opened or seeded.
328    #[serde(skip_serializing_if = "Vec::is_empty")]
329    pub changes: Vec<ChangeRequest>,
330    /// The head branch each change request was opened from.
331    ///
332    /// Beyond the sketch, and unavoidable: [`ChangeRequest`] records only the base
333    /// it targets, and `find_changes` matches on the head as well.
334    // llmlint: ignore[invalid_states_unrepresentable] the matching `ChangeSpec.head` and
335    // `ChangeRequest.base` are `String` in the contract this mirrors, and a validated ref
336    // type here would disagree with them. Every value written to this map went through
337    // `addressable` in `open_change` first, which is the same refusal the real
338    // implementation makes at the same point.
339    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
340    pub heads: BTreeMap<ChangeId, String>,
341    /// The title each change request was opened under.
342    ///
343    /// Beyond the sketch, and for the same reason [`heads`](HostState::heads) is:
344    /// [`ChangeRequest`] records neither, and the title is what a publication's
345    /// commit subject becomes — so a journey asserting that the subject it asked
346    /// for is the one the host was given has nowhere else to read it.
347    // llmlint: ignore[invalid_states_unrepresentable] this records the `ChangeSpec.title`
348    // the contract fixes as a `String`, so a validated type here would disagree with the
349    // one it mirrors. `Subject` is not that type: it is `onevcs`'s rule for a *commit
350    // subject*, and a host's own limit is its own — spelling it here would
351    // refuse a seeded title a real host accepts, which is drift in the direction that
352    // looks like rigour. What the host itself refuses is a title that names nothing, and
353    // that is refused below and in `open_change`, at the boundary the value arrives at.
354    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
355    pub titles: BTreeMap<ChangeId, String>,
356    /// The body each change request was opened with, for the change requests that
357    /// were opened with one.
358    ///
359    /// Beside [`titles`](HostState::titles) and for its reason: the body is what a
360    /// caller passes on the `PublishRequest` and nothing else records it, so a
361    /// journey asserting that the body it drafted is the one the host was given has
362    /// nowhere else to read it. A change request with no entry was opened with no
363    /// body at all, which is what a publication nobody gave one does — the two are
364    /// different scenarios, and an empty string is the first rather than the second.
365    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
366    pub bodies: BTreeMap<ChangeId, String>,
367    /// The checks the host reports on each change request. A change with no entry
368    /// has no checks, which is what a repository with no CI reports.
369    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
370    pub checks: BTreeMap<ChangeId, Vec<Check>>,
371    /// The log the host hands over for a check, keyed by change request and then by
372    /// check name. Beyond the sketch: `check_log` is one of the six methods, and
373    /// without this the only log a journey could asssert on is a synthesized one.
374    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
375    pub check_logs: BTreeMap<ChangeId, BTreeMap<String, String>>,
376    /// The reason each change request that was opened as a **draft** was drafted
377    /// with, for the change requests that were.
378    ///
379    /// Beside [`bodies`](HostState::bodies) and for its reason: the reason is what a
380    /// caller passes on the `PublishRequest` and this is where a journey reads back
381    /// that the host was given it. A change with no entry is not a draft, which is
382    /// what every change request opened without one is.
383    ///
384    /// Nothing is refused about the reason itself here. Its shape — that every field
385    /// of it renders as the one line it is printed on — is `onevcs`'s own rule, and
386    /// it is applied where a publication takes one in, before any host is asked.
387    #[serde(
388        skip_serializing_if = "BTreeMap::is_empty",
389        deserialize_with = "drafts_of_any_version"
390    )]
391    pub drafts: BTreeMap<ChangeId, DraftReason>,
392    /// Every `ready_for_review` call this host has taken, in the order it took them
393    /// — the lifts it was asked to perform, never a request for a *reviewer*.
394    ///
395    /// A list rather than a set, and beyond the sketch for a reason a set could not
396    /// serve: lifting a draft is required to be *idempotent*, so what a journey
397    /// asserts is that a second publication asked the host for nothing — and only a
398    /// record of the calls themselves can say that. A change named here is not a
399    /// draft any more, whatever [`drafts`](HostState::drafts) says it was opened as.
400    #[serde(skip_serializing_if = "Vec::is_empty")]
401    pub made_ready: Vec<ChangeId>,
402    /// The change requests opened as a draft **while their required checks run** —
403    /// the lifecycle's draft, which no caller gave a reason for and so is not in
404    /// [`drafts`](HostState::drafts). A change named here is a draft until
405    /// [`made_ready`](HostState::made_ready) names it.
406    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
407    pub awaiting_checks: BTreeSet<ChangeId>,
408    /// The checks the host reports on a change request once it has been lifted out of
409    /// its draft, in place of [`checks`](HostState::checks).
410    ///
411    /// What a repository whose workflow skips drafts looks like from outside: the
412    /// draft's checks are skipped or absent, and the run the lift triggers registers
413    /// after it. A change with no entry reports the same checks either side of a lift.
414    ///
415    /// A publication tells the run a lift triggered from the draft's by the run's own
416    /// identity, as it would on a real host: a check named here that the draft also
417    /// reported is a new run — running or settled — only where its
418    /// [`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
419    /// the draft's start, or reporting none, is the draft's run still standing.
420    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
421    pub checks_after_lift: BTreeMap<ChangeId, Vec<Check>>,
422    /// What this host answers when asked which checks a merge into a base requires.
423    ///
424    /// Unset is a host whose branch protection names exactly the checks it was seeded
425    /// as required, on any change request — a complete answer, and an empty one where
426    /// none is seeded required. Seeded, it is the answer for every base: an empty
427    /// complete one is a repository declaring no required check, and one naming an
428    /// `unconsulted` source is the incomplete answer a narrower credential gets.
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub required_checks: Option<RequiredChecks>,
431    /// Every `describe_change` call this host has taken, in the order it took them:
432    /// which change request, the title it was handed when it was handed one, and the
433    /// body.
434    ///
435    /// The calls themselves, for the reason [`made_ready`](HostState::made_ready) is:
436    /// what a journey asks is what a closeout *wrote*, call by call, and the current
437    /// title and body — which each call also writes into
438    /// [`titles`](HostState::titles) and [`bodies`](HostState::bodies), so
439    /// `change_description` answers from the same record `open_change` wrote — can
440    /// only say where the description ended up.
441    #[serde(skip_serializing_if = "Vec::is_empty")]
442    pub described: Vec<Described>,
443    /// Which sources this host answers about its checks from, which is what the
444    /// real implementation reports alongside them.
445    ///
446    /// Unset is a credential allowed to read everything: the whole rollup, exactly
447    /// what a host with nothing to hide reports. A journey states a narrower set to
448    /// be the credential the real one meets in CI — a fine-grained token, which
449    /// cannot read check runs at all and sees GitHub Actions and nothing else — and
450    /// states an *empty* one to be a credential that can read no source at all,
451    /// which is a refusal rather than "no checks". Unset and empty are therefore
452    /// different scenarios, which is why this is an `Option` rather than a set whose
453    /// emptiness means "not stated".
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub check_sources: Option<BTreeSet<CheckSource>>,
456    /// What merging each change request did.
457    ///
458    /// Both a script and a record: an entry seeded here is what `merge` answers,
459    /// whatever the policy asks for — which is how a journey expresses a host that
460    /// queues or refuses — and a merge the policy decided is written back here.
461    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
462    pub merges: BTreeMap<ChangeId, MergeOutcome>,
463    /// When each merged change request reached its base, as this host spells the
464    /// moment — what `merge_time` answers.
465    ///
466    /// Written beside [`merges`](HostState::merges) whenever this host performs a
467    /// merge, at the moment it performs it, and seedable for a merge a journey
468    /// seeded: a host that merged on its own clock, earlier than anybody asked, is
469    /// exactly what a late merge's record must report the time of. A merged change
470    /// with no entry is one whose time this host cannot say.
471    // llmlint: ignore[invalid_states_unrepresentable] this mirrors the answer
472    // `RemoteHost::merge_time` gives, which the contract fixes as the host's own spelling
473    // in an `Option<String>`; a timestamp type here would disagree with the seam it
474    // answers. A value that is not an RFC3339 moment is refused in `check`, where the
475    // document is read, and every one this host writes is `events::timestamp`'s.
476    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
477    pub merge_times: BTreeMap<ChangeId, String>,
478    /// Each change request's review feedback — review-thread comments, review
479    /// summaries and conversation comments — in the order it was posted.
480    ///
481    /// Both a script and a record: what a journey seeds here is what
482    /// `review_comments` reads, [`Host::add_comment`](crate::Host::add_comment),
483    /// [`Host::edit_comment`](crate::Host::edit_comment) and
484    /// [`Host::set_thread`](crate::Host::set_thread) move it as a reviewer would, and
485    /// every reply `reply_to_comment` posts is written back here beside them.
486    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
487    pub review_comments: BTreeMap<ChangeId, Vec<HostComment>>,
488    /// Every reply this host posted, in the order it posted them.
489    ///
490    /// The calls themselves, for the reason [`described`](HostState::described) is:
491    /// what a journey about idempotency asks is how many replies were *posted*, and
492    /// the comments a read returns can only say what is there now.
493    #[serde(default, skip_serializing_if = "Vec::is_empty")]
494    pub replies: Vec<Replied>,
495    /// What this host charges for each read of review feedback, one entry a read, in
496    /// order; a read with none left is charged the one GraphQL point a page that
497    /// `spike-review-loop` measured, and no REST request.
498    #[serde(default, skip_serializing_if = "Vec::is_empty")]
499    pub review_charges: Vec<ReadCost>,
500    /// The change requests closed without merging — by a person on the host, or because
501    /// the branch they target was deleted from under them.
502    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
503    pub closed: BTreeSet<ChangeId>,
504    /// The branches gone from the origin: a merged change request's head a person
505    /// deleted. A closed change request targeting one reads `base_deleted`, and nothing
506    /// is retargeted onto one.
507    // llmlint: ignore[invalid_states_unrepresentable] branch names, as `heads` holds them;
508    // each is held to git's grammar in `check`, where the document is read.
509    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
510    pub deleted_branches: BTreeSet<String>,
511    /// Each check's state on one head commit, in place of the change request's own
512    /// [`checks`](HostState::checks): what a host reports for a head pushed since, and
513    /// how a journey gives each change request of a stack its own verdict.
514    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
515    pub head_checks: BTreeMap<Sha, Vec<Check>>,
516    /// Every host call this host answered, in the order it answered them: the method,
517    /// the change requests it names and what GitHub charges the call the real host
518    /// sends — which is what a consumer sums over a whole review round.
519    #[serde(default, skip_serializing_if = "Vec::is_empty")]
520    pub ledger: Vec<HostCall>,
521    /// Whether this host requires a branch to be up to date with its base before it
522    /// merges one into it — GitHub branch protection's `strict` — which is what
523    /// `requires_up_to_date` answers. Unset is a host that requires it of no branch.
524    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
525    pub up_to_date_required: bool,
526    /// Whether the repository lets a change request be armed to merge itself once its
527    /// required checks pass — GitHub's `allow_auto_merge` — which is what
528    /// `allows_auto_merge` answers. Unset is a repository that allows it, so a
529    /// publication whose watch elapses arms the merge; `false` is one that does not,
530    /// so it is left as the elapsed watch left it. Only `false` is written, so allowed
531    /// has the one spelling.
532    #[serde(default = "allowed", skip_serializing_if = "is_allowed")]
533    pub allow_auto_merge: bool,
534    /// The change requests whose own merge is armed — by a `change-auto` publication,
535    /// or by one whose watch elapsed with no required check red. The host lands one
536    /// the moment its required checks are green, whoever asks next: a publication
537    /// watching it, or one asking for its merge again.
538    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
539    pub armed: BTreeSet<ChangeId>,
540    /// The checks each reading of a change request reports, in turn: the first
541    /// `change_checks` call about it answers the first entry, the second the second,
542    /// and the last entry answers every call after it — so a journey scripts each poll
543    /// of a watch rather than racing a clock. Seeded, it stands in for that change
544    /// request's [`checks`](HostState::checks), and for its
545    /// [`checks_after_lift`](HostState::checks_after_lift) unless
546    /// [`check_readings_after_lift`](HostState::check_readings_after_lift) scripts the
547    /// lift's side; a merge is judged on the entry the latest reading answered.
548    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
549    pub check_readings: BTreeMap<ChangeId, Vec<Vec<Check>>>,
550    /// The same script for the readings after a change request is lifted, counted from
551    /// its lift: the first `change_checks` call after `ready_for_review` answers the
552    /// first entry. A lift happens on a clock — the grace window — so the readings on
553    /// each side of it are scripted apart, and neither has to guess on which poll it
554    /// came.
555    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
556    pub check_readings_after_lift: BTreeMap<ChangeId, Vec<Vec<Check>>>,
557    /// What `head_activity` answers about a change request, in turn: one answer per
558    /// call, and the last answering every call after it. A change request with none
559    /// answers, completely, that nothing is running on the commit it was asked about —
560    /// a host whose checks are the whole of what runs, which is what every journey
561    /// written before this answer existed describes.
562    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
563    pub head_activity: BTreeMap<ChangeId, Vec<ActivityAnswer>>,
564}
565
566/// One answer a host gives about the work still running on a change request's head —
567/// what [`HostState::head_activity`] scripts per call.
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
569#[serde(rename_all = "kebab-case", tag = "answer")]
570pub enum ActivityAnswer {
571    /// An answer about the commit it was asked about, naming the work in progress on
572    /// it — none, where `in_progress` is empty.
573    Head {
574        /// The host's name for each piece of work still in progress.
575        // llmlint: ignore[invalid_states_unrepresentable] a scripted copy of
576        // `onevcs::HeadActivity::in_progress`, whose contract-fixed type it must hand
577        // back unchanged; any name a host could report is a valid one.
578        #[serde(default, skip_serializing_if = "Vec::is_empty")]
579        in_progress: Vec<String>,
580        /// Whether every source of such work was read: `false` is a credential that
581        /// could read the workflow runs and not the check suites.
582        // llmlint: ignore[invalid_states_unrepresentable] a scripted copy of the
583        // contract-fixed `onevcs::HeadActivity::complete: bool`, with the same two states.
584        #[serde(default = "allowed", skip_serializing_if = "is_allowed")]
585        complete: bool,
586    },
587    /// An answer about `head`, a commit other than the one asked about — a host that
588    /// has not processed the push yet.
589    Other {
590        /// The commit the answer is about.
591        head: Sha,
592        /// The host's name for each piece of work still in progress on it.
593        // llmlint: ignore[invalid_states_unrepresentable] as on `Head::in_progress`: the
594        // contract-fixed `HeadActivity::in_progress` this answer is handed back as.
595        #[serde(default, skip_serializing_if = "Vec::is_empty")]
596        in_progress: Vec<String>,
597    },
598    /// A refusal: a credential that may read neither source.
599    Refused,
600}
601
602/// Every method a ledger entry may name: each `RemoteHost` method this host answers.
603/// `Host::called` refuses to record any other, and
604/// `the_ledger_records_a_call_to_every_method_the_host_interface_declares` holds this
605/// list equal to the trait's own declaration, read out of its source.
606pub(crate) const LEDGER_METHODS: &[&str] = &[
607    "allows_auto_merge",
608    "authenticated_user",
609    "change_checks",
610    "change_description",
611    "change_standings",
612    "check_log",
613    "describe_change",
614    "find_changes",
615    "head_activity",
616    "is_draft",
617    "merge",
618    "merge_time",
619    "merged_at",
620    "merged_head",
621    "mergeability",
622    "open_change",
623    "ready_for_review",
624    "reopen_change",
625    "reply_to_comment",
626    "required_checks_on",
627    "requires_up_to_date",
628    "retarget_change",
629    "review_comments",
630    "review_standings",
631    "visibility",
632];
633
634/// One host call a host answered, as its ledger records it.
635// llmlint: ignore[invalid_states_unrepresentable] `method` is the name of the
636// `RemoteHost` method that was called; `HostCall::checked` holds it to `LEDGER_METHODS`
637// where the document is read and where a call is recorded, and nothing reads a decision
638// out of the name.
639#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
640pub struct HostCall {
641    /// The `RemoteHost` method called, by its name.
642    pub method: String,
643    /// The change requests the call names, in the order it names them.
644    #[serde(default, skip_serializing_if = "Vec::is_empty")]
645    pub changes: Vec<ChangeId>,
646    /// What GitHub charges the call the real host sends.
647    pub charge: ReadCost,
648}
649
650impl HostCall {
651    /// Refuse an entry no call to this host could have written: a method it does not
652    /// answer, or a change request named by no identifier at all. A change request it
653    /// never opened is not refused — the real host charges a call it then refuses, and
654    /// so the ledger records one.
655    pub(crate) fn checked(&self) -> Result<()> {
656        if !LEDGER_METHODS.contains(&self.method.as_str()) {
657            return Err(Error::Invalid {
658                reason: format!(
659                    "the call ledger records a call to {:?}, which is no method this host \
660                     answers",
661                    self.method
662                ),
663            });
664        }
665        if self.changes.iter().any(|id| id.0.trim().is_empty()) {
666            return Err(Error::Invalid {
667                reason: format!(
668                    "the call ledger records a call to {:?} naming a change request with no \
669                     identifier",
670                    self.method
671                ),
672            });
673        }
674        Ok(())
675    }
676}
677
678/// One comment a host holds on a change request.
679///
680/// What `review_comments` answers a [`ReviewComment`](onevcs::ReviewComment) from:
681/// everything but `ours` and `reply_marker`, which are read out of the body exactly as
682/// the real host's are, and with the one thing a host keeps that a read does not
683/// show — when it last changed.
684// llmlint: ignore[invalid_states_unrepresentable] every text field is what the real host
685// reports as a string and `ReviewComment` carries as one; a seeded comment is the
686// journey's own scenario, and `check` refuses the two things no host could hold — an id
687// held twice, and a reply to a comment the change request does not carry.
688#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
689pub struct HostComment {
690    /// The host's id for it.
691    pub id: CommentId,
692    /// What kind it is, and — for a thread comment — its thread's flags.
693    pub kind: CommentKind,
694    /// Who wrote it.
695    pub author: String,
696    /// What it says.
697    pub body: String,
698    /// Where a person reads it.
699    pub url: String,
700    /// When it was written.
701    pub created_at: String,
702    /// When it last changed.
703    pub updated_at: String,
704    /// The comment it replies to in its thread.
705    #[serde(default, skip_serializing_if = "Option::is_none")]
706    pub in_reply_to: Option<CommentId>,
707    /// When, in this host's own count of changes to the change request's feedback, it
708    /// last changed — what a read marker is compared against. A seeded comment at `0`
709    /// is one every read from a marker has already seen.
710    #[serde(default)]
711    pub revision: u64,
712}
713
714/// One reply a host posted.
715#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
716pub struct Replied {
717    /// The change request it was posted on.
718    pub change: ChangeId,
719    /// The comment it was posted against.
720    pub comment: CommentId,
721    /// The idempotency key the call carried.
722    pub key: String,
723    /// The reply's own id.
724    pub reply: CommentId,
725    /// Whether it was posted in the comment's thread.
726    pub threaded: bool,
727}
728
729/// One description a host was handed for a change request after it was opened.
730///
731/// What `RemoteHost::describe_change` was given, verbatim: the title only where the
732/// call carried one, because a call that replaced the body alone left the title as
733/// it was, and a record that filled it in would say the caller wrote a title nobody
734/// wrote.
735// llmlint: ignore[invalid_states_unrepresentable] the title and body are the `&str`s the
736// seam hands over — a host places no shape on prose, and `onevcs`'s own `Subject` rule
737// was applied where the description was composed, before any host was asked.
738#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
739pub struct Described {
740    /// The change request the description was written to.
741    pub id: ChangeId,
742    /// The title the call replaced the change request's with, when it replaced one.
743    #[serde(default, skip_serializing_if = "Option::is_none")]
744    pub title: Option<String>,
745    /// The body the call replaced the change request's with.
746    pub body: String,
747}
748
749/// The `drafts` map of a document at any readable version.
750///
751/// A document written before version 9 spells a draft reason as the four fields of
752/// the one kind there was and no `kind` beside them; this build's reason is one of
753/// two kinds and says which. So a reason arriving without a `kind` is read as
754/// `awaiting-release` — exactly what it was — rather than refused, which is what
755/// keeps a consumer's checked-in scenario readable across the bump. A reason that
756/// *names* a kind is read as it names it, so nothing written by this build is
757/// reinterpreted.
758fn drafts_of_any_version<'de, D>(
759    deserializer: D,
760) -> std::result::Result<BTreeMap<ChangeId, DraftReason>, D::Error>
761where
762    D: serde::Deserializer<'de>,
763{
764    let raw: BTreeMap<ChangeId, serde_json::Map<String, serde_json::Value>> =
765        Deserialize::deserialize(deserializer)?;
766    raw.into_iter()
767        .map(|(id, mut fields)| {
768            fields
769                .entry("kind")
770                .or_insert_with(|| serde_json::Value::from("awaiting-release"));
771            serde_json::from_value::<DraftReason>(serde_json::Value::Object(fields))
772                .map(|reason| (id, reason))
773                .map_err(serde::de::Error::custom)
774        })
775        .collect()
776}
777
778/// Who a host with nothing seeded says is calling.
779///
780/// A host that answers nobody is refused by the real implementation, so a default
781/// state that answered nobody would be a provider that cannot run a publication
782/// until it is configured.
783pub const DEFAULT_AUTHENTICATED_USER: &str = "onevcs-testing";
784
785impl Default for HostState {
786    fn default() -> Self {
787        Self {
788            version: STATE_VERSION,
789            authenticated_user: DEFAULT_AUTHENTICATED_USER.to_owned(),
790            changes: Vec::new(),
791            heads: BTreeMap::new(),
792            titles: BTreeMap::new(),
793            bodies: BTreeMap::new(),
794            drafts: BTreeMap::new(),
795            made_ready: Vec::new(),
796            awaiting_checks: BTreeSet::new(),
797            checks_after_lift: BTreeMap::new(),
798            required_checks: None,
799            described: Vec::new(),
800            checks: BTreeMap::new(),
801            check_logs: BTreeMap::new(),
802            check_sources: None,
803            merges: BTreeMap::new(),
804            merge_times: BTreeMap::new(),
805            review_comments: BTreeMap::new(),
806            replies: Vec::new(),
807            review_charges: Vec::new(),
808            closed: BTreeSet::new(),
809            deleted_branches: BTreeSet::new(),
810            head_checks: BTreeMap::new(),
811            ledger: Vec::new(),
812            up_to_date_required: false,
813            allow_auto_merge: true,
814            armed: BTreeSet::new(),
815            check_readings: BTreeMap::new(),
816            check_readings_after_lift: BTreeMap::new(),
817            head_activity: BTreeMap::new(),
818        }
819    }
820}
821
822/// What a host document that says nothing about auto-merge allows: GitHub's default.
823fn allowed() -> bool {
824    true
825}
826
827/// Whether `allow_auto_merge` holds the default, which a written document omits.
828#[expect(
829    clippy::trivially_copy_pass_by_ref,
830    reason = "serde's skip_serializing_if passes a reference"
831)]
832fn is_allowed(allow: &bool) -> bool {
833    *allow
834}
835
836/// The identity a session request names, or the reason none of them is it.
837///
838/// Three ways to name one, mirroring what the registry accepts: the identity key
839/// itself, the `owner/name` tail of it, or the bare repository name.
840pub(crate) fn identity_of<'a>(state: &'a VcsState, origin_or_path: &str) -> Option<&'a Identity> {
841    let wanted = origin_or_path.trim_end_matches('/');
842    state
843        .identities
844        .iter()
845        .find(|identity| identity.origin == wanted)
846        .or_else(|| {
847            state.identities.iter().find(|identity| {
848                identity
849                    .origin
850                    .rsplit('/')
851                    .next()
852                    .is_some_and(|name| name == wanted)
853                    || identity.origin.ends_with(&format!("/{wanted}"))
854            })
855        })
856}
857
858/// The known identities, as a refusal names them.
859pub(crate) fn known(state: &VcsState) -> String {
860    if state.identities.is_empty() {
861        return "this provider was seeded with no identities".to_owned();
862    }
863    let names: Vec<&str> = state
864        .identities
865        .iter()
866        .map(|identity| identity.origin.as_str())
867        .collect();
868    format!("it knows {}", names.join(", "))
869}
870
871/// The identity a session belongs to, or the reason this provider cannot say.
872///
873/// `open_session` records it and nothing else writes it, so a session that arrived
874/// in a hand-written scenario without one is refused here rather than published
875/// against a repository nobody named.
876pub(crate) fn identity_for(state: &VcsState, token: &SessionToken) -> Result<String> {
877    state
878        .session_identities
879        .get(token)
880        .cloned()
881        .ok_or_else(|| Error::Invalid {
882            reason: format!(
883                "this provider has no record of session {:?}, so it cannot say which identity \
884                 its work belongs to",
885                token.0
886            ),
887        })
888}
889
890/// The session a token names.
891pub(crate) fn session_of<'a>(state: &'a VcsState, token: &SessionToken) -> Option<&'a Session> {
892    state
893        .sessions
894        .iter()
895        .find(|session| session.token == *token)
896}
897
898/// The branch a request asks for, or the one that is derived from the token.
899///
900/// `branch` and `branch_name` are two answers to one question — *continue this* and
901/// *cut one at this* — so a request naming both is refused here as the real
902/// implementation refuses it: that refusal is about the shape of the request rather
903/// than about anything a repository does, and a consumer proving it must meet the
904/// same answer on either side.
905///
906/// **What a provider deliberately does not do to `branch_name` is the rest**, and it
907/// is not approximated: the host's branch prefix is read out of an `onevcs` state
908/// root no provider has, and the first-free suffix is searched against an identity's
909/// checkouts and its origin, which a provider has neither of. So the name is taken as
910/// it was proposed and refused — naming it — where git would refuse it, rather than
911/// being sanitized by a second copy of a grammar that would then have to be held to
912/// the first. A consumer proving what the prefix, the sanitizer and the collision
913/// suffix do drives the real `onevcs`, which is where all three live.
914pub(crate) fn requested_branch(req: &SessionRequest, token: &SessionToken) -> Result<String> {
915    if let (Some(pinned), Some(proposed)) = (&req.branch, &req.branch_name) {
916        return Err(Error::Invalid {
917            reason: format!(
918                "this request names both a branch to continue ({pinned:?}) and a name to cut \
919                 ({proposed:?}), which are two answers to one question"
920            ),
921        });
922    }
923    let name = req
924        .branch
925        .clone()
926        .or_else(|| req.branch_name.clone())
927        .unwrap_or_else(|| format!("onevcs/{}", token.0));
928    named_branch(&name, "the branch")?;
929    Ok(name)
930}
931
932/// A branch name, refused here if git would refuse it.
933///
934/// The real implementation asks `git check-ref-format`, which is the parser that
935/// decides; a provider with no git carries `git-check-ref-format(1)`'s rules
936/// instead. That is a restatement, so it is gated rather than trusted:
937/// `refs.rs` in the suite runs both this and git itself over a table of names
938/// and holds them to each other, because a copy of somebody else's grammar with
939/// no gate is a copy that drifts.
940///
941/// One deliberate difference, and the gate knows about it: a leading `-` is
942/// refused here even though git accepts it as a ref, because such a name reaches
943/// a command line as an option rather than as the branch it spells.
944pub(crate) fn named_branch(value: &str, what: &str) -> Result<()> {
945    // Rule 1 is per slash-separated component; the rest are about the whole name.
946    let components_usable = !value.is_empty()
947        && value.split('/').all(|component| {
948            !component.is_empty() && !component.starts_with('.') && !component.ends_with(".lock")
949        });
950    let usable = components_usable
951        && !value.starts_with('-')
952        && !value.contains("..")
953        && !value.contains("@{")
954        && !value.ends_with('.')
955        && !value.ends_with('/')
956        && !value.chars().any(|c| {
957            c.is_whitespace() || c.is_ascii_control() || c == '\u{7f}' || "~^:?*[\\".contains(c)
958        });
959    if !usable {
960        return Err(Error::Invalid {
961            reason: format!("{what} {value:?} is a name git would not accept"),
962        });
963    }
964    Ok(())
965}
966
967/// A label, refused here where the real implementation refuses one: a key is one or
968/// more of `A-Z`, `a-z`, `0-9`, `_` and `-`, and a value is one line. The same rule
969/// spelled twice on purpose, for the reason [`named_branch`] gives — a provider with
970/// no `onevcs` state root carries the grammar, and the journeys over both providers
971/// hold the two to each other.
972pub(crate) fn label_pair(key: &str, value: &str) -> Result<()> {
973    let key_usable = !key.is_empty()
974        && key
975            .chars()
976            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
977    if !key_usable {
978        return Err(Error::Invalid {
979            reason: format!(
980                "{key:?} is not a label key; a key is one or more of A-Z, a-z, 0-9, `_` and `-`"
981            ),
982        });
983    }
984    if value.contains('\n') {
985        return Err(Error::Invalid {
986            reason: format!(
987                "the value of label {key:?} holds a newline; a label's value is one line"
988            ),
989        });
990    }
991    Ok(())
992}
993
994/// A seeded repository side is refused if it holds a session nothing could act on.
995impl Checked for VcsState {
996    fn check(&self) -> Result<()> {
997        readable_version(self.version)?;
998        for session in &self.sessions {
999            // The token names a file under the state root, and a branch goes on to
1000            // spell a ref; both arrive from whoever wrote the document.
1001            if !events::is_safe_name(&session.token.0) {
1002                return Err(Error::Invalid {
1003                    reason: format!("{:?} is not a session token", session.token.0),
1004                });
1005            }
1006            named_branch(&session.branch, "the branch")?;
1007            named_branch(&session.base, "the base")?;
1008        }
1009        for row in &self.preserved {
1010            known_identity(self, &row.identity, "preserved work")?;
1011            named_branch(&row.branch.branch, "the preserved branch")?;
1012            named_branch(&row.branch.base, "the preserved branch's base")?;
1013            // A hold that names a session is checked like the three below: a row
1014            // held by a session nobody opened is the same fiction, and the costly one
1015            // — it is the session an operator is told to wait for or close. A running
1016            // publication holds a branch under no session and names none.
1017            if let Some(token) = row.held_by.as_ref().and_then(|held| held.token.as_ref()) {
1018                opened(self, token, "holding preserved work")?;
1019            }
1020        }
1021        // Both of these name a session, so both are checked twice over: the token
1022        // has to be a plain name, because it goes on to spell the file its stream is
1023        // written in, and it has to name a session this state actually holds. A
1024        // document that closes or publishes a session nobody opened describes a run
1025        // that could not have happened, and answering `recoverable` or `session`
1026        // from it would be answering from a fiction rather than refusing one.
1027        for token in &self.closed_sessions {
1028            opened(self, token, "closed")?;
1029        }
1030        for (token, labels) in &self.session_labels {
1031            opened(self, token, "labelled")?;
1032            for (key, value) in labels {
1033                label_pair(key, value)?;
1034            }
1035        }
1036        for (token, origin) in &self.session_identities {
1037            opened(self, token, "given an identity")?;
1038            // The value as well as the key: this is what `identity_for` answers with,
1039            // and it goes on to spell the slug a change request is opened against and
1040            // the label every one of that session's events carries. An identity this
1041            // provider does not know is one it could not have opened the session for.
1042            known_identity(self, origin, &format!("session {:?}", token.0))?;
1043        }
1044        for publication in &self.publications {
1045            let session = opened(self, &publication.session, "published")?;
1046            named_branch(&publication.branch, "the published branch")?;
1047            // A publication of some other branch than the one the session is on is
1048            // the same kind of fiction, and the harder one to spot afterwards: the
1049            // branch is what a journey asserts the publication was of.
1050            if publication.branch != session.branch {
1051                return Err(Error::Invalid {
1052                    reason: format!(
1053                        "the publication of session {:?} names branch {:?}, but that session is \
1054                         on {:?}",
1055                        publication.session.0, publication.branch, session.branch
1056                    ),
1057                });
1058            }
1059        }
1060        Ok(())
1061    }
1062
1063    /// Every field an older readable document could hold means here what it meant
1064    /// there, and the two the repository side gained in version 4 are absent from one
1065    /// written before them — which is the answer, since that build held no hold and
1066    /// counted no lines.
1067    ///
1068    /// Version 5's field is the exception, and it is carried rather than left at its
1069    /// default: a document written before it *listed preserved work nobody had
1070    /// published*, which is the `no` this build spells out. Left at the default it
1071    /// would read as "nothing here can say", and a consumer's checked-in scenario
1072    /// would start reporting work it had always called unpublished as work that may
1073    /// have landed.
1074    fn carry_forward(&mut self) {
1075        if self.version < 5 {
1076            for row in &mut self.preserved {
1077                row.landed = Landed::No;
1078            }
1079        }
1080        self.version = STATE_VERSION;
1081    }
1082}
1083
1084/// Refuse a record kept about a change request this state does not hold.
1085fn opened_change(state: &HostState, id: &ChangeId, what: &str) -> Result<()> {
1086    if state.changes.iter().any(|change| change.id == *id) {
1087        return Ok(());
1088    }
1089    Err(Error::Invalid {
1090        reason: format!(
1091            "{what} is recorded for change request {:?}, but no change request by that \
1092             identifier was opened",
1093            id.0
1094        ),
1095    })
1096}
1097
1098/// Refuse an identity key this provider was not seeded with.
1099fn known_identity(state: &VcsState, origin: &str, what: &str) -> Result<()> {
1100    if state
1101        .identities
1102        .iter()
1103        .any(|identity| identity.origin == origin)
1104    {
1105        return Ok(());
1106    }
1107    Err(Error::Invalid {
1108        reason: format!(
1109            "{what} belongs to identity {origin:?}, which this provider does not know; {}",
1110            known(state)
1111        ),
1112    })
1113}
1114
1115/// A change request's title, refused when it names nothing.
1116///
1117/// The one thing a real host refuses about a title, and the only one this provider
1118/// may: how long a title may be is the host's own rule rather than `onevcs`'s
1119/// commit-subject rule, and a provider applying the stricter of the two would
1120/// refuse what the host it stands in for accepts.
1121pub(crate) fn titled(title: &str) -> Result<()> {
1122    if title.trim().is_empty() {
1123        return Err(Error::Invalid {
1124            reason: "a change request's title is blank, so it names no change".to_owned(),
1125        });
1126    }
1127    Ok(())
1128}
1129
1130/// The session a token names, refused when this state does not hold one.
1131fn opened<'a>(state: &'a VcsState, token: &SessionToken, what: &str) -> Result<&'a Session> {
1132    if !events::is_safe_name(&token.0) {
1133        return Err(Error::Invalid {
1134            reason: format!("{:?} is not a session token", token.0),
1135        });
1136    }
1137    session_of(state, token).ok_or_else(|| Error::Invalid {
1138        reason: format!(
1139            "session {:?} is {what} here, but no session by that token was opened",
1140            token.0
1141        ),
1142    })
1143}
1144
1145/// A seeded host side is refused if it holds a change nothing could address.
1146impl Checked for HostState {
1147    fn check(&self) -> Result<()> {
1148        readable_version(self.version)?;
1149        for change in &self.changes {
1150            named_branch(&change.base, "the base of a seeded change request")?;
1151            if change.id.0.is_empty() {
1152                return Err(Error::Invalid {
1153                    reason: "a seeded change request carries no identifier".to_owned(),
1154                });
1155            }
1156            // The commit a change request's checks are reported against is the whole
1157            // evidence that a change reached anything, and the real implementation
1158            // refuses a host answer that names none rather than passing a blank one
1159            // through. A seeded one is refused for the same reason.
1160            if change.head_sha.0.trim().is_empty() {
1161                return Err(Error::Invalid {
1162                    reason: format!(
1163                        "the seeded change request {:?} names no commit its checks are \
1164                         reported against",
1165                        change.id.0
1166                    ),
1167                });
1168            }
1169        }
1170        // Both of these are recorded *about* a change request, by `open_change` and
1171        // by nothing else, and both are read back by the id they are keyed under. An
1172        // entry for a change nobody opened is one no call could ever reach, so it is
1173        // refused rather than carried — unlike the checks, logs, and merge outcomes
1174        // below it, which a journey deliberately seeds for a change it has not
1175        // opened yet.
1176        for (id, head) in &self.heads {
1177            opened_change(self, id, "a head")?;
1178            named_branch(head, "the head of a seeded change request")?;
1179        }
1180        for (id, title) in &self.titles {
1181            opened_change(self, id, "a title")?;
1182            // The real host refuses a title that names nothing, so a seeded one is
1183            // refused for the same reason.
1184            titled(title)?;
1185        }
1186        for id in self.bodies.keys() {
1187            // Nothing is refused about the body itself: a host places no shape on
1188            // prose, and a change request opened with an empty one is a scenario.
1189            opened_change(self, id, "a body")?;
1190        }
1191        for (id, reason) in &self.drafts {
1192            opened_change(self, id, "a draft reason")?;
1193            // The publication rule the crate next door states, applied rather than
1194            // restated: a seeded reason no publication could have carried is refused
1195            // where the document is read, not answered from later.
1196            reason.checked()?;
1197        }
1198        for id in &self.made_ready {
1199            opened_change(self, id, "a lifted draft")?;
1200        }
1201        for id in &self.awaiting_checks {
1202            opened_change(self, id, "a draft awaiting its checks")?;
1203        }
1204        for described in &self.described {
1205            opened_change(self, &described.id, "a description")?;
1206            // The real host refuses a title that names nothing, so a seeded one is
1207            // refused for the same reason — and a body is prose, refused for nothing.
1208            if let Some(title) = &described.title {
1209                titled(title)?;
1210            }
1211        }
1212        // Review feedback is read by the change request it is keyed under, and a reply
1213        // names the comment it answers, so a comment on a change nobody opened, an id
1214        // held twice, or a reply to a comment that is not there is one no read could
1215        // answer as the real host would.
1216        for (id, comments) in &self.review_comments {
1217            opened_change(self, id, "review feedback")?;
1218            let mut held = BTreeSet::new();
1219            for comment in comments {
1220                if comment.id.0.trim().is_empty() || !held.insert(&comment.id) {
1221                    return Err(Error::Invalid {
1222                        reason: format!(
1223                            "the review feedback seeded for change request {:?} holds the \
1224                             comment id {:?} more than once, or an empty one",
1225                            id.0, comment.id.0
1226                        ),
1227                    });
1228                }
1229            }
1230            for comment in comments {
1231                // A read hands these on as the RFC 3339 moments the contract promises.
1232                for (field, at) in [
1233                    ("created_at", &comment.created_at),
1234                    ("updated_at", &comment.updated_at),
1235                ] {
1236                    if events::moment(at).is_none() {
1237                        return Err(Error::Invalid {
1238                            reason: format!(
1239                                "comment {:?} on change request {:?} has {field} {at:?}, which \
1240                                 is not an RFC 3339 moment",
1241                                comment.id.0, id.0
1242                            ),
1243                        });
1244                    }
1245                }
1246                if let Some(answered) = &comment.in_reply_to {
1247                    if !held.contains(answered) {
1248                        return Err(Error::Invalid {
1249                            reason: format!(
1250                                "comment {:?} on change request {:?} replies to {:?}, which \
1251                                 that change request does not carry",
1252                                comment.id.0, id.0, answered.0
1253                            ),
1254                        });
1255                    }
1256                }
1257            }
1258        }
1259        for id in &self.closed {
1260            opened_change(self, id, "a closing")?;
1261        }
1262        for id in &self.armed {
1263            opened_change(self, id, "an armed merge")?;
1264        }
1265        // Scripted, like the checks, for a change a journey has not opened yet. What is
1266        // refused is a script no call could answer from: one with no entry, which would
1267        // read as unseeded while saying it was seeded, and an answer about a commit
1268        // that names none.
1269        for (id, readings) in self
1270            .check_readings
1271            .iter()
1272            .chain(&self.check_readings_after_lift)
1273        {
1274            if readings.is_empty() {
1275                return Err(Error::Invalid {
1276                    reason: format!(
1277                        "the checks scripted for change request {:?} hold no reading",
1278                        id.0
1279                    ),
1280                });
1281            }
1282        }
1283        for (id, answers) in &self.head_activity {
1284            if answers.is_empty() {
1285                return Err(Error::Invalid {
1286                    reason: format!(
1287                        "the head activity scripted for change request {:?} holds no answer",
1288                        id.0
1289                    ),
1290                });
1291            }
1292            for answer in answers {
1293                if let ActivityAnswer::Other { head, .. } = answer {
1294                    if head.0.trim().is_empty() {
1295                        return Err(Error::Invalid {
1296                            reason: format!(
1297                                "the head activity scripted for change request {:?} answers \
1298                                 about a commit it does not name",
1299                                id.0
1300                            ),
1301                        });
1302                    }
1303                }
1304            }
1305        }
1306        for branch in &self.deleted_branches {
1307            named_branch(branch, "a deleted branch")?;
1308        }
1309        for call in &self.ledger {
1310            call.checked()?;
1311        }
1312        // A merge time is what `merge_time` answers and what a landing is recorded
1313        // with, so a seeded one that is not a moment is refused here, where the
1314        // document is read, rather than recorded as one.
1315        for (id, at) in &self.merge_times {
1316            if events::moment(at).is_none() {
1317                return Err(Error::Invalid {
1318                    reason: format!(
1319                        "the merge time seeded for change request {:?} is {at:?}, which is \
1320                         not an RFC3339 moment",
1321                        id.0
1322                    ),
1323                });
1324            }
1325        }
1326        Ok(())
1327    }
1328
1329    /// A version 2 document held no bodies, and a version 5 document's checks named
1330    /// no commit; each reads as what it was — change requests opened with none, and
1331    /// checks whose head that build never recorded. There is nothing to fill in — an
1332    /// absent field is already that answer, and a check's commit is the one thing
1333    /// that must never be inferred — so this is the version and nothing else. The one
1334    /// carry a version 8 document needs, the `kind` its draft reasons lack, is done
1335    /// where those are read (`drafts_of_any_version`), because it is a fact about
1336    /// one value's shape rather than about the document.
1337    fn carry_forward(&mut self) {
1338        self.version = STATE_VERSION;
1339    }
1340}
1341
1342/// Refuse a document written at a version this build does not read.
1343///
1344/// Named rather than guessed at: a state whose shape is a later build's reads one
1345/// way here and another way where that version is understood, and for a seeded
1346/// scenario those two readings are two different tests. The refusal names the
1347/// range as well as the version, because "too old" and "too new" are two different
1348/// things to do about it — one is a document to re-seed, the other a build to
1349/// update.
1350fn readable_version(declared: u32) -> Result<()> {
1351    if !(OLDEST_READABLE_VERSION..=STATE_VERSION).contains(&declared) {
1352        return Err(Error::Invalid {
1353            reason: format!(
1354                "the document declares version {declared}; this build reads versions \
1355                 {OLDEST_READABLE_VERSION} to {STATE_VERSION}"
1356            ),
1357        });
1358    }
1359    Ok(())
1360}