Skip to main content

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}