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