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