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