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