1use serde::{Deserialize, Serialize};
14
15use crate::{Address, AxCode, AxError, ByteLen, GoalId, Payload, PursuitState, TimeMs, Version};
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21pub struct GoalConflict {
22 pub goal: GoalId,
23 pub with: GoalId,
24 pub level: ConflictLevel,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
33pub enum ConflictLevel {
34 Serialize,
35 Arbitrate,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
45pub struct PursuitChanged {
46 pub step: PursuitMove,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub goal: Option<String>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
55pub enum PursuitMove {
56 Set,
57 Pause,
58 Resume,
59 Clear,
60}
61
62impl PursuitChanged {
63 pub fn held(self) -> Result<Option<(String, PursuitState)>, AxError> {
71 let state = match self.step {
72 PursuitMove::Set | PursuitMove::Resume => PursuitState::Running,
73 PursuitMove::Pause => PursuitState::Paused,
74 PursuitMove::Clear => return Ok(None),
75 };
76 let goal = self.goal.ok_or_else(|| {
77 AxError::failure(
78 AxCode::WireMismatch,
79 "read a pursuit_changed line",
80 "a step that leaves a pursuit names no goal",
81 )
82 .with_recovery("replay with the build that wrote this record")
83 })?;
84 Ok(Some((goal, state)))
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct SignalEnqueued {
95 pub id: SignalId,
96 pub kind: SignalKind,
97 pub from: String,
98 pub room: Address,
99 pub room_version: Version,
100 pub payload: Payload,
101 pub at: TimeMs,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub lane: Option<Lane>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct SignalConsumed {
114 pub id: SignalId,
115 pub by: String,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct WorktreeOpened {
124 pub name: String,
125 pub disk_bytes: ByteLen,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum Lane {
132 Urgent,
133 Ordinary,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
142#[serde(try_from = "String", into = "String")]
143pub struct SignalId(String);
144
145impl TryFrom<String> for SignalId {
146 type Error = AxError;
147
148 fn try_from(raw: String) -> Result<SignalId, AxError> {
149 SignalId::parse(&raw)
150 }
151}
152
153impl From<SignalId> for String {
154 fn from(id: SignalId) -> String {
155 id.0
156 }
157}
158
159impl SignalId {
160 pub fn parse(raw: &str) -> Result<SignalId, AxError> {
165 if raw.is_empty() || raw.chars().any(char::is_whitespace) {
166 return Err(AxError::failure(
167 AxCode::InvalidArgs,
168 "read a signal id",
169 format!("{raw:?}"),
170 )
171 .with_recovery(
172 "use a non-empty id with no whitespace, such as a run id and a counter",
173 ));
174 }
175 Ok(SignalId(raw.to_owned()))
176 }
177
178 #[must_use]
179 pub fn as_str(&self) -> &str {
180 &self.0
181 }
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(try_from = "String", into = "String")]
194pub enum SignalKind {
195 Mention,
196 Thread,
197 Broadcast,
198 Steer,
199}
200
201impl SignalKind {
202 #[must_use]
203 pub fn as_str(self) -> &'static str {
204 match self {
205 SignalKind::Mention => "mention",
206 SignalKind::Thread => "thread",
207 SignalKind::Broadcast => "broadcast",
208 SignalKind::Steer => "steer",
209 }
210 }
211
212 pub fn parse(raw: &str) -> Result<SignalKind, AxError> {
215 match raw {
216 "mention" => Ok(SignalKind::Mention),
217 "thread" => Ok(SignalKind::Thread),
218 "broadcast" => Ok(SignalKind::Broadcast),
219 "steer" => Ok(SignalKind::Steer),
220 other => {
221 Err(
222 AxError::failure(AxCode::InvalidArgs, "read a signal kind", other.to_owned())
223 .with_recovery("mention, thread, broadcast or steer"),
224 )
225 }
226 }
227 }
228}
229
230impl TryFrom<String> for SignalKind {
231 type Error = AxError;
232
233 fn try_from(raw: String) -> Result<SignalKind, AxError> {
234 SignalKind::parse(&raw)
235 }
236}
237
238impl From<SignalKind> for String {
239 fn from(kind: SignalKind) -> String {
240 kind.as_str().to_owned()
241 }
242}
243
244#[cfg(test)]
245#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
246mod tests {
247 use super::*;
248
249 #[test]
252 fn a_pursuit_step_writes_the_keys_the_hand_written_map_wrote() {
253 let hand = |json: serde_json::Value| {
254 serde_json::to_string(&Payload::new(json.as_object().unwrap().clone()).unwrap())
255 .unwrap()
256 };
257 let typed =
258 |record: &PursuitChanged| serde_json::to_string(&Payload::of(record).unwrap()).unwrap();
259 let paused = PursuitChanged {
260 step: PursuitMove::Pause,
261 goal: Some("read the meter".to_owned()),
262 };
263 let cleared = PursuitChanged {
264 step: PursuitMove::Clear,
265 goal: None,
266 };
267 assert_eq!(
268 (typed(&paused), typed(&cleared)),
269 (
270 hand(serde_json::json!({ "step": "pause", "goal": "read the meter" })),
271 hand(serde_json::json!({ "step": "clear" })),
272 )
273 );
274 assert_eq!(
275 (paused.held().unwrap(), cleared.held().unwrap()),
276 (
277 Some(("read the meter".to_owned(), PursuitState::Paused)),
278 None
279 )
280 );
281 }
282
283 #[test]
286 fn a_worktree_line_writes_the_keys_the_hand_written_map_wrote() {
287 let opened = WorktreeOpened {
288 name: "node-1".to_owned(),
289 disk_bytes: ByteLen::new(6),
290 };
291 assert_eq!(
292 serde_json::to_string(&Payload::of(&opened).unwrap()).unwrap(),
293 r#"{"disk_bytes":6,"name":"node-1"}"#
294 );
295 }
296
297 #[test]
300 fn a_goal_line_writes_the_keys_the_hand_written_map_wrote() {
301 let conflict = GoalConflict {
302 goal: GoalId::new("b").unwrap(),
303 with: GoalId::new("a").unwrap(),
304 level: ConflictLevel::Arbitrate,
305 };
306 let entry = crate::GoalEntry {
307 id: GoalId::new("a").unwrap(),
308 owner: "lab/a".to_owned(),
309 resources: vec![crate::GoalResource::External("the printer".to_owned())],
310 statement: "print".to_owned(),
311 standing: true,
312 };
313 let bytes = |payload: Payload| serde_json::to_string(&payload).unwrap();
314 assert_eq!(
315 (
316 bytes(Payload::of(&conflict).unwrap()),
317 bytes(Payload::of(&entry).unwrap()),
318 ),
319 (
320 r#"{"goal":"b","level":"arbitrate","with":"a"}"#.to_owned(),
321 r#"{"id":"a","owner":"lab/a","resources":[{"external":"the printer"}],"standing":true,"statement":"print"}"#.to_owned(),
322 )
323 );
324 assert_eq!(
325 Payload::of(&conflict)
326 .unwrap()
327 .read::<GoalConflict>()
328 .unwrap(),
329 conflict
330 );
331 }
332}