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::{
12 ChangeId, ChangeRequest, Check, CheckSource, Error, Identity, MergeOutcome, Recoverable, Result,
13};
14use onevcs::{MergePolicy, Publication, Session, SessionRequest, SessionToken};
15
16use crate::events;
17use crate::store::Checked;
18
19/// The version of the state document this build writes.
20///
21/// A file-backed state outlives the process that wrote it and is read by the next
22/// one, which makes it a stored contract like `onevcs`'s own registry document —
23/// and like that document, the version a document declares decides what is done
24/// with it rather than being guessed at. `3` is the shape the goldens in
25/// `tests/golden/` hold, and those goldens are compared byte for byte, so a change
26/// to the document cannot reach a consumer without the diff saying so.
27///
28/// `2` was what both sides learned when publishing and closing a session came
29/// through the interface: [`VcsState::policy`], [`VcsState::closed_sessions`],
30/// [`VcsState::publications`], and [`HostState::titles`]. `3` is
31/// [`HostState::bodies`], the body a change request was opened with. `4` is the two
32/// fields a `Recoverable` gained inside [`VcsState::preserved`] — the live session
33/// holding a branch, and the lines it would land when it removes more than it adds.
34///
35/// **Every change to the document is versioned, an added field included.** A field
36/// that only ever appears when it holds something is *compatible* — that is what
37/// [`OLDEST_READABLE_VERSION`] is for — but compatible is not the same as
38/// unremarkable: the version is how a consumer's checked-in scenario says which
39/// shape it was written against, and a document that gained a field without saying
40/// so leaves nothing able to tell "this build wrote no body" from "this document
41/// predates bodies". The two answers differ for exactly the journey this crate
42/// exists to support.
43pub const STATE_VERSION: u32 = 4;
44
45/// The oldest document version this build reads.
46///
47/// Every version from here to [`STATE_VERSION`] is read and carried forward on the
48/// way in, which is how `onevcs`'s own registry document treats its older
49/// versions: what separates a readable older version from a refused one is whether
50/// its every field still means here what it meant there. `2` to `3` added a field
51/// and changed none, so a version 2 document reads as one whose change requests
52/// were opened with no body — which is what they were; `3` to `4` added two the same
53/// way, so a version 3 document reads as one whose preserved rows say nothing about a
54/// hold or a line count, which is what they said.
55///
56/// `1` is refused rather than read for the opposite reason: it describes a provider
57/// that could not publish, and every session in it would read back as open — a
58/// wrong answer where a journey asserting on a session it had closed needs a
59/// refusal.
60pub const OLDEST_READABLE_VERSION: u32 = 2;
61
62/// Everything the repository side of a run knows about itself.
63///
64/// Every field is public and serializable, so a journey both seeds a scenario and
65/// asserts on what a run left behind. Everything but the version is omitted when
66/// it holds nothing, so a hand-written document names only the part of a scenario
67/// that matters — and a document written by a build that knew fewer fields still
68/// reads here.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(default)]
71pub struct VcsState {
72 /// The schema version this state was written at. A document that names none is
73 /// the one this build writes.
74 // llmlint: ignore[boundary_inputs_validated] deciding what to do with a version this
75 // build does not read is the whole of the check — and it is in `Checked::check` below,
76 // where a document is read, rather than here where serde only proves the shape.
77 pub version: u32,
78 /// The repository identities this provider can resolve. A
79 /// [`SessionRequest::repo`] naming none of them is refused, the way an
80 /// unregistered repository is.
81 #[serde(skip_serializing_if = "Vec::is_empty")]
82 pub identities: Vec<Identity>,
83 /// Every session opened or seeded, in the order they were opened.
84 #[serde(skip_serializing_if = "Vec::is_empty")]
85 pub sessions: Vec<Session>,
86 /// Which identity each session belongs to.
87 ///
88 /// Beyond the sketch this crate was specified from, and unavoidable: a
89 /// [`Session`] carries no identity, and a [`Recoverable`] must name one — so
90 /// preserving a session's branch could not answer the question `recoverable`
91 /// asks without this. `open_session` records it; nothing else writes it.
92 // llmlint: ignore[invalid_states_unrepresentable] an identity key is a `String`
93 // everywhere the crate this mirrors spells one — `Recoverable.identity`,
94 // `Identity.origin`, the registry document's own map key — and a newtype here would
95 // make a seeded state disagree with the types it is made of. Every value written to
96 // this map came out of `identity_of`, so it names an identity this provider holds.
97 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
98 pub session_identities: BTreeMap<SessionToken, String>,
99 /// Preserved work, newest last, as `recoverable` reports it.
100 ///
101 /// [`Recoverable`] rather than `PreservedBranch` — it *contains* the preserved
102 /// branch and adds the identity, the checkout, why the workstream stopped, and
103 /// the command that lands it, none of which are derivable from the branch
104 /// alone. One list rather than two that could disagree.
105 #[serde(skip_serializing_if = "Vec::is_empty")]
106 pub preserved: Vec<Recoverable>,
107 /// The sessions that have been closed. Every other session here is open.
108 ///
109 /// One way to say closed rather than two, so a scenario written by hand names
110 /// only the sessions whose lifecycle is not the one they were opened in.
111 // llmlint: ignore[invalid_states_unrepresentable] keyed by session token, exactly as
112 // `session_identities` above is and for the same reason: this document is a scenario
113 // somebody writes by hand, and a session is named once under `sessions` with the rest
114 // of the state keyed to it rather than nested inside a shape that could hold only one
115 // arrangement. A token here that names no opened session is refused in
116 // `Checked::check`, where the document is read — the same trust boundary every other
117 // cross-reference in it is checked at.
118 #[serde(skip_serializing_if = "BTreeSet::is_empty")]
119 pub closed_sessions: BTreeSet<SessionToken>,
120 /// The policy this provider publishes under.
121 ///
122 /// The answer a rules file gives the real implementation, which a provider has
123 /// none of — so a journey states it, and unset is the policy the contract's own
124 /// `default:` names ([`DEFAULT_PUBLICATION`](crate::DEFAULT_PUBLICATION)). A
125 /// per-run policy narrows it through [`MergePolicy::narrow`], which is the
126 /// rules system's rule rather than a restatement of it here.
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub policy: Option<MergePolicy>,
129 /// Every publication this provider performed, in the order it performed them.
130 ///
131 /// Both a record a journey asserts on and the answer to "has this session been
132 /// published already": a second publication of a session that landed has
133 /// nothing the base does not already carry, which is what the real
134 /// implementation reports for the same reason.
135 // llmlint: ignore[invalid_states_unrepresentable] this holds `onevcs::Publication`
136 // verbatim — the value `Vcs::publish` handed back, carrying its own session and branch
137 // — so a journey asserts on exactly what a caller would receive. A shape that made
138 // "this publication is of some other session's branch" unrepresentable could not hold
139 // that type, and would be a second spelling of the answer the crate next door already
140 // has. The cross-reference is checked in `Checked::check` instead, where the document
141 // is read.
142 #[serde(skip_serializing_if = "Vec::is_empty")]
143 pub publications: Vec<Publication>,
144}
145
146/// A repository side that knows nothing, at the version this build writes.
147impl Default for VcsState {
148 fn default() -> Self {
149 Self {
150 version: STATE_VERSION,
151 identities: Vec::new(),
152 sessions: Vec::new(),
153 session_identities: BTreeMap::new(),
154 preserved: Vec::new(),
155 closed_sessions: BTreeSet::new(),
156 policy: None,
157 publications: Vec::new(),
158 }
159 }
160}
161
162/// Everything the remote-host side of a run knows about itself.
163///
164/// Omitted-when-empty and versioned for the same reasons [`VcsState`] is.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(default)]
167pub struct HostState {
168 /// The schema version this state was written at. A document that names none is
169 /// the one this build writes.
170 // llmlint: ignore[boundary_inputs_validated] as on `VcsState::version`: the decision
171 // about an unreadable version is made in `Checked::check`, where the document is read.
172 pub version: u32,
173 /// Who the host says is calling. Empty is refused, exactly as a `gh` that
174 /// reports no authenticated user is.
175 // llmlint: ignore[invalid_states_unrepresentable] the interface this satisfies is
176 // `authenticated_user() -> Result<String>`, so the login is a `String` by contract and
177 // the one unusable value — a host that names nobody — is refused where it is read
178 // rather than made unrepresentable in a state a journey writes by hand.
179 pub authenticated_user: String,
180 /// Every change request that has been opened or seeded.
181 #[serde(skip_serializing_if = "Vec::is_empty")]
182 pub changes: Vec<ChangeRequest>,
183 /// The head branch each change request was opened from.
184 ///
185 /// Beyond the sketch, and unavoidable: [`ChangeRequest`] records only the base
186 /// it targets, and `find_changes` matches on the head as well.
187 // llmlint: ignore[invalid_states_unrepresentable] the matching `ChangeSpec.head` and
188 // `ChangeRequest.base` are `String` in the contract this mirrors, and a validated ref
189 // type here would disagree with them. Every value written to this map went through
190 // `addressable` in `open_change` first, which is the same refusal the real
191 // implementation makes at the same point.
192 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
193 pub heads: BTreeMap<ChangeId, String>,
194 /// The title each change request was opened under.
195 ///
196 /// Beyond the sketch, and for the same reason [`heads`](HostState::heads) is:
197 /// [`ChangeRequest`] records neither, and the title is what a publication's
198 /// commit subject becomes — so a journey asserting that the subject it asked
199 /// for is the one the host was given has nowhere else to read it.
200 // llmlint: ignore[invalid_states_unrepresentable] this records the `ChangeSpec.title`
201 // the contract fixes as a `String`, so a validated type here would disagree with the
202 // one it mirrors. `Subject` is not that type: it is `onevcs`'s rule for a *commit
203 // subject*, and a host's own limit is its own — spelling it here would
204 // refuse a seeded title a real host accepts, which is drift in the direction that
205 // looks like rigour. What the host itself refuses is a title that names nothing, and
206 // that is refused below and in `open_change`, at the boundary the value arrives at.
207 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
208 pub titles: BTreeMap<ChangeId, String>,
209 /// The body each change request was opened with, for the change requests that
210 /// were opened with one.
211 ///
212 /// Beside [`titles`](HostState::titles) and for its reason: the body is what a
213 /// caller passes on the `PublishRequest` and nothing else records it, so a
214 /// journey asserting that the body it drafted is the one the host was given has
215 /// nowhere else to read it. A change request with no entry was opened with no
216 /// body at all, which is what a publication nobody gave one does — the two are
217 /// different scenarios, and an empty string is the first rather than the second.
218 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
219 pub bodies: BTreeMap<ChangeId, String>,
220 /// The checks the host reports on each change request. A change with no entry
221 /// has no checks, which is what a repository with no CI reports.
222 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
223 pub checks: BTreeMap<ChangeId, Vec<Check>>,
224 /// The log the host hands over for a check, keyed by change request and then by
225 /// check name. Beyond the sketch: `check_log` is one of the six methods, and
226 /// without this the only log a journey could asssert on is a synthesized one.
227 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
228 pub check_logs: BTreeMap<ChangeId, BTreeMap<String, String>>,
229 /// Which sources this host answers about its checks from, which is what the
230 /// real implementation reports alongside them.
231 ///
232 /// Unset is a credential allowed to read everything: the whole rollup, exactly
233 /// what a host with nothing to hide reports. A journey states a narrower set to
234 /// be the credential the real one meets in CI — a fine-grained token, which
235 /// cannot read check runs at all and sees GitHub Actions and nothing else — and
236 /// states an *empty* one to be a credential that can read no source at all,
237 /// which is a refusal rather than "no checks". Unset and empty are therefore
238 /// different scenarios, which is why this is an `Option` rather than a set whose
239 /// emptiness means "not stated".
240 #[serde(skip_serializing_if = "Option::is_none")]
241 pub check_sources: Option<BTreeSet<CheckSource>>,
242 /// What merging each change request did.
243 ///
244 /// Both a script and a record: an entry seeded here is what `merge` answers,
245 /// whatever the policy asks for — which is how a journey expresses a host that
246 /// queues or refuses — and a merge the policy decided is written back here.
247 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
248 pub merges: BTreeMap<ChangeId, MergeOutcome>,
249}
250
251/// Who a host with nothing seeded says is calling.
252///
253/// A host that answers nobody is refused by the real implementation, so a default
254/// state that answered nobody would be a provider that cannot run a publication
255/// until it is configured.
256pub const DEFAULT_AUTHENTICATED_USER: &str = "onevcs-testing";
257
258impl Default for HostState {
259 fn default() -> Self {
260 Self {
261 version: STATE_VERSION,
262 authenticated_user: DEFAULT_AUTHENTICATED_USER.to_owned(),
263 changes: Vec::new(),
264 heads: BTreeMap::new(),
265 titles: BTreeMap::new(),
266 bodies: BTreeMap::new(),
267 checks: BTreeMap::new(),
268 check_logs: BTreeMap::new(),
269 check_sources: None,
270 merges: BTreeMap::new(),
271 }
272 }
273}
274
275/// The identity a session request names, or the reason none of them is it.
276///
277/// Three ways to name one, mirroring what the registry accepts: the identity key
278/// itself, the `owner/name` tail of it, or the bare repository name.
279pub(crate) fn identity_of<'a>(state: &'a VcsState, origin_or_path: &str) -> Option<&'a Identity> {
280 let wanted = origin_or_path.trim_end_matches('/');
281 state
282 .identities
283 .iter()
284 .find(|identity| identity.origin == wanted)
285 .or_else(|| {
286 state.identities.iter().find(|identity| {
287 identity
288 .origin
289 .rsplit('/')
290 .next()
291 .is_some_and(|name| name == wanted)
292 || identity.origin.ends_with(&format!("/{wanted}"))
293 })
294 })
295}
296
297/// The known identities, as a refusal names them.
298pub(crate) fn known(state: &VcsState) -> String {
299 if state.identities.is_empty() {
300 return "this provider was seeded with no identities".to_owned();
301 }
302 let names: Vec<&str> = state
303 .identities
304 .iter()
305 .map(|identity| identity.origin.as_str())
306 .collect();
307 format!("it knows {}", names.join(", "))
308}
309
310/// The identity a session belongs to, or the reason this provider cannot say.
311///
312/// `open_session` records it and nothing else writes it, so a session that arrived
313/// in a hand-written scenario without one is refused here rather than published
314/// against a repository nobody named.
315pub(crate) fn identity_for(state: &VcsState, token: &SessionToken) -> Result<String> {
316 state
317 .session_identities
318 .get(token)
319 .cloned()
320 .ok_or_else(|| Error::Invalid {
321 reason: format!(
322 "this provider has no record of session {:?}, so it cannot say which identity \
323 its work belongs to",
324 token.0
325 ),
326 })
327}
328
329/// The session a token names.
330pub(crate) fn session_of<'a>(state: &'a VcsState, token: &SessionToken) -> Option<&'a Session> {
331 state
332 .sessions
333 .iter()
334 .find(|session| session.token == *token)
335}
336
337/// The branch a request asks for, or the one that is derived from the token.
338pub(crate) fn requested_branch(req: &SessionRequest, token: &SessionToken) -> Result<String> {
339 let name = req
340 .branch
341 .clone()
342 .unwrap_or_else(|| format!("onevcs/{}", token.0));
343 named_branch(&name, "the branch")?;
344 Ok(name)
345}
346
347/// A branch name, refused here if git would refuse it.
348///
349/// The real implementation asks `git check-ref-format`, which is the parser that
350/// decides; a provider with no git carries `git-check-ref-format(1)`'s rules
351/// instead. That is a restatement, so it is gated rather than trusted:
352/// `refs.rs` in the suite runs both this and git itself over a table of names
353/// and holds them to each other, because a copy of somebody else's grammar with
354/// no gate is a copy that drifts.
355///
356/// One deliberate difference, and the gate knows about it: a leading `-` is
357/// refused here even though git accepts it as a ref, because such a name reaches
358/// a command line as an option rather than as the branch it spells.
359pub(crate) fn named_branch(value: &str, what: &str) -> Result<()> {
360 // Rule 1 is per slash-separated component; the rest are about the whole name.
361 let components_usable = !value.is_empty()
362 && value.split('/').all(|component| {
363 !component.is_empty() && !component.starts_with('.') && !component.ends_with(".lock")
364 });
365 let usable = components_usable
366 && !value.starts_with('-')
367 && !value.contains("..")
368 && !value.contains("@{")
369 && !value.ends_with('.')
370 && !value.ends_with('/')
371 && !value.chars().any(|c| {
372 c.is_whitespace() || c.is_ascii_control() || c == '\u{7f}' || "~^:?*[\\".contains(c)
373 });
374 if !usable {
375 return Err(Error::Invalid {
376 reason: format!("{what} {value:?} is a name git would not accept"),
377 });
378 }
379 Ok(())
380}
381
382/// A seeded repository side is refused if it holds a session nothing could act on.
383impl Checked for VcsState {
384 fn check(&self) -> Result<()> {
385 readable_version(self.version)?;
386 for session in &self.sessions {
387 // The token names a file under the state root, and a branch goes on to
388 // spell a ref; both arrive from whoever wrote the document.
389 if !events::is_safe_name(&session.token.0) {
390 return Err(Error::Invalid {
391 reason: format!("{:?} is not a session token", session.token.0),
392 });
393 }
394 named_branch(&session.branch, "the branch")?;
395 named_branch(&session.base, "the base")?;
396 }
397 for row in &self.preserved {
398 known_identity(self, &row.identity, "preserved work")?;
399 named_branch(&row.branch.branch, "the preserved branch")?;
400 named_branch(&row.branch.base, "the preserved branch's base")?;
401 // The hold names a session, so it is checked like the three below: a row
402 // held by a session nobody opened is the same fiction, and the costly one
403 // — it is the session an operator is told to wait for or close.
404 if let Some(held) = &row.held_by {
405 opened(self, &held.token, "holding preserved work")?;
406 }
407 }
408 // Both of these name a session, so both are checked twice over: the token
409 // has to be a plain name, because it goes on to spell the file its stream is
410 // written in, and it has to name a session this state actually holds. A
411 // document that closes or publishes a session nobody opened describes a run
412 // that could not have happened, and answering `recoverable` or `session`
413 // from it would be answering from a fiction rather than refusing one.
414 for token in &self.closed_sessions {
415 opened(self, token, "closed")?;
416 }
417 for (token, origin) in &self.session_identities {
418 opened(self, token, "given an identity")?;
419 // The value as well as the key: this is what `identity_for` answers with,
420 // and it goes on to spell the slug a change request is opened against and
421 // the label every one of that session's events carries. An identity this
422 // provider does not know is one it could not have opened the session for.
423 known_identity(self, origin, &format!("session {:?}", token.0))?;
424 }
425 for publication in &self.publications {
426 let session = opened(self, &publication.session, "published")?;
427 named_branch(&publication.branch, "the published branch")?;
428 // A publication of some other branch than the one the session is on is
429 // the same kind of fiction, and the harder one to spot afterwards: the
430 // branch is what a journey asserts the publication was of.
431 if publication.branch != session.branch {
432 return Err(Error::Invalid {
433 reason: format!(
434 "the publication of session {:?} names branch {:?}, but that session is \
435 on {:?}",
436 publication.session.0, publication.branch, session.branch
437 ),
438 });
439 }
440 }
441 Ok(())
442 }
443
444 /// Nothing but the version: every field an older readable document could hold
445 /// means here what it meant there, and the two the repository side gained in
446 /// version 4 are absent from one written before them — which is the answer, since
447 /// that build held no hold and counted no lines.
448 fn carry_forward(&mut self) {
449 self.version = STATE_VERSION;
450 }
451}
452
453/// Refuse a record kept about a change request this state does not hold.
454fn opened_change(state: &HostState, id: &ChangeId, what: &str) -> Result<()> {
455 if state.changes.iter().any(|change| change.id == *id) {
456 return Ok(());
457 }
458 Err(Error::Invalid {
459 reason: format!(
460 "{what} is recorded for change request {:?}, but no change request by that \
461 identifier was opened",
462 id.0
463 ),
464 })
465}
466
467/// Refuse an identity key this provider was not seeded with.
468fn known_identity(state: &VcsState, origin: &str, what: &str) -> Result<()> {
469 if state
470 .identities
471 .iter()
472 .any(|identity| identity.origin == origin)
473 {
474 return Ok(());
475 }
476 Err(Error::Invalid {
477 reason: format!(
478 "{what} belongs to identity {origin:?}, which this provider does not know; {}",
479 known(state)
480 ),
481 })
482}
483
484/// A change request's title, refused when it names nothing.
485///
486/// The one thing a real host refuses about a title, and the only one this provider
487/// may: how long a title may be is the host's own rule rather than `onevcs`'s
488/// commit-subject rule, and a provider applying the stricter of the two would
489/// refuse what the host it stands in for accepts.
490pub(crate) fn titled(title: &str) -> Result<()> {
491 if title.trim().is_empty() {
492 return Err(Error::Invalid {
493 reason: "a change request's title is blank, so it names no change".to_owned(),
494 });
495 }
496 Ok(())
497}
498
499/// The session a token names, refused when this state does not hold one.
500fn opened<'a>(state: &'a VcsState, token: &SessionToken, what: &str) -> Result<&'a Session> {
501 if !events::is_safe_name(&token.0) {
502 return Err(Error::Invalid {
503 reason: format!("{:?} is not a session token", token.0),
504 });
505 }
506 session_of(state, token).ok_or_else(|| Error::Invalid {
507 reason: format!(
508 "session {:?} is {what} here, but no session by that token was opened",
509 token.0
510 ),
511 })
512}
513
514/// A seeded host side is refused if it holds a change nothing could address.
515impl Checked for HostState {
516 fn check(&self) -> Result<()> {
517 readable_version(self.version)?;
518 for change in &self.changes {
519 named_branch(&change.base, "the base of a seeded change request")?;
520 if change.id.0.is_empty() {
521 return Err(Error::Invalid {
522 reason: "a seeded change request carries no identifier".to_owned(),
523 });
524 }
525 // The commit a change request's checks are reported against is the whole
526 // evidence that a change reached anything, and the real implementation
527 // refuses a host answer that names none rather than passing a blank one
528 // through. A seeded one is refused for the same reason.
529 if change.head_sha.0.trim().is_empty() {
530 return Err(Error::Invalid {
531 reason: format!(
532 "the seeded change request {:?} names no commit its checks are \
533 reported against",
534 change.id.0
535 ),
536 });
537 }
538 }
539 // Both of these are recorded *about* a change request, by `open_change` and
540 // by nothing else, and both are read back by the id they are keyed under. An
541 // entry for a change nobody opened is one no call could ever reach, so it is
542 // refused rather than carried — unlike the checks, logs, and merge outcomes
543 // below it, which a journey deliberately seeds for a change it has not
544 // opened yet.
545 for (id, head) in &self.heads {
546 opened_change(self, id, "a head")?;
547 named_branch(head, "the head of a seeded change request")?;
548 }
549 for (id, title) in &self.titles {
550 opened_change(self, id, "a title")?;
551 // The real host refuses a title that names nothing, so a seeded one is
552 // refused for the same reason.
553 titled(title)?;
554 }
555 for id in self.bodies.keys() {
556 // Nothing is refused about the body itself: a host places no shape on
557 // prose, and a change request opened with an empty one is a scenario.
558 opened_change(self, id, "a body")?;
559 }
560 Ok(())
561 }
562
563 /// A version 2 document held no bodies, and reads as what it was: change
564 /// requests opened with none. There is nothing to fill in — an absent entry is
565 /// already that answer — so this is the version and nothing else.
566 fn carry_forward(&mut self) {
567 self.version = STATE_VERSION;
568 }
569}
570
571/// Refuse a document written at a version this build does not read.
572///
573/// Named rather than guessed at: a state whose shape is a later build's reads one
574/// way here and another way where that version is understood, and for a seeded
575/// scenario those two readings are two different tests. The refusal names the
576/// range as well as the version, because "too old" and "too new" are two different
577/// things to do about it — one is a document to re-seed, the other a build to
578/// update.
579fn readable_version(declared: u32) -> Result<()> {
580 if !(OLDEST_READABLE_VERSION..=STATE_VERSION).contains(&declared) {
581 return Err(Error::Invalid {
582 reason: format!(
583 "the document declares version {declared}; this build reads versions \
584 {OLDEST_READABLE_VERSION} to {STATE_VERSION}"
585 ),
586 });
587 }
588 Ok(())
589}