use serde::Serialize;
use crate::diagnostic::Reason;
pub const EVENTS_SCHEMA: &str = "rk.events/1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
Schema,
StepStarted,
StepFinished,
ChildOutput,
RunFinished,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ChildStream {
Stdout,
Stderr,
}
#[derive(Debug, Serialize)]
pub struct Event {
pub schema: &'static str,
pub seq: u64,
pub time: String,
pub run_id: String,
pub command: &'static str,
#[serde(rename = "type")]
pub kind: EventKind,
pub step: Option<String>,
pub status: Option<String>,
pub reason: Option<Reason>,
pub exit_code: Option<i32>,
pub duration_ms: Option<u64>,
pub stream: Option<ChildStream>,
pub data_b64: Option<String>,
}
impl Event {
#[must_use]
pub const fn opening(seq: u64, time: String, run_id: String, command: &'static str) -> Self {
Self {
schema: EVENTS_SCHEMA,
seq,
time,
run_id,
command,
kind: EventKind::Schema,
step: None,
status: None,
reason: None,
exit_code: None,
duration_ms: None,
stream: None,
data_b64: None,
}
}
#[must_use]
pub fn child_output(mut self, stream: ChildStream, bytes: &[u8]) -> Self {
self.kind = EventKind::ChildOutput;
self.stream = Some(stream);
self.data_b64 = Some(base64(bytes));
self
}
}
fn base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
for (idx, shift) in [18u32, 12, 6, 0].into_iter().enumerate() {
if idx <= chunk.len() {
out.push(char::from(ALPHABET[(n >> shift) as usize & 0x3f]));
} else {
out.push('=');
}
}
}
out
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{ChildStream, Event, EventKind, base64};
#[test]
fn the_event_schema_snapshot_holds() {
let mut event =
Event::opening(0, "2026-08-29T14:10:31Z".into(), "01K5NQ7X".into(), "setup");
assert_eq!(
serde_json::to_string(&event).expect("an event serializes"),
r#"{"schema":"rk.events/1","seq":0,"time":"2026-08-29T14:10:31Z","run_id":"01K5NQ7X","command":"setup","type":"schema","step":null,"status":null,"reason":null,"exit_code":null,"duration_ms":null,"stream":null,"data_b64":null}"#
);
event.seq = 12;
event.kind = EventKind::StepFinished;
event.step = Some("protect-tags".into());
event.status = Some("satisfied".into());
event.exit_code = Some(0);
event.duration_ms = Some(418);
assert_eq!(
serde_json::to_string(&event).expect("an event serializes"),
r#"{"schema":"rk.events/1","seq":12,"time":"2026-08-29T14:10:31Z","run_id":"01K5NQ7X","command":"setup","type":"step_finished","step":"protect-tags","status":"satisfied","reason":null,"exit_code":0,"duration_ms":418,"stream":null,"data_b64":null}"#
);
}
#[test]
fn a_child_output_event_carries_the_chunk_losslessly() {
let event = Event::opening(3, "2026-08-29T14:10:31Z".into(), "01K5NQ7X".into(), "setup")
.child_output(ChildStream::Stderr, &[0x66, 0x6f, 0x6f, 0xff, 0xfe]);
assert_eq!(
serde_json::to_string(&event).expect("an event serializes"),
r#"{"schema":"rk.events/1","seq":3,"time":"2026-08-29T14:10:31Z","run_id":"01K5NQ7X","command":"setup","type":"child_output","step":null,"status":null,"reason":null,"exit_code":null,"duration_ms":null,"stream":"stderr","data_b64":"Zm9v//4="}"#
);
}
#[test]
fn the_base64_encoder_matches_the_rfc_vectors() {
for (input, expected) in [
(&b""[..], ""),
(b"f", "Zg=="),
(b"fo", "Zm8="),
(b"foo", "Zm9v"),
(b"foob", "Zm9vYg=="),
(b"fooba", "Zm9vYmE="),
(b"foobar", "Zm9vYmFy"),
] {
assert_eq!(base64(input), expected);
}
}
}