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