Skip to main content

onevcs_testing/
state.rs

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