riff_core/record.rs
1//! The records of the log of `riff-server`.
2//!
3//! # Design
4//!
5//! Each change that must not be lost is a [`Record`] in one log
6//! (01M3T410XDD9W4EC0Y68FAA7XN). A record
7//! has a position (1, 2, 3, and so on), the time of its write, its
8//! cause, and one [`Change`]. The change names say what happened, in the
9//! past tense. These Rust types are the schema of the log.
10//!
11//! The fields next to the change are the [`Envelope`]: one type that
12//! [`Record`] and the tolerant reader [`Line::parse`] share. So a new
13//! field of the envelope gets to the writer, the reader and each start.
14//!
15//! The cause is in the envelope: the caller ([`By`]) and the kind of
16//! the command (01M3X4Z60G1FXQTDC5XDJ05BAX). So the log alone shows who made each
17//! change. A record from before this rule has no cause: it reads, and
18//! its cause is not known.
19//!
20//! The envelope also has the call ID of the command: the field `call`
21//! (01M48VFFY5CK9MRXJESV2NHY5F). It came after release 1.0.0. A record
22//! of a command with no call ID, and each record of 1.0.0, has none. A
23//! build of 1.0.0 skips the field. `riff-server` uses it to give a
24//! repeated call the reply of the first try, also after a start.
25//!
26//! # One layout for each type
27//!
28//! Each type of the log, the wire and the checkpoint has one struct
29//! (01M49W17GGV1K5FZEKJRPV0GNA). A tolerant reader holds the struct
30//! itself, as [`Line::parse`] holds the [`Envelope`] with
31//! `#[serde(flatten)]`: it never keeps a second list of the fields.
32//! Each conversion between two such types names each field of its
33//! source in a destructure with no `..` (01M49W17M2JVNYSHDQJWHZ8A7X):
34//! a field that the target does not need is named with `_`. So a new
35//! field fails the build at each place that must carry it, and no
36//! review must find it:
37//!
38//! ```compile_fail
39//! use riff_core::record::Envelope;
40//!
41//! // A conversion that does not name the field `call` does not build.
42//! fn written(envelope: Envelope) -> (u64, u64) {
43//! let Envelope { position, written_at_ms, by: _, command: _ } = envelope;
44//! (position, written_at_ms)
45//! }
46//! ```
47//!
48//! Each type has a round-trip test: each variant with each optional
49//! field set, written and read back (01M49W18ETF4KZJN848M91VY35). The
50//! records go through the real path of the store in
51//! `riff-server/tests/calls.rs`.
52//!
53//! A record names a session by its URI with no lead mark and no claims:
54//! the who and the place at the time of the change
55//! (01M3T411QW1SQV12RJVATEJ8YD). So a replay knows
56//! where each session was.
57//!
58//! # The rules for a change of a record (01M3T4111PFM0C6KPREWFS9EQQ)
59//!
60//! - A new field has a default, and the default means "as before". An
61//! old build skips a field that it does not know.
62//! - Do not change the type or the meaning of a field. Do not use the
63//! name of a removed field again.
64//! - A new kind of change gets a new name. A build that does not know a
65//! kind skips the record ([`Line::Unknown`]).
66//! - A kind is never renamed, and the name of a removed kind is never
67//! used again (01M3XM2C3MND6YB24SGZ565353). The file `kinds.json`
68//! in the directory of each release under
69//! `crates/riff-server/tests/fixtures/` lists the new kinds of the
70//! release (01M43GSRSDJMGAH8SR1GD4Z3XF). The file of 1.0.0 also
71//! lists each field of the envelope. A test fails when a name of a
72//! list is gone from the code.
73//! - The enum [`Change`] and the list [`Change::KINDS`] come from one
74//! macro. So a variant cannot be missing from the list. The order of
75//! the list is not a part of the format.
76//! - A field with a set of named values that can grow has the value
77//! `other`: the class in [`By`], the [`Scope`] of a pause, and the
78//! reason of a start ([`StartReason`]). A build reads a value that it
79//! does not know as `other`: a text, and each other form of JSON.
80//! `apply` stores nothing for such a value. Such a record counts as a
81//! skipped record ([`Record::other`]): the build writes no checkpoint
82//! past it (01M3XM2C18TT8VSKGD77YPZG53). The `state` of a `pause_set`
83//! has two values and no `other`.
84//! - A `posted` record has two such parts too
85//! (01M3XSF90E9JYYTC13D9THY4WE): the kind of the message
86//! ([`Kind`](crate::wire::Kind)), and each selector of its `to`
87//! ([`Selector`]). A selector that the build does not know is a
88//! selector `other`: an object with a field or a value that the build
89//! does not know, and each other form of JSON. It keeps its JSON as
90//! it came, and it matches no session. The message stays in its
91//! thread, and a reader shows it as a message.
92//! - A session URI keeps each query part that the build does not know,
93//! as it came ([`SessionUri::other`], 01M3XYYSY536AEJVERBPTQFQYX). The session is the same
94//! session, and the part gives no mark: no lead and no claim. Such a
95//! record counts as a skipped record too.
96//! - `command` is text. A reader takes a kind of command that it does
97//! not know as text.
98//! - Only the read of JSON takes a value of a later build. A call with
99//! such a kind, such a selector or such a URI of its caller is
100//! refused.
101//!
102//! # Each type with named values in a record
103//!
104//! The list has each enum that a record holds, and each struct that
105//! refused a field that it does not know. A struct that is not in the
106//! list skips such a field. A new type of these two sorts in a record
107//! needs a line here.
108//!
109//! | Type | Where | A value that the build does not know |
110//! |---|---|---|
111//! | [`Change`] | `change` | The record is skipped ([`Line::Unknown`]). |
112//! | [`By`] | `by` | `other` |
113//! | [`Scope`] | `pause_set` | `other` |
114//! | [`StartReason`] | `session_started` | `other` |
115//! | [`Kind`](crate::wire::Kind) | the message of `posted` | `other` |
116//! | [`Selector`] | the `to` of the message of `posted` | `other`: a new field, a new value of a field, and each other form of JSON |
117//! | [`RiffState`] | `pause_set` | The line does not read. The set never grows: a pause is set or ended. A new sort of pause is a new [`Scope`]. |
118//!
119//! # Each text with a grammar in a record
120//!
121//! The list has each text that the read of a record checks or takes
122//! apart, and each number. A text that is not in the list is free text:
123//! the read takes each value. A new text with a grammar in a record
124//! needs a line here.
125//!
126//! | Text | Where | A value that the build does not know |
127//! |---|---|---|
128//! | The query of a session URI ([`SessionUri`]) | `session` of a change, `from` of a message | The URI keeps the part, and the record counts as a skipped record: a part with a new name, `lead` with a value other than `true`, a second `session`, and a `claim` with a character that is not allowed. |
129//! | The other parts of a session URI | the same | The line does not read. They never grow: `riff://USER@HOST`, then `/OWNER/REPO` or `/-`, then `#WORKTREE`, and each part holds only ASCII letters, digits, `-`, `_`, `.` and `~`. A new fact of a session is a new query part. |
130//! | The session ID of a session URI | the `session` part of the query | The line does not read when the ID is empty or has a character that is not allowed. The grammar never grows: the ID is a name with only ASCII letters, digits, `-`, `_`, `.` and `~`, and a new fact of a session is a new query part. |
131//! | A thread name ([`ThreadName`]) | `thread` | The line does not read. The grammar never grows: a text that is not empty and has no white space. Each new sort of thread name fits it, as `dm:` did. A name that starts with `dm:` reads with each character, also white space. The read does not take a name apart. |
132//! | The caller in `by` | `{"session":"USER/ID"}` | `other` ([`By`]) |
133//! | A session in `woken` ([`Who`]) | `posted` | The read checks no character. A field that the build does not know is skipped. |
134//! | The signature and the payload of a message | `sig`, `payload` | The read keeps the text, and checks nothing. A reader checks them: a text that does not check gives a message that is not verified. |
135//! | An item, an email, a user, the ID of a riff, a body, a command, the reason of a hold, the title of a wave | `item`, `email`, `user`, `riff_id`, `body`, `command`, `reason`, `title` | Free text. |
136//! | An item of a plan, a need, a done need | `items`, `needs`, `done` of `plan_set` | Free text: `handle` of `plan` checks the form `issue-N`, and the read does not. |
137//! | A time, a position, a seq, a count, the number of a wave | `written_at_ms`, `at_ms`, `due_ms`, `position`, `seq`, `after_secs`, `per_host`, `number` | The line does not read. A number is a whole number that is not negative, and its type never changes (the rule of a field). `per_host` is at most 65535. |
138//! | A mark | `worker`, `must_clear`, `admin` | The line does not read. A mark is `true` or `false`, and its type never changes. |
139//!
140//! The header of a chunk has the number of the format. A header of a
141//! later format stops the load (01M3T411F3K6FD28R3Q3ZE4VCN): this is
142//! the one sign that a build must not read the log.
143//!
144//! # Example
145//!
146//! ```
147//! use riff_core::name::Who;
148//! use riff_core::record::{By, Change, Claimed, Envelope, Line, Record};
149//!
150//! let record = Record {
151//! envelope: Envelope {
152//! position: 1234,
153//! written_at_ms: 1_790_000_000_000,
154//! by: Some(By::Session(Who::new("ann", Some("s1"))?)),
155//! command: Some("claim".into()),
156//! call: None,
157//! },
158//! change: Change::Claimed(Claimed {
159//! session: "riff://ann@heron/acme/app?session=s1".parse()?,
160//! thread: "acme/app".parse()?,
161//! item: "issue-7".into(),
162//! }),
163//! };
164//! let line = serde_json::to_string(&record).unwrap();
165//! assert_eq!(
166//! line,
167//! r#"{"position":1234,"written_at_ms":1790000000000,"by":{"session":"ann/s1"},"command":"claim","change":{"claimed":{"session":"riff://ann@heron/acme/app?session=s1","thread":"acme/app","item":"issue-7"}}}"#
168//! );
169//! assert_eq!(Line::parse(&line)?, Line::Record(Box::new(record)));
170//!
171//! // A record with no cause reads. Its cause is not known.
172//! let old = r#"{"position":7,"written_at_ms":1,"change":{"member_invited":{"email":"ann@acme.io"}}}"#;
173//! let Line::Record(old) = Line::parse(old)? else { panic!("a known kind") };
174//! assert_eq!((old.envelope.by, old.envelope.command), (None, None));
175//! # Ok::<(), Box<dyn std::error::Error>>(())
176//! ```
177
178use std::collections::BTreeSet;
179
180use serde::{Deserialize, Deserializer, Serialize, Serializer};
181
182use crate::name::{SessionUri, ThreadName, Who};
183use crate::selector::Selector;
184use crate::wire::{Idle, Message, RiffState, StartReason};
185
186/// Makes the enum [`Change`], the list [`Change::KINDS`] and
187/// [`Change::kind`] from one list of variants. Each variant gives its
188/// name in the JSON of a record. So a variant cannot be missing from the
189/// list of the kinds.
190macro_rules! changes {
191 ($($(#[$doc:meta])* $variant:ident($body:ty) = $kind:literal,)*) => {
192 /// What happened.
193 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
194 pub enum Change {
195 $($(#[$doc])* #[serde(rename = $kind)] $variant($body),)*
196 }
197
198 impl Change {
199 /// The name of each kind of change, as the JSON of a record
200 /// has it. The order of the list is not a part of the
201 /// format: a reader finds a kind by its name. So a new kind
202 /// can go in at any place.
203 pub const KINDS: &[&str] = &[$($kind),*];
204
205 /// The name of the kind of this change.
206 pub fn kind(&self) -> &'static str {
207 match self {
208 $(Change::$variant(_) => $kind,)*
209 }
210 }
211 }
212 };
213}
214
215// ANCHOR: record
216/// One line of the log: its envelope and its change.
217#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
218pub struct Record {
219 #[serde(flatten)]
220 pub envelope: Envelope,
221 pub change: Change,
222}
223
224/// The fields of a record next to its change. See the design of the
225/// module.
226#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
227pub struct Envelope {
228 /// The place of the record in the one log of the riff: 1, 2, 3, and
229 /// so on.
230 pub position: u64,
231 /// The time when the server made the record, in milliseconds since
232 /// the Unix epoch.
233 pub written_at_ms: u64,
234 /// The caller of the command that made the record. `None` in a
235 /// record from before this field: the cause is not known.
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub by: Option<By>,
238 /// The kind of the command that made the record, for example
239 /// `claim`. It is text: a reader takes a kind that it does not know
240 /// as text.
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub command: Option<String>,
243 /// The call ID of the command that made the record: the header
244 /// `riff-call` of the call (01M48VFFY5CK9MRXJESV2NHY5F). `None` in a
245 /// record of a command with no call ID, and in a record from before
246 /// this field. A build that does not know the field skips it.
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub call: Option<String>,
249}
250
251changes! {
252 /// A message, with its seq in its thread and the sessions that it
253 /// woke.
254 Posted(Box<Posted>) = "posted",
255 /// A session joined a thread.
256 JoinedThread(Member) = "joined_thread",
257 /// A session left a thread. It is no longer the lead there.
258 LeftThread(Member) = "left_thread",
259 /// A session took a claim. It replaces the old holder.
260 Claimed(Claimed) = "claimed",
261 /// A claim is free. The record of the last claim of a worker, made
262 /// by its own release, says that the worker must clear its context.
263 Released(Released) = "released",
264 /// A session became the lead of its user in a repository thread.
265 LeadSet(Member) = "lead_set",
266 /// A setting of the riff changed.
267 SettingChanged(SettingChanged) = "setting_changed",
268 /// A session had no sign of life for `SESSION_EXPIRY`. The state
269 /// drops it: its read cursors, its memberships, its claims, its lead,
270 /// and each direct thread whose two sessions are gone.
271 SessionForgotten(Forgotten) = "session_forgotten",
272 /// A session started, or came with no new start. The record has the
273 /// worker mark of the session (01M3X9X9M079WGFPJZHNXH9VEP).
274 SessionStarted(SessionStarted) = "session_started",
275 /// A pause is set or ended: the pause of the whole riff, or the
276 /// pause of one repository (01M3XAHZG26ECNARX35JD73YXJ).
277 PauseSet(PauseSet) = "pause_set",
278 /// The riff has its ID. It is the first record of a new log.
279 RiffMade(RiffMade) = "riff_made",
280 /// An email signed in for the first time, and holds its USER.
281 PersonJoined(PersonJoined) = "person_joined",
282 /// An email is a member of the riff.
283 MemberInvited(Email) = "member_invited",
284 /// An email is no member of the riff. Each sign-in of its USER from
285 /// before this record is ended.
286 MemberRemoved(Email) = "member_removed",
287 /// An email is an admin that the owner made, or it is not.
288 AdminSet(AdminSet) = "admin_set",
289 /// An email is the owner. With no email, the owner is gone, and the
290 /// riff has no owner. The request for the owner role ends.
291 OwnerSet(OwnerSet) = "owner_set",
292 /// An admin asks for the owner role.
293 OwnerAsked(OwnerAsked) = "owner_asked",
294 /// The owner keeps the owner role that an admin asked for.
295 OwnerDenied(Email) = "owner_denied",
296 /// Each sign-in of a USER from before this record is ended.
297 SigninsEnded(SigninsEnded) = "signins_ended",
298 /// A lead holds an item, with a reason: no worker can claim it. A
299 /// second record for the same item replaces the reason. New in
300 /// 1.1.0.
301 ItemHeld(ItemHeld) = "item_held",
302 /// The hold of an item ends. New in 1.1.0.
303 ItemFreed(ItemFreed) = "item_freed",
304 /// The plan of a repository thread: it replaces the plan of the
305 /// thread. New in 1.3.0.
306 PlanSet(PlanSet) = "plan_set",
307 /// The server forgets the plan of a repository thread. The holds
308 /// stay. New in 1.3.0.
309 PlanEnded(PlanEnded) = "plan_ended",
310 /// The notes of the lead of a user in a repository thread: the full
311 /// text replaces the old text, and the old text stays in the
312 /// history (01M59258K3TYR09HC4AWHWZPEX).
313 NotesSet(NotesSet) = "notes_set",
314}
315// ANCHOR_END: record
316
317/// Who caused a record: the caller of its command, with its class
318/// (01M3X4Z60G1FXQTDC5XDJ05BAX). It holds only the user and the session ID. The session
319/// in a change is a full URI, with the place.
320///
321/// | Caller | JSON |
322/// |---|---|
323/// | a person | `{"person":"mike"}` |
324/// | a session | `{"session":"mike/a6cf"}` |
325/// | a sign-in | `{"sign_in":"mike@comotechnologies.io"}` |
326/// | the server | `"server"` |
327///
328/// The set of classes can grow. A build reads a class that it does not
329/// know as [`By::Other`].
330///
331/// ```
332/// use riff_core::name::Who;
333/// use riff_core::record::By;
334///
335/// let session = By::Session(Who::new("mike", Some("a6cf"))?);
336/// assert_eq!(serde_json::to_string(&session).unwrap(), r#"{"session":"mike/a6cf"}"#);
337/// assert_eq!(serde_json::to_string(&By::Server).unwrap(), r#""server""#);
338/// assert_eq!(session.to_string(), "the session mike/a6cf");
339///
340/// let read = |json: &str| serde_json::from_str::<By>(json).unwrap();
341/// assert_eq!(read(r#"{"person":"mike"}"#), By::Person("mike".into()));
342/// assert_eq!(read(r#"{"session":"mike/a6cf"}"#), session);
343/// assert_eq!(read(r#""server""#), By::Server);
344/// // A class of a later build.
345/// assert_eq!(read(r#"{"robot":"r2"}"#), By::Other);
346/// assert_eq!(read(r#""cron""#), By::Other);
347/// # Ok::<(), riff_core::name::NameError>(())
348/// ```
349#[derive(Clone, Debug, PartialEq, Eq)]
350pub enum By {
351 /// A person, by the user.
352 Person(String),
353 /// An agent session, by the user and the session ID.
354 Session(Who),
355 /// A verified email of the provider, before a token is there.
356 SignIn(String),
357 /// A timer of `riff-server`.
358 Server,
359 /// A class that this build does not know.
360 Other,
361}
362
363impl By {
364 const PERSON: &str = "person";
365 const SESSION: &str = "session";
366 const SIGN_IN: &str = "sign_in";
367 const SERVER: &str = "server";
368 const OTHER: &str = "other";
369
370 /// The class and the name as a JSON value: the form in a record and
371 /// in a log line.
372 pub fn json(&self) -> serde_json::Value {
373 let named = |class: &str, name: String| serde_json::json!({ class: name });
374 match self {
375 By::Person(user) => named(By::PERSON, user.clone()),
376 By::Session(who) => named(By::SESSION, who.to_string()),
377 By::SignIn(email) => named(By::SIGN_IN, email.clone()),
378 By::Server => By::SERVER.into(),
379 By::Other => By::OTHER.into(),
380 }
381 }
382
383 fn read(value: &serde_json::Value) -> By {
384 if let Some(text) = value.as_str() {
385 return if text == By::SERVER {
386 By::Server
387 } else {
388 By::Other
389 };
390 }
391 let Some(map) = value.as_object() else {
392 return By::Other;
393 };
394 let mut fields = map.iter();
395 let (Some((class, name)), None) = (fields.next(), fields.next()) else {
396 return By::Other;
397 };
398 let Some(name) = name.as_str() else {
399 return By::Other;
400 };
401 match class.as_str() {
402 By::PERSON => By::Person(name.to_owned()),
403 By::SESSION => name
404 .split_once('/')
405 .and_then(|(user, session)| Who::new(user, Some(session)).ok())
406 .map_or(By::Other, By::Session),
407 By::SIGN_IN => By::SignIn(name.to_owned()),
408 _ => By::Other,
409 }
410 }
411}
412
413/// The caller for people, for example `the session mike/a6cf`.
414impl std::fmt::Display for By {
415 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416 match self {
417 By::Person(user) => write!(f, "the person {user}"),
418 By::Session(who) => write!(f, "the session {who}"),
419 By::SignIn(email) => write!(f, "the sign-in {email}"),
420 By::Server => f.write_str("the server"),
421 By::Other => f.write_str("a caller of a class that this build does not know"),
422 }
423 }
424}
425
426/// The schema takes each value: [`By`] reads a class that it does not
427/// know as [`By::Other`].
428impl schemars::JsonSchema for By {
429 fn schema_name() -> std::borrow::Cow<'static, str> {
430 "By".into()
431 }
432
433 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
434 schemars::json_schema!({})
435 }
436}
437
438impl Serialize for By {
439 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
440 self.json().serialize(serializer)
441 }
442}
443
444impl<'de> Deserialize<'de> for By {
445 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<By, D::Error> {
446 Ok(By::read(&serde_json::Value::deserialize(deserializer)?))
447 }
448}
449
450impl Change {
451 /// The session URI that this change holds: the `session` of the
452 /// change, or the `from` of the message of a `posted` change. `None`
453 /// for a change with no session URI.
454 pub fn session(&self) -> Option<&SessionUri> {
455 match self {
456 Change::Posted(posted) => Some(&posted.message.from),
457 Change::JoinedThread(m) | Change::LeftThread(m) | Change::LeadSet(m) => {
458 Some(&m.session)
459 }
460 Change::Claimed(c) => Some(&c.session),
461 Change::Released(r) => Some(&r.session),
462 Change::SessionForgotten(f) => Some(&f.session),
463 Change::SessionStarted(s) => Some(&s.session),
464 Change::SettingChanged(_)
465 | Change::PauseSet(_)
466 | Change::RiffMade(_)
467 | Change::PersonJoined(_)
468 | Change::MemberInvited(_)
469 | Change::MemberRemoved(_)
470 | Change::AdminSet(_)
471 | Change::OwnerSet(_)
472 | Change::OwnerAsked(_)
473 | Change::OwnerDenied(_)
474 | Change::SigninsEnded(_)
475 | Change::ItemHeld(_)
476 | Change::ItemFreed(_)
477 | Change::PlanSet(_)
478 | Change::PlanEnded(_)
479 | Change::NotesSet(_) => None,
480 }
481 }
482}
483
484impl Record {
485 /// The field of this record whose value this build read as `other`:
486 /// `by`, `scope`, `reason`, the `kind` or the `to` of a message, or
487 /// a session URI (`session`, or the `from` of a message). `None`
488 /// when the build knows each value. A record with such a
489 /// value counts as a skipped record: the build writes no checkpoint
490 /// past it (01M3XM2C18TT8VSKGD77YPZG53).
491 ///
492 /// ```
493 /// use riff_core::record::{Change, Line};
494 ///
495 /// let read = |line: &str| match Line::parse(line).unwrap() {
496 /// Line::Record(record) => record,
497 /// Line::Unknown { .. } => panic!("a known kind"),
498 /// };
499 /// let known = r#"{"position":2,"written_at_ms":1,"by":{"person":"ann"},"command":"pause","change":{"pause_set":{"scope":"riff","state":"paused"}}}"#;
500 /// assert_eq!(read(known).other(), None);
501 /// assert_eq!(read(known).change.kind(), "pause_set");
502 /// assert!(Change::KINDS.contains(&"pause_set"));
503 /// // The values of a later build.
504 /// assert_eq!(read(&known.replace(r#""riff""#, r#"{"wave":"17"}"#)).other(), Some("scope"));
505 /// assert_eq!(read(&known.replace(r#""riff""#, r#""host""#)).other(), Some("scope"));
506 /// assert_eq!(read(&known.replace("person", "robot")).other(), Some("by"));
507 /// // The state of a pause has no `other`: the line does not read.
508 /// assert!(Line::parse(&known.replace(r#""paused""#, r#""slow""#)).is_err());
509 ///
510 /// // A message of a later build: a kind, and a field of a selector.
511 /// let post = r#"{"position":3,"written_at_ms":1,"change":{"posted":{"thread":"acme/app","message":{"seq":1,"from":"riff://ann@heron/acme/app?session=s1","to":[{"user":"bob"}],"body":"hi","at_ms":1,"kind":"note"}}}}"#;
512 /// assert_eq!(read(post).other(), None);
513 /// assert_eq!(read(&post.replace("note", "poll")).other(), Some("kind"));
514 /// assert_eq!(read(&post.replace(r#"{"user":"bob"}"#, r#"{"user":"bob","wave":"17"}"#)).other(), Some("to"));
515 /// assert_eq!(read(&post.replace(r#"{"user":"bob"}"#, r#""all""#)).other(), Some("to"));
516 /// // A session URI with a query part of a later build.
517 /// assert_eq!(read(&post.replace("session=s1", "session=s1&wave=17")).other(), Some("from"));
518 /// ```
519 pub fn other(&self) -> Option<&'static str> {
520 if self.envelope.by == Some(By::Other) {
521 return Some("by");
522 }
523 if self.change.session().is_some_and(SessionUri::is_other) {
524 return Some(match self.change {
525 Change::Posted(_) => "from",
526 _ => "session",
527 });
528 }
529 match &self.change {
530 Change::PauseSet(set) if set.scope == Scope::Other => Some("scope"),
531 Change::SessionStarted(started) if started.reason == StartReason::Other => {
532 Some("reason")
533 }
534 Change::Posted(posted) if !posted.message.kind.is_post() => Some("kind"),
535 Change::Posted(posted) if posted.message.to.iter().any(Selector::is_other) => {
536 Some("to")
537 }
538 _ => None,
539 }
540 }
541}
542
543impl Record {
544 /// True when this record is a record of the repository thread `repo`
545 /// for an audit (01M3ZWRC11R5M9V1KTF05P240W): a post, a
546 /// membership, a lead, a claim or a release in that thread; a direct
547 /// message from a session in that repository; the start or the end
548 /// of a session in that repository; and a pause of the riff or of
549 /// that repository. Each other record is not.
550 ///
551 /// ```
552 /// use riff_core::record::{Change, Claimed, Envelope, Line, PauseSet, Record, Scope};
553 /// use riff_core::wire::RiffState;
554 ///
555 /// let repo = "acme/app".parse()?;
556 /// let envelope = Envelope { position: 1, written_at_ms: 1, by: None, command: None, call: None };
557 /// let record = |change| Record { envelope: envelope.clone(), change };
558 /// let claim = |thread: &str| record(Change::Claimed(Claimed {
559 /// session: "riff://ann@heron/acme/app?session=s1".parse().unwrap(),
560 /// thread: thread.parse().unwrap(),
561 /// item: "issue-7".into(),
562 /// }));
563 /// assert!(claim("acme/app").of_repository(&repo));
564 /// assert!(!claim("acme/web").of_repository(&repo));
565 ///
566 /// let pause = |scope| record(Change::PauseSet(PauseSet { scope, state: RiffState::Paused }));
567 /// assert!(pause(Scope::Riff).of_repository(&repo));
568 /// assert!(pause(Scope::Repository(repo.clone())).of_repository(&repo));
569 /// assert!(!pause(Scope::Repository("acme/web".parse()?)).of_repository(&repo));
570 ///
571 /// // A record of the people is not a record of a repository.
572 /// let invited = r#"{"position":7,"written_at_ms":1,"change":{"member_invited":{"email":"ann@acme.io"}}}"#;
573 /// let Line::Record(invited) = Line::parse(invited)? else { panic!("a known kind") };
574 /// assert!(!invited.of_repository(&repo));
575 /// # Ok::<(), Box<dyn std::error::Error>>(())
576 /// ```
577 pub fn of_repository(&self, repo: &ThreadName) -> bool {
578 let here = |session: &SessionUri| session.default_thread().as_ref() == Some(repo);
579 match &self.change {
580 Change::Posted(posted) if posted.thread.is_direct() => here(&posted.message.from),
581 Change::Posted(posted) => &posted.thread == repo,
582 Change::JoinedThread(m) | Change::LeftThread(m) | Change::LeadSet(m) => {
583 &m.thread == repo
584 }
585 Change::Claimed(c) => &c.thread == repo,
586 Change::Released(r) => &r.thread == repo,
587 Change::SessionStarted(s) => here(&s.session),
588 Change::SessionForgotten(f) => here(&f.session),
589 Change::PauseSet(set) => match &set.scope {
590 Scope::Riff => true,
591 Scope::Repository(thread) => thread == repo,
592 Scope::Other => false,
593 },
594 Change::ItemHeld(held) => &held.thread == repo,
595 Change::ItemFreed(freed) => &freed.thread == repo,
596 Change::PlanSet(set) => &set.thread == repo,
597 Change::PlanEnded(ended) => &ended.thread == repo,
598 Change::NotesSet(set) => &set.thread == repo,
599 Change::SettingChanged(_)
600 | Change::RiffMade(_)
601 | Change::PersonJoined(_)
602 | Change::MemberInvited(_)
603 | Change::MemberRemoved(_)
604 | Change::AdminSet(_)
605 | Change::OwnerSet(_)
606 | Change::OwnerAsked(_)
607 | Change::OwnerDenied(_)
608 | Change::SigninsEnded(_) => false,
609 }
610 }
611
612 /// This record for an audit: a post has only its mark
613 /// ([`Posted::for_audit`]). Each other record stays as it is.
614 pub fn for_audit(self) -> Record {
615 let Record { envelope, change } = self;
616 let change = match change {
617 Change::Posted(posted) => Change::Posted(Box::new(posted.for_audit())),
618 change => change,
619 };
620 Record { envelope, change }
621 }
622}
623
624/// A message in a thread.
625#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
626pub struct Posted {
627 pub thread: ThreadName,
628 /// The message, with its seq. A signed message keeps its payload and
629 /// its signature unchanged.
630 pub message: Message,
631 /// Each session that the message woke.
632 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
633 pub woken: BTreeSet<Who>,
634 /// The key of the post (`wire::Post::key`): the next post with the same
635 /// key from the same user is dropped (01M5TKG2K1AYK1KE7C2AA2W9DS).
636 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub key: Option<String>,
638}
639
640/// The marks of a post for an audit, longest first: the start of a
641/// body that names the step of the flow (01M3ZWRC3XBFN8FJDGE8XWZ5EA).
642pub const MARKS: [&str; 3] = ["verify request", "verify result", "request"];
643
644impl Posted {
645 /// The mark of `body`: the first of [`MARKS`] that starts it, before
646 /// a colon. An empty text when no mark starts it.
647 ///
648 /// ```
649 /// use riff_core::record::Posted;
650 ///
651 /// assert_eq!(Posted::mark("request: claim issue-12"), "request");
652 /// assert_eq!(Posted::mark("verify request: issue-12, PR #40"), "verify request");
653 /// assert_eq!(Posted::mark("verify result: PASS for issue-12"), "verify result");
654 /// assert_eq!(Posted::mark("Lead: request: claim issue-12"), "");
655 /// assert_eq!(Posted::mark("requests are free"), "");
656 /// ```
657 pub fn mark(body: &str) -> &'static str {
658 MARKS
659 .into_iter()
660 .find(|mark| {
661 body.strip_prefix(mark)
662 .is_some_and(|rest| rest.starts_with(':'))
663 })
664 .unwrap_or_default()
665 }
666
667 /// The post for an audit (01M3ZWRC3XBFN8FJDGE8XWZ5EA): its body is
668 /// only its [mark](Posted::mark), and it has no signature and no
669 /// payload. The kind, the sender, the `to` and the time stay.
670 ///
671 /// ```
672 /// use riff_core::record::Posted;
673 /// use riff_core::wire::Message;
674 ///
675 /// let posted = Posted {
676 /// thread: "acme/app".parse()?,
677 /// message: Message {
678 /// seq: 4,
679 /// from: "riff://ann@heron/acme/app?session=s1".parse()?,
680 /// to: Vec::new(),
681 /// body: "request: claim issue-12. The token is t0p.".into(),
682 /// at_ms: 9,
683 /// kind: Default::default(),
684 /// sig: Some("sig".into()),
685 /// payload: Some("payload".into()),
686 /// },
687 /// woken: Default::default(),
688 /// key: None,
689 /// };
690 /// let audit = posted.clone().for_audit();
691 /// assert_eq!(audit.message.body, "request");
692 /// assert_eq!((audit.message.sig, audit.message.payload), (None, None));
693 /// assert_eq!((audit.message.seq, audit.message.at_ms), (4, 9));
694 /// # Ok::<(), riff_core::name::NameError>(())
695 /// ```
696 pub fn for_audit(mut self) -> Posted {
697 self.message.body = Posted::mark(&self.message.body).to_owned();
698 self.message.sig = None;
699 self.message.payload = None;
700 self
701 }
702}
703
704/// A session and a thread.
705#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
706pub struct Member {
707 pub session: SessionUri,
708 pub thread: ThreadName,
709}
710
711/// A claim of one work item in one thread.
712#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
713pub struct Claimed {
714 pub session: SessionUri,
715 pub thread: ThreadName,
716 pub item: String,
717}
718
719/// The hold of one item in one repository thread, with its reason
720/// (01M43GSGB9ZFHSG0Q83Y50FEGW). The envelope of the record names who held it, and
721/// when. A hold is not a claim.
722///
723/// ```
724/// use riff_core::record::{Change, ItemFreed, ItemHeld};
725///
726/// let held = Change::ItemHeld(ItemHeld {
727/// thread: "como-technologies/riff".parse()?,
728/// item: "issue-366".into(),
729/// reason: "waits for the word of Mike".into(),
730/// });
731/// assert_eq!(
732/// serde_json::to_string(&held).unwrap(),
733/// r#"{"item_held":{"thread":"como-technologies/riff","item":"issue-366","reason":"waits for the word of Mike"}}"#
734/// );
735/// let freed = Change::ItemFreed(ItemFreed {
736/// thread: "como-technologies/riff".parse()?,
737/// item: "issue-366".into(),
738/// });
739/// assert_eq!(
740/// serde_json::to_string(&freed).unwrap(),
741/// r#"{"item_freed":{"thread":"como-technologies/riff","item":"issue-366"}}"#
742/// );
743/// # Ok::<(), riff_core::name::NameError>(())
744/// ```
745#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
746pub struct ItemHeld {
747 pub thread: ThreadName,
748 pub item: String,
749 pub reason: String,
750}
751
752/// The end of the hold of one item in one repository thread.
753#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
754pub struct ItemFreed {
755 pub thread: ThreadName,
756 pub item: String,
757}
758
759/// The plan of one repository thread (01M4A4YTNSJR0R1T9JNXPBSKHC): the
760/// full plan, so the record replaces the plan of the thread. The design
761/// is in the book: "Design: the plan on the server".
762///
763/// ```
764/// use riff_core::record::{Change, Plan, PlanEnded, PlanItem, PlanSet, Wave};
765///
766/// let set = Change::PlanSet(PlanSet {
767/// thread: "como-technologies/riff".parse()?,
768/// plan: Plan {
769/// wave: Some(Wave { number: 19, title: "Wave 19".into() }),
770/// items: vec![
771/// PlanItem { item: "issue-366".into(), needs: vec![] },
772/// PlanItem { item: "issue-459".into(), needs: vec!["issue-416".into()] },
773/// ],
774/// done: vec!["issue-416".into()],
775/// },
776/// });
777/// assert_eq!(
778/// serde_json::to_string(&set).unwrap(),
779/// r#"{"plan_set":{"thread":"como-technologies/riff","wave":{"number":19,"title":"Wave 19"},"items":[{"item":"issue-366","needs":[]},{"item":"issue-459","needs":["issue-416"]}],"done":["issue-416"]}}"#
780/// );
781/// let ended = Change::PlanEnded(PlanEnded { thread: "como-technologies/riff".parse()? });
782/// assert_eq!(
783/// serde_json::to_string(&ended).unwrap(),
784/// r#"{"plan_ended":{"thread":"como-technologies/riff"}}"#
785/// );
786/// # Ok::<(), riff_core::name::NameError>(())
787/// ```
788#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
789pub struct PlanSet {
790 pub thread: ThreadName,
791 #[serde(flatten)]
792 pub plan: Plan,
793}
794
795/// A plan with no thread: the current wave, its items with their
796/// needs, and the done needs. A need that is not in `done` is open.
797#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
798pub struct Plan {
799 /// The current wave. `None` when the repository has no open wave:
800 /// then the items are the open issues with no wave.
801 #[serde(default, skip_serializing_if = "Option::is_none")]
802 pub wave: Option<Wave>,
803 #[serde(default)]
804 pub items: Vec<PlanItem>,
805 #[serde(default)]
806 pub done: Vec<String>,
807}
808
809/// A wave of a plan: its number, and its title on the forge.
810#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
811pub struct Wave {
812 pub number: u64,
813 pub title: String,
814}
815
816/// One item of a plan, with the items that it needs.
817#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
818pub struct PlanItem {
819 pub item: String,
820 #[serde(default)]
821 pub needs: Vec<String>,
822}
823
824/// The end of the plan of one repository thread.
825#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
826pub struct PlanEnded {
827 pub thread: ThreadName,
828}
829
830/// The notes of the lead of one user in one repository thread
831/// (01M59258K3TYR09HC4AWHWZPEX). The text is the whole text: it replaces
832/// the old text. The envelope names who wrote it, and when.
833///
834/// ```
835/// use riff_core::record::{Change, NotesSet};
836///
837/// let set = Change::NotesSet(NotesSet {
838/// thread: "como-technologies/riff".parse()?,
839/// user: "mike".into(),
840/// text: "Prefer thelio for builds.".into(),
841/// });
842/// assert_eq!(
843/// serde_json::to_string(&set).unwrap(),
844/// r#"{"notes_set":{"thread":"como-technologies/riff","user":"mike","text":"Prefer thelio for builds."}}"#
845/// );
846/// # Ok::<(), riff_core::name::NameError>(())
847/// ```
848#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
849pub struct NotesSet {
850 pub thread: ThreadName,
851 /// The user of the lead.
852 pub user: String,
853 pub text: String,
854}
855
856/// A claim that is free now.
857///
858/// ```
859/// use riff_core::record::{Claimed, Released};
860///
861/// let claim = Claimed {
862/// session: "riff://ann@heron/acme/app?session=s1".parse()?,
863/// thread: "acme/app".parse()?,
864/// item: "issue-7".into(),
865/// };
866/// let released = Released::of(claim.clone());
867/// let json = serde_json::to_string(&released).unwrap();
868/// // A record from before the mark has the same fields.
869/// assert_eq!(json, serde_json::to_string(&claim).unwrap());
870/// assert!(!serde_json::from_str::<Released>(&json).unwrap().must_clear);
871///
872/// let last = Released { must_clear: true, ..released };
873/// assert!(serde_json::to_string(&last).unwrap().ends_with(r#""must_clear":true}"#));
874/// # Ok::<(), riff_core::name::NameError>(())
875/// ```
876#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
877pub struct Released {
878 pub session: SessionUri,
879 pub thread: ThreadName,
880 pub item: String,
881 /// True when the session is a worker that released its last claim
882 /// itself: it must clear its context before its next claim
883 /// (01M3X9XAK1KPZZVM1AJR2H8DSS).
884 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
885 pub must_clear: bool,
886}
887
888impl Released {
889 /// The release of `claim`, with no ask to clear.
890 pub fn of(claim: Claimed) -> Released {
891 let Claimed {
892 session,
893 thread,
894 item,
895 } = claim;
896 Released {
897 session,
898 thread,
899 item,
900 must_clear: false,
901 }
902 }
903}
904
905/// A start of a session.
906///
907/// ```
908/// use riff_core::record::{Change, Line};
909/// use riff_core::wire::StartReason;
910///
911/// let line = r#"{"position":4,"written_at_ms":9,"change":{"session_started":{"session":"riff://ann@heron/acme/app?session=s1","reason":"clear","worker":true}}}"#;
912/// let Line::Record(record) = Line::parse(line)? else { panic!("a known kind") };
913/// let Change::SessionStarted(started) = &record.change else { panic!("a start") };
914/// assert!(started.worker && started.reason.is_fresh());
915/// assert_eq!(serde_json::to_string(&record).unwrap(), line);
916///
917/// // A reason of a later build reads as `other`. It is no fresh start.
918/// let later = line.replace("clear", "wake");
919/// let Line::Record(record) = Line::parse(&later)? else { panic!("a known kind") };
920/// let Change::SessionStarted(started) = &record.change else { panic!("a start") };
921/// assert_eq!(started.reason, StartReason::Other);
922/// assert!(!started.reason.is_fresh());
923/// # Ok::<(), String>(())
924/// ```
925#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
926pub struct SessionStarted {
927 pub session: SessionUri,
928 /// Why the record is there. `process` and `clear` are fresh starts.
929 pub reason: StartReason,
930 /// True when the session is a worker.
931 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
932 pub worker: bool,
933}
934
935/// A pause that a command set or ended. The envelope of the record
936/// names who did it, and when.
937///
938/// ```
939/// use riff_core::record::{Change, PauseSet, Scope};
940/// use riff_core::wire::RiffState;
941///
942/// let set = Change::PauseSet(PauseSet {
943/// scope: Scope::Repository("como-technologies/strata".parse()?),
944/// state: RiffState::Paused,
945/// });
946/// assert_eq!(
947/// serde_json::to_string(&set).unwrap(),
948/// r#"{"pause_set":{"scope":{"repository":"como-technologies/strata"},"state":"paused"}}"#
949/// );
950/// let riff = PauseSet { scope: Scope::Riff, state: RiffState::Running };
951/// assert_eq!(serde_json::to_string(&riff).unwrap(), r#"{"scope":"riff","state":"running"}"#);
952/// # Ok::<(), riff_core::name::NameError>(())
953/// ```
954#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
955pub struct PauseSet {
956 pub scope: Scope,
957 /// `paused` sets the pause, and `running` ends it.
958 pub state: RiffState,
959}
960
961/// What a pause stops: the whole riff, or one repository
962/// (01M3XAHZG26ECNARX35JD73YXJ).
963///
964/// | Scope | JSON |
965/// |---|---|
966/// | the whole riff | `"riff"` |
967/// | a repository | `{"repository":"como-technologies/strata"}` |
968///
969/// The set of scopes can grow. A build reads a scope that it does not
970/// know as [`Scope::Other`], and such a record changes no pause.
971///
972/// ```
973/// use riff_core::record::Scope;
974///
975/// let read = |json: &str| serde_json::from_str::<Scope>(json).unwrap();
976/// assert_eq!(read(r#""riff""#), Scope::Riff);
977/// assert_eq!(read(r#"{"repository":"acme/app"}"#), Scope::Repository("acme/app".parse()?));
978/// // A scope of a later build.
979/// assert_eq!(read(r#"{"wave":"17"}"#), Scope::Other);
980/// assert_eq!(read(r#""host""#), Scope::Other);
981/// assert_eq!(Scope::Riff.to_string(), "the riff");
982/// assert_eq!(read(r#"{"repository":"acme/app"}"#).to_string(), "the repository acme/app");
983/// # Ok::<(), riff_core::name::NameError>(())
984/// ```
985#[derive(Clone, Debug, PartialEq, Eq)]
986pub enum Scope {
987 /// The whole riff.
988 Riff,
989 /// One repository, by its thread.
990 Repository(ThreadName),
991 /// A scope that this build does not know.
992 Other,
993}
994
995impl Scope {
996 const RIFF: &str = "riff";
997 const REPOSITORY: &str = "repository";
998 const OTHER: &str = "other";
999
1000 fn read(value: &serde_json::Value) -> Scope {
1001 if let Some(text) = value.as_str() {
1002 return if text == Scope::RIFF {
1003 Scope::Riff
1004 } else {
1005 Scope::Other
1006 };
1007 }
1008 let Some(map) = value.as_object() else {
1009 return Scope::Other;
1010 };
1011 let mut fields = map.iter();
1012 let (Some((kind, name)), None) = (fields.next(), fields.next()) else {
1013 return Scope::Other;
1014 };
1015 match (kind.as_str(), name.as_str()) {
1016 (Scope::REPOSITORY, Some(name)) => {
1017 ThreadName::try_from(name.to_owned()).map_or(Scope::Other, Scope::Repository)
1018 }
1019 _ => Scope::Other,
1020 }
1021 }
1022}
1023
1024/// The scope for people, for example `the repository acme/app`.
1025impl std::fmt::Display for Scope {
1026 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1027 match self {
1028 Scope::Riff => f.write_str("the riff"),
1029 Scope::Repository(thread) => write!(f, "the repository {thread}"),
1030 Scope::Other => f.write_str("a scope that this build does not know"),
1031 }
1032 }
1033}
1034
1035/// The schema takes each value: [`Scope`] reads a scope that it does
1036/// not know as [`Scope::Other`].
1037impl schemars::JsonSchema for Scope {
1038 fn schema_name() -> std::borrow::Cow<'static, str> {
1039 "Scope".into()
1040 }
1041
1042 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1043 schemars::json_schema!({})
1044 }
1045}
1046
1047impl Serialize for Scope {
1048 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1049 match self {
1050 Scope::Riff => Scope::RIFF.serialize(serializer),
1051 Scope::Repository(thread) => {
1052 serde_json::json!({ Scope::REPOSITORY: thread }).serialize(serializer)
1053 }
1054 Scope::Other => Scope::OTHER.serialize(serializer),
1055 }
1056 }
1057}
1058
1059impl<'de> Deserialize<'de> for Scope {
1060 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Scope, D::Error> {
1061 Ok(Scope::read(&serde_json::Value::deserialize(deserializer)?))
1062 }
1063}
1064
1065#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1066pub struct SettingChanged {
1067 /// The settings of idle workers.
1068 pub idle: Idle,
1069}
1070
1071/// A session that the state forgets.
1072#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1073pub struct Forgotten {
1074 pub session: SessionUri,
1075}
1076
1077/// The ID of a riff (01M3JNVBPMZ1K9WX7Q7DP6Y0DH).
1078#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1079pub struct RiffMade {
1080 pub riff_id: String,
1081}
1082
1083/// The first sign-in of a person: the verified email holds the USER
1084/// (R209).
1085#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1086pub struct PersonJoined {
1087 pub user: String,
1088 /// The verified email, in lower case.
1089 pub email: String,
1090}
1091
1092/// A person, by the verified email in lower case. A record of the
1093/// people names a person by the email: the state finds the USER.
1094#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1095pub struct Email {
1096 pub email: String,
1097}
1098
1099#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1100pub struct AdminSet {
1101 pub email: String,
1102 /// True: the person is an admin. False: the person is a member
1103 /// again.
1104 pub admin: bool,
1105}
1106
1107/// The owner of the riff.
1108///
1109/// ```
1110/// use riff_core::record::OwnerSet;
1111///
1112/// let gone = OwnerSet { email: None };
1113/// assert_eq!(serde_json::to_string(&gone).unwrap(), "{}");
1114/// assert_eq!(serde_json::from_str::<OwnerSet>("{}").unwrap(), gone);
1115/// ```
1116#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1117pub struct OwnerSet {
1118 /// The email of the owner. `None`: the owner is gone.
1119 #[serde(default, skip_serializing_if = "Option::is_none")]
1120 pub email: Option<String>,
1121}
1122
1123/// A request for the owner role.
1124#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1125pub struct OwnerAsked {
1126 /// The email of the admin that asks.
1127 pub email: String,
1128 /// With no answer before this time, the admin is the owner. In
1129 /// milliseconds since the Unix epoch.
1130 pub due_ms: u64,
1131}
1132
1133#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1134pub struct SigninsEnded {
1135 pub user: String,
1136}
1137
1138/// One line of the log, as this build reads it.
1139#[derive(Clone, Debug, PartialEq, Eq)]
1140pub enum Line {
1141 Record(Box<Record>),
1142 /// A record of a kind that this build does not know. A replay skips
1143 /// it, and logs a warning with its position.
1144 Unknown {
1145 position: u64,
1146 kind: String,
1147 },
1148}
1149
1150impl Line {
1151 /// Reads one line of the log. A record of a kind that this build does
1152 /// not know is [`Line::Unknown`]. A line that does not read is an
1153 /// error. The record keeps its call ID, so a start from the log keeps
1154 /// each call.
1155 ///
1156 /// ```
1157 /// use riff_core::record::Line;
1158 ///
1159 /// let called = r#"{"position":3,"written_at_ms":1,"call":"c1","change":{"member_invited":{"email":"a@acme.io"}}}"#;
1160 /// let Line::Record(record) = Line::parse(called).unwrap() else { panic!("a known kind") };
1161 /// assert_eq!(record.envelope.call.as_deref(), Some("c1"));
1162 ///
1163 /// let later = r#"{"position":9,"written_at_ms":1,"change":{"reacted":{"emoji":"+1"}}}"#;
1164 /// assert_eq!(
1165 /// Line::parse(later).unwrap(),
1166 /// Line::Unknown { position: 9, kind: "reacted".into() }
1167 /// );
1168 /// assert!(Line::parse(r#"{"position":9}"#).is_err());
1169 /// assert!(Line::parse(r#"{"position":9,"written_at_ms":1,"change":{"claimed":{}}}"#).is_err());
1170 /// ```
1171 pub fn parse(line: &str) -> Result<Line, String> {
1172 #[derive(Deserialize)]
1173 struct Raw {
1174 #[serde(flatten)]
1175 envelope: Envelope,
1176 change: serde_json::Map<String, serde_json::Value>,
1177 }
1178 let Raw { envelope, change } = serde_json::from_str(line).map_err(|e| e.to_string())?;
1179 let position = envelope.position;
1180 let [kind] = change.keys().collect::<Vec<_>>()[..] else {
1181 return Err(format!(
1182 "the change of the record at position {position} needs one kind"
1183 ));
1184 };
1185 if !Change::KINDS.contains(&kind.as_str()) {
1186 return Ok(Line::Unknown {
1187 position,
1188 kind: kind.clone(),
1189 });
1190 }
1191 let change = serde_json::from_value(serde_json::Value::Object(change))
1192 .map_err(|e| format!("the record at position {position}: {e}"))?;
1193 Ok(Line::Record(Box::new(Record { envelope, change })))
1194 }
1195}
1196
1197/// One change of each kind, for the tests of each crate: a new kind
1198/// goes here, and each round trip covers it.
1199#[cfg(any(test, feature = "test-support"))]
1200pub fn one_of_each() -> Vec<Change> {
1201 let uri = || -> SessionUri { "riff://ann@heron/acme/app?session=s1".parse().unwrap() };
1202 let thread = || -> ThreadName { "acme/app".parse().unwrap() };
1203 let member = Member {
1204 session: uri(),
1205 thread: thread(),
1206 };
1207 let claim = Claimed {
1208 session: uri(),
1209 thread: thread(),
1210 item: "issue-7".into(),
1211 };
1212 let email = Email {
1213 email: "ann@acme.io".into(),
1214 };
1215 vec![
1216 Change::Posted(Box::new(Posted {
1217 thread: thread(),
1218 message: Message {
1219 seq: 1,
1220 from: uri(),
1221 to: vec!["user=ann,session=s1,host=heron,repo=acme/app,worktree=issue-7,claim=issue-7,lead=true,person=false".parse().unwrap()],
1222 body: "hi".into(),
1223 at_ms: 5,
1224 kind: crate::wire::Kind::Status,
1225 sig: Some("h..s".into()),
1226 payload: Some("cA".into()),
1227 },
1228 woken: BTreeSet::from([uri().who().clone()]),
1229 key: Some("pull-40".into()),
1230 })),
1231 Change::JoinedThread(member.clone()),
1232 Change::LeftThread(member.clone()),
1233 Change::Claimed(claim.clone()),
1234 Change::Released(Released {
1235 session: claim.session,
1236 thread: claim.thread,
1237 item: claim.item,
1238 must_clear: true,
1239 }),
1240 Change::LeadSet(member),
1241 Change::SettingChanged(SettingChanged {
1242 idle: Idle::default(),
1243 }),
1244 Change::SessionForgotten(Forgotten { session: uri() }),
1245 Change::SessionStarted(SessionStarted {
1246 session: uri(),
1247 reason: StartReason::Process,
1248 worker: true,
1249 }),
1250 Change::PauseSet(PauseSet {
1251 scope: Scope::Repository(thread()),
1252 state: RiffState::Paused,
1253 }),
1254 Change::RiffMade(RiffMade {
1255 riff_id: "r1".into(),
1256 }),
1257 Change::PersonJoined(PersonJoined {
1258 user: "ann".into(),
1259 email: email.email.clone(),
1260 }),
1261 Change::MemberInvited(email.clone()),
1262 Change::MemberRemoved(email.clone()),
1263 Change::AdminSet(AdminSet {
1264 email: email.email.clone(),
1265 admin: true,
1266 }),
1267 Change::OwnerSet(OwnerSet {
1268 email: Some(email.email.clone()),
1269 }),
1270 Change::OwnerAsked(OwnerAsked {
1271 email: email.email.clone(),
1272 due_ms: 9,
1273 }),
1274 Change::OwnerDenied(email),
1275 Change::SigninsEnded(SigninsEnded { user: "ann".into() }),
1276 Change::ItemHeld(ItemHeld {
1277 thread: thread(),
1278 item: "issue-7".into(),
1279 reason: "waits for ann".into(),
1280 }),
1281 Change::ItemFreed(ItemFreed {
1282 thread: thread(),
1283 item: "issue-7".into(),
1284 }),
1285 Change::PlanSet(PlanSet {
1286 thread: thread(),
1287 plan: Plan {
1288 wave: Some(Wave {
1289 number: 3,
1290 title: "Wave 3".into(),
1291 }),
1292 items: vec![PlanItem {
1293 item: "issue-7".into(),
1294 needs: vec!["issue-6".into()],
1295 }],
1296 done: vec!["issue-6".into()],
1297 },
1298 }),
1299 Change::PlanEnded(PlanEnded { thread: thread() }),
1300 Change::NotesSet(NotesSet {
1301 thread: thread(),
1302 user: "ann".into(),
1303 text: "Wave 36 runs. Mike decides the cut.".into(),
1304 }),
1305 ]
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310 use super::*;
1311 use crate::wire::Kind;
1312
1313 fn uri() -> SessionUri {
1314 "riff://ann@heron/acme/app?session=s1".parse().unwrap()
1315 }
1316
1317 fn thread() -> ThreadName {
1318 "acme/app".parse().unwrap()
1319 }
1320
1321 #[test]
1322 fn each_kind_reads_back_and_has_its_name() {
1323 let changes = one_of_each();
1324 let kinds: BTreeSet<&str> = changes.iter().map(Change::kind).collect();
1325 assert_eq!(kinds, BTreeSet::from_iter(Change::KINDS.iter().copied()));
1326 assert_eq!(
1327 kinds.len(),
1328 Change::KINDS.len(),
1329 "a name is in the list two times"
1330 );
1331 for change in changes {
1332 let kind = change.kind();
1333 let record = Record {
1334 envelope: Envelope {
1335 position: 3,
1336 written_at_ms: 4,
1337 by: Some(By::Server),
1338 command: Some("forget".into()),
1339 call: None,
1340 },
1341 change,
1342 };
1343 let line = serde_json::to_string(&record).unwrap();
1344 assert!(line.contains(&format!(r#""change":{{"{kind}":"#)), "{line}");
1345 assert_eq!(Line::parse(&line).unwrap(), Line::Record(Box::new(record)));
1346 }
1347 }
1348
1349 /// Each field of the envelope comes through the tolerant reader
1350 /// (01M49W17GGV1K5FZEKJRPV0GNA). The destructure names each field:
1351 /// a new field of the envelope does not build until it is set here
1352 /// too, and then the reader must keep it.
1353 #[test]
1354 fn each_field_of_the_envelope_comes_through_the_reader() {
1355 let envelope = Envelope {
1356 position: 7,
1357 written_at_ms: 8,
1358 by: Some(By::Session(uri().who().clone())),
1359 command: Some("claim".into()),
1360 call: Some("call-7".into()),
1361 };
1362 let Envelope {
1363 position,
1364 written_at_ms,
1365 by,
1366 command,
1367 call,
1368 } = &envelope;
1369 assert!(*position != 0 && *written_at_ms != 0);
1370 assert!(by.is_some() && command.is_some() && call.is_some());
1371 let record = Record {
1372 envelope: envelope.clone(),
1373 change: Change::RiffMade(RiffMade {
1374 riff_id: "r1".into(),
1375 }),
1376 };
1377 let line = serde_json::to_string(&record).unwrap();
1378 let Line::Record(read) = Line::parse(&line).unwrap() else {
1379 panic!("a known kind");
1380 };
1381 assert_eq!(read.envelope, envelope);
1382 }
1383
1384 #[test]
1385 fn an_unknown_field_is_skipped() {
1386 let line = r#"{"position":2,"written_at_ms":1,"later":true,"change":{"pause_set":{"scope":"riff","state":"running","why":"x"}}}"#;
1387 let Line::Record(record) = Line::parse(line).unwrap() else {
1388 panic!("a known kind");
1389 };
1390 assert_eq!(
1391 record.change,
1392 Change::PauseSet(PauseSet {
1393 scope: Scope::Riff,
1394 state: RiffState::Running
1395 })
1396 );
1397 assert_eq!(record.other(), None);
1398 }
1399
1400 #[test]
1401 fn each_class_of_a_caller_reads_back() {
1402 let who = uri().who().clone();
1403 for (by, json) in [
1404 (By::Person("ann".into()), r#"{"person":"ann"}"#),
1405 (By::Session(who), r#"{"session":"ann/s1"}"#),
1406 (
1407 By::SignIn("ann@acme.io".into()),
1408 r#"{"sign_in":"ann@acme.io"}"#,
1409 ),
1410 (By::Server, r#""server""#),
1411 (By::Other, r#""other""#),
1412 ] {
1413 assert_eq!(serde_json::to_string(&by).unwrap(), json);
1414 assert_eq!(serde_json::from_str::<By>(json).unwrap(), by);
1415 }
1416 }
1417
1418 #[test]
1419 fn a_class_that_the_build_does_not_know_reads_as_other() {
1420 for json in [
1421 r#"{"robot":"r2"}"#,
1422 r#""cron""#,
1423 r#"{"person":"ann","more":"x"}"#,
1424 r#"{"person":7}"#,
1425 r#"{"session":"no-session-id"}"#,
1426 "7",
1427 "{}",
1428 ] {
1429 assert_eq!(
1430 serde_json::from_str::<By>(json).unwrap(),
1431 By::Other,
1432 "{json}"
1433 );
1434 }
1435 let line = r#"{"position":2,"written_at_ms":1,"by":{"robot":"r2"},"command":"sweep","change":{"pause_set":{"scope":"riff","state":"running"}}}"#;
1436 let Line::Record(record) = Line::parse(line).unwrap() else {
1437 panic!("a known kind");
1438 };
1439 assert_eq!(record.envelope.by, Some(By::Other));
1440 assert_eq!(record.other(), Some("by"));
1441 // The kind of a command is text: a kind of a later build reads.
1442 assert_eq!(record.envelope.command.as_deref(), Some("sweep"));
1443 }
1444
1445 #[test]
1446 fn each_scope_of_a_pause_reads_back() {
1447 for (scope, json) in [
1448 (Scope::Riff, r#""riff""#),
1449 (Scope::Repository(thread()), r#"{"repository":"acme/app"}"#),
1450 (Scope::Other, r#""other""#),
1451 ] {
1452 assert_eq!(serde_json::to_string(&scope).unwrap(), json);
1453 assert_eq!(serde_json::from_str::<Scope>(json).unwrap(), scope);
1454 }
1455 }
1456
1457 #[test]
1458 fn a_scope_that_the_build_does_not_know_reads_as_other() {
1459 for json in [
1460 r#"{"wave":"17"}"#,
1461 r#""host""#,
1462 r#"{"repository":"acme/app","more":"x"}"#,
1463 r#"{"repository":7}"#,
1464 r#"{"repository":"two words"}"#,
1465 "7",
1466 "{}",
1467 ] {
1468 assert_eq!(
1469 serde_json::from_str::<Scope>(json).unwrap(),
1470 Scope::Other,
1471 "{json}"
1472 );
1473 }
1474 let line = r#"{"position":2,"written_at_ms":1,"by":{"person":"ann"},"command":"pause","change":{"pause_set":{"scope":{"wave":"17"},"state":"paused"}}}"#;
1475 let Line::Record(record) = Line::parse(line).unwrap() else {
1476 panic!("a known kind");
1477 };
1478 assert_eq!(
1479 record.change,
1480 Change::PauseSet(PauseSet {
1481 scope: Scope::Other,
1482 state: RiffState::Paused
1483 })
1484 );
1485 assert_eq!(record.other(), Some("scope"));
1486 }
1487
1488 #[test]
1489 fn a_reason_that_the_build_does_not_know_reads_as_other() {
1490 let line = r#"{"position":2,"written_at_ms":1,"change":{"session_started":{"session":"riff://ann@heron/acme/app?session=s1","reason":"wake"}}}"#;
1491 let Line::Record(record) = Line::parse(line).unwrap() else {
1492 panic!("a known kind");
1493 };
1494 assert_eq!(record.other(), Some("reason"));
1495 let known = line.replace("wake", "join");
1496 let Line::Record(record) = Line::parse(&known).unwrap() else {
1497 panic!("a known kind");
1498 };
1499 assert_eq!(record.other(), None);
1500 // A reason of a later build can have each form of JSON.
1501 for later in [r#"{"wake":"timer"}"#, "7", "null", r#"["join"]"#] {
1502 let line = line.replace(r#""wake""#, later);
1503 let Line::Record(record) = Line::parse(&line).unwrap() else {
1504 panic!("a known kind");
1505 };
1506 assert_eq!(record.other(), Some("reason"), "{later}");
1507 }
1508 }
1509
1510 /// A `posted` record with the message `message`, as JSON.
1511 fn posted(message: &str) -> Record {
1512 let line = format!(
1513 r#"{{"position":2,"written_at_ms":1,"change":{{"posted":{{"thread":"acme/app","message":{message},"woken":[{{"user":"bob","session":"b1"}}]}}}}}}"#
1514 );
1515 match Line::parse(&line).unwrap() {
1516 Line::Record(record) => *record,
1517 Line::Unknown { .. } => panic!("a known kind"),
1518 }
1519 }
1520
1521 const MESSAGE: &str = r#"{"seq":1,"from":"riff://ann@heron/acme/app?session=s1","to":[{"user":"bob"}],"body":"the text","at_ms":5,"kind":"status"}"#;
1522
1523 #[test]
1524 fn a_kind_of_a_message_that_the_build_does_not_know_reads_as_other() {
1525 assert_eq!(posted(MESSAGE).other(), None);
1526 // A message with no kind is a message.
1527 let plain = posted(&MESSAGE.replace(r#","kind":"status""#, ""));
1528 assert_eq!(plain.other(), None);
1529 // A kind of a later build can have each form of JSON.
1530 for later in [
1531 r#""poll""#,
1532 r#"{"poll":"wave"}"#,
1533 "7",
1534 "null",
1535 r#"["note"]"#,
1536 ] {
1537 let record = posted(&MESSAGE.replace(r#""status""#, later));
1538 assert_eq!(record.other(), Some("kind"), "{later}");
1539 let Change::Posted(posted) = &record.change else {
1540 panic!("a post");
1541 };
1542 assert_eq!(posted.message.kind, Kind::Other);
1543 // The message keeps its text, its address and its wakes.
1544 assert_eq!(posted.message.body, "the text");
1545 assert_eq!(posted.message.to, ["user=bob".parse().unwrap()]);
1546 assert_eq!(posted.woken.len(), 1);
1547 }
1548 }
1549
1550 #[test]
1551 fn a_selector_with_a_field_that_the_build_does_not_know_reads_as_other() {
1552 for later in [
1553 r#"[{"user":"bob","wave":"17"}]"#,
1554 r#"[{"user":"bob"},{"wave":17}]"#,
1555 r#"[{"wave":{"n":17},"lead":true}]"#,
1556 // A value that the build does not know, and each other form
1557 // of JSON.
1558 r#"[{"user":null,"wave":"17"}]"#,
1559 r#"[{"lead":"maybe","user":"bob"}]"#,
1560 r#"["all"]"#,
1561 r#"[["user","bob"]]"#,
1562 "[17]",
1563 "[null]",
1564 ] {
1565 let record = posted(&MESSAGE.replace(r#"[{"user":"bob"}]"#, later));
1566 assert_eq!(record.other(), Some("to"), "{later}");
1567 let Change::Posted(posted) = &record.change else {
1568 panic!("a post");
1569 };
1570 assert_eq!(posted.message.kind, Kind::Status);
1571 assert_eq!(posted.message.body, "the text");
1572 // The record writes each field of the selector again.
1573 let written = serde_json::to_value(&posted.message.to).unwrap();
1574 let read: serde_json::Value = serde_json::from_str(later).unwrap();
1575 assert_eq!(written, read);
1576 }
1577 }
1578
1579 /// The line of a change with the session URI `uri`, for each kind
1580 /// that holds one.
1581 fn lines_with(uri: &str) -> Vec<String> {
1582 let member = format!(r#"{{"session":"{uri}","thread":"acme/app"}}"#);
1583 let claim = format!(r#"{{"session":"{uri}","thread":"acme/app","item":"issue-7"}}"#);
1584 let message = MESSAGE.replace("riff://ann@heron/acme/app?session=s1", uri);
1585 [
1586 (
1587 "posted",
1588 format!(r#"{{"thread":"acme/app","message":{message}}}"#),
1589 ),
1590 ("joined_thread", member.clone()),
1591 ("left_thread", member.clone()),
1592 ("lead_set", member),
1593 ("claimed", claim.clone()),
1594 ("released", claim),
1595 ("session_forgotten", format!(r#"{{"session":"{uri}"}}"#)),
1596 (
1597 "session_started",
1598 format!(r#"{{"session":"{uri}","reason":"process"}}"#),
1599 ),
1600 ]
1601 .into_iter()
1602 .map(|(kind, body)| {
1603 format!(r#"{{"position":2,"written_at_ms":1,"change":{{"{kind}":{body}}}}}"#)
1604 })
1605 .collect()
1606 }
1607
1608 #[test]
1609 fn a_session_uri_with_a_query_part_of_a_later_build_reads_in_each_kind() {
1610 let known = "riff://ann@heron/acme/app?session=s1";
1611 for line in lines_with(known) {
1612 let Line::Record(record) = Line::parse(&line).unwrap() else {
1613 panic!("a known kind");
1614 };
1615 assert_eq!(record.other(), None, "{line}");
1616 assert_eq!(record.change.session(), Some(&uri()), "{line}");
1617 }
1618 // Each kind with no session URI has none.
1619 let with_uri = lines_with(known).len();
1620 let without = one_of_each()
1621 .iter()
1622 .filter(|change| change.session().is_none())
1623 .count();
1624 assert_eq!(with_uri + without, Change::KINDS.len());
1625
1626 for later in [
1627 "riff://ann@heron/acme/app?session=s1&wave=17",
1628 "riff://ann@heron/acme/app?session=s1&lead=maybe",
1629 "riff://ann@heron/acme/app?session=s1&lead=true&mode",
1630 "riff://ann@heron/acme/app?session=s1&session=s2",
1631 "riff://ann@heron/acme/app?session=s1&claim=two%20words",
1632 "riff://ann@heron/acme/app?session=s1&claim=issue-7&wave=17#api",
1633 ] {
1634 for line in lines_with(later) {
1635 let Line::Record(record) = Line::parse(&line).unwrap() else {
1636 panic!("a known kind");
1637 };
1638 let field = match record.change {
1639 Change::Posted(_) => "from",
1640 _ => "session",
1641 };
1642 assert_eq!(record.other(), Some(field), "{line}");
1643 // The same session, and the URI as it came.
1644 let session = record.change.session().unwrap();
1645 assert_eq!(session.who(), uri().who(), "{line}");
1646 assert_eq!(session.place().host(), "heron", "{line}");
1647 assert_eq!(session.to_string(), later, "{line}");
1648 assert!(serde_json::to_string(&record).unwrap().contains(later));
1649 }
1650 }
1651 // The part gives no mark.
1652 let read = |text: &str| SessionUri::try_from(text.to_owned()).unwrap();
1653 let maybe = read("riff://ann@heron/acme/app?session=s1&lead=maybe");
1654 assert!(!maybe.lead() && maybe.claims().is_empty());
1655 let claim = read("riff://ann@heron/acme/app?session=s1&claim=two%20words");
1656 assert!(claim.claims().is_empty());
1657 }
1658
1659 /// The list "Each text with a grammar in a record" of the module
1660 /// docs: what reads, and what does not.
1661 #[test]
1662 fn each_text_with_a_grammar_reads_as_the_list_says() {
1663 let reads = |line: &str| Line::parse(line).is_ok();
1664 let claimed = |session: &str, thread: &str, item: &str| {
1665 format!(
1666 r#"{{"position":2,"written_at_ms":1,"change":{{"claimed":{{"session":"{session}","thread":"{thread}","item":"{item}"}}}}}}"#
1667 )
1668 };
1669 let session = "riff://ann@heron/acme/app?session=s1";
1670 assert!(reads(&claimed(session, "acme/app", "issue-7")));
1671 // The parts of a URI before its query never grow.
1672 for other in [
1673 "riff://ann@heron/acme/app/more?session=s1",
1674 "riff://ann@two words/acme/app?session=s1",
1675 "rifs://ann@heron/acme/app?session=s1",
1676 "riff://heron/acme/app?session=s1",
1677 ] {
1678 assert!(!reads(&claimed(other, "acme/app", "issue-7")), "{other}");
1679 }
1680 // A thread name is a text with no white space. The read does
1681 // not take it apart.
1682 for thread in [
1683 "wave:17",
1684 "dm:ann/s1|bob/b1",
1685 "dm:a later form",
1686 "dm:x",
1687 "a/b/c",
1688 ] {
1689 let reads_it = !thread.contains(' ') || thread.starts_with("dm:");
1690 assert_eq!(
1691 reads(&claimed(session, thread, "issue-7")),
1692 reads_it,
1693 "{thread}"
1694 );
1695 }
1696 assert!(!reads(&claimed(session, "", "issue-7")));
1697 // An item is free text.
1698 assert!(reads(&claimed(
1699 session,
1700 "acme/app",
1701 "two words, and more: 17"
1702 )));
1703 assert!(reads(&claimed(session, "acme/app", "")));
1704 // A session in `woken`: no check of a character, and a field of
1705 // a later build is skipped.
1706 let woken =
1707 r#"[{"user":"two words","session":"b 1","wave":17},{"user":"bob","session":null}]"#;
1708 let record = posted(MESSAGE);
1709 let line = serde_json::to_string(&record)
1710 .unwrap()
1711 .replace(r#"[{"user":"bob","session":"b1"}]"#, woken);
1712 assert!(line.contains("two words"));
1713 let Line::Record(record) = Line::parse(&line).unwrap() else {
1714 panic!("a known kind");
1715 };
1716 assert_eq!(record.other(), None);
1717 // A signature and a payload that do not check still read.
1718 let signed = MESSAGE.replace(
1719 r#""kind""#,
1720 r#""sig":"not a signature","payload":"%%","kind""#,
1721 );
1722 assert_eq!(posted(&signed).other(), None);
1723 // A number is a whole number that is not negative.
1724 let numbered = |position: &str, at: &str| {
1725 format!(
1726 r#"{{"position":{position},"written_at_ms":{at},"change":{{"riff_made":{{"riff_id":"r1"}}}}}}"#
1727 )
1728 };
1729 assert!(reads(&numbered("2", "18446744073709551615")));
1730 for (position, at) in [("-2", "1"), ("2", "1.5"), ("2", r#""1""#), ("2", "null")] {
1731 assert!(!reads(&numbered(position, at)), "{position} {at}");
1732 }
1733 let idle = |per_host: &str| {
1734 format!(
1735 r#"{{"position":2,"written_at_ms":1,"change":{{"setting_changed":{{"idle":{{"per_host":{per_host},"after_secs":60}}}}}}}}"#
1736 )
1737 };
1738 assert!(reads(&idle("65535")) && !reads(&idle("65536")));
1739 }
1740
1741 #[test]
1742 fn a_state_of_a_pause_that_the_build_does_not_know_does_not_read() {
1743 let line = r#"{"position":2,"written_at_ms":1,"change":{"pause_set":{"scope":"riff","state":"slow"}}}"#;
1744 assert!(Line::parse(line).is_err());
1745 }
1746
1747 #[test]
1748 fn a_record_of_the_kind_riff_state_set_is_of_no_kind_of_this_build() {
1749 let line =
1750 r#"{"position":2,"written_at_ms":1,"change":{"riff_state_set":{"state":"running"}}}"#;
1751 assert_eq!(
1752 Line::parse(line).unwrap(),
1753 Line::Unknown {
1754 position: 2,
1755 kind: "riff_state_set".into()
1756 }
1757 );
1758 }
1759
1760 #[test]
1761 fn a_record_with_no_cause_reads_and_writes_no_cause() {
1762 let line = r#"{"position":2,"written_at_ms":1,"change":{"pause_set":{"scope":"riff","state":"running"}}}"#;
1763 let Line::Record(record) = Line::parse(line).unwrap() else {
1764 panic!("a known kind");
1765 };
1766 assert_eq!(
1767 (&record.envelope.by, &record.envelope.command),
1768 (&None, &None)
1769 );
1770 assert_eq!(serde_json::to_string(&record).unwrap(), line);
1771 }
1772
1773 #[test]
1774 fn a_change_with_two_kinds_does_not_read() {
1775 let line = r#"{"position":2,"written_at_ms":1,"change":{"claimed":{},"released":{}}}"#;
1776 assert!(Line::parse(line).is_err());
1777 }
1778}