use serde::{Deserialize, Serialize};
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ObservedRoutineRow {
pub namespace: String,
pub observation_ordinal: u64,
pub observation_resource_version: String,
pub observed_at_ms: i64,
pub recorded_at_ms: i64,
pub observer: String,
pub name: String,
pub uid: String,
pub resource_version: String,
pub generation: i64,
pub fire_conversation_id: String,
pub creator_persona: String,
pub provenance_conversation_id: String,
pub scope: String,
pub display_name: String,
pub description: String,
pub schedule_json: String,
pub schedule_timezone: String,
pub next_fires_json: String,
pub prompt: String,
pub prompt_truncated: bool,
pub ready: bool,
pub phase: Option<String>,
pub message: Option<String>,
pub last_fire_time_ms: Option<i64>,
pub next_fire_time_ms: Option<i64>,
pub conditions_json: String,
pub suspended: bool,
pub paused_by: Option<String>,
pub paused_at_ms: Option<i64>,
pub pause_reason: Option<String>,
pub orphaned: bool,
pub setup_completed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ObservedRoutinesPayload {
version: u8,
rows: Vec<ObservedRoutineRow>,
}
const PAYLOAD_VERSION: u8 = 2;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DecodeError {
#[error("the observed-routines payload is not readable JSON: {0}")]
Malformed(String),
#[error("the observed-routines payload is version {found}; this build reads {expected}")]
UnknownVersion {
found: u8,
expected: u8,
},
}
#[must_use]
pub fn encode_rows(rows: &[ObservedRoutineRow]) -> Vec<u8> {
let payload = ObservedRoutinesPayload {
version: PAYLOAD_VERSION,
rows: rows.to_vec(),
};
serde_json::to_vec(&payload).expect("an observed-routines payload always serializes")
}
pub fn decode_rows(bytes: &[u8]) -> Result<Vec<ObservedRoutineRow>, DecodeError> {
let payload: ObservedRoutinesPayload =
serde_json::from_slice(bytes).map_err(|error| DecodeError::Malformed(error.to_string()))?;
if payload.version != PAYLOAD_VERSION {
return Err(DecodeError::UnknownVersion {
found: payload.version,
expected: PAYLOAD_VERSION,
});
}
Ok(payload.rows)
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::{DecodeError, ObservedRoutineRow, decode_rows, encode_rows};
fn row(name: &str) -> ObservedRoutineRow {
ObservedRoutineRow {
namespace: "polychrome".to_owned(),
observation_ordinal: 1,
observation_resource_version: "999".to_owned(),
observed_at_ms: 1_700_000_000_000,
recorded_at_ms: 1_700_000_000_001,
observer: "control-plane/test".to_owned(),
name: name.to_owned(),
uid: "uid-1".to_owned(),
resource_version: "42".to_owned(),
generation: 3,
fire_conversation_id: "fire-1".to_owned(),
creator_persona: "persona-1".to_owned(),
provenance_conversation_id: "conv-1".to_owned(),
scope: "private".to_owned(),
display_name: String::new(),
description: String::new(),
schedule_json: "{}".to_owned(),
schedule_timezone: "UTC".to_owned(),
next_fires_json: "[]".to_owned(),
prompt: "do the thing".to_owned(),
prompt_truncated: false,
ready: true,
phase: Some("Ready".to_owned()),
message: None,
last_fire_time_ms: None,
next_fire_time_ms: None,
conditions_json: "[]".to_owned(),
suspended: false,
paused_by: None,
paused_at_ms: None,
pause_reason: None,
orphaned: false,
setup_completed: false,
}
}
#[test]
fn rows_round_trip_through_the_canonical_encoding() {
let rows = vec![row("a"), row("b")];
let bytes = encode_rows(&rows);
assert_eq!(decode_rows(&bytes).expect("decodes"), rows);
}
#[test]
fn encoding_is_deterministic() {
let rows = vec![row("a"), row("b")];
assert_eq!(encode_rows(&rows), encode_rows(&rows));
}
#[test]
fn an_empty_collection_round_trips() {
let bytes = encode_rows(&[]);
assert_eq!(decode_rows(&bytes).expect("decodes"), Vec::new());
}
#[test]
fn a_foreign_version_is_refused() {
let bytes = br#"{"version":99,"rows":[]}"#;
match decode_rows(bytes) {
Err(DecodeError::UnknownVersion {
found: 99,
expected: 2,
}) => {}
other => panic!("expected UnknownVersion, got {other:?}"),
}
}
#[test]
fn garbage_bytes_are_refused_rather_than_panicking() {
assert!(matches!(
decode_rows(b"not json"),
Err(DecodeError::Malformed(_))
));
}
}