1use serde::Serialize;
11
12use crate::diagnostic::Reason;
13
14pub const EVENTS_SCHEMA: &str = "rk.events/1";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum EventKind {
22 Schema,
24 StepStarted,
26 StepFinished,
28 ChildOutput,
30 RunFinished,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "snake_case")]
37pub enum ChildStream {
38 Stdout,
40 Stderr,
42}
43
44#[derive(Debug, Serialize)]
49pub struct Event {
50 pub schema: &'static str,
52 pub seq: u64,
54 pub time: String,
56 pub run_id: String,
58 pub command: &'static str,
60 #[serde(rename = "type")]
62 pub kind: EventKind,
63 pub step: Option<String>,
65 pub status: Option<String>,
67 pub reason: Option<Reason>,
69 pub exit_code: Option<i32>,
71 pub duration_ms: Option<u64>,
73 pub stream: Option<ChildStream>,
75 pub data_b64: Option<String>,
78 pub detail: Option<String>,
82}
83
84impl Event {
85 #[must_use]
87 pub const fn opening(seq: u64, time: String, run_id: String, command: &'static str) -> Self {
88 Self {
89 schema: EVENTS_SCHEMA,
90 seq,
91 time,
92 run_id,
93 command,
94 kind: EventKind::Schema,
95 step: None,
96 status: None,
97 reason: None,
98 exit_code: None,
99 duration_ms: None,
100 stream: None,
101 data_b64: None,
102 detail: None,
103 }
104 }
105
106 #[must_use]
108 pub fn child_output(mut self, stream: ChildStream, bytes: &[u8]) -> Self {
109 self.kind = EventKind::ChildOutput;
110 self.stream = Some(stream);
111 self.data_b64 = Some(base64(bytes));
112 self
113 }
114}
115
116fn base64(bytes: &[u8]) -> String {
119 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
120 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
121 for chunk in bytes.chunks(3) {
122 let b = [
123 chunk[0],
124 *chunk.get(1).unwrap_or(&0),
125 *chunk.get(2).unwrap_or(&0),
126 ];
127 let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
128 for (idx, shift) in [18u32, 12, 6, 0].into_iter().enumerate() {
129 if idx <= chunk.len() {
130 out.push(char::from(ALPHABET[(n >> shift) as usize & 0x3f]));
131 } else {
132 out.push('=');
133 }
134 }
135 }
136 out
137}
138
139#[cfg(test)]
140mod tests {
141 #![allow(clippy::expect_used)]
142
143 use super::{ChildStream, Event, EventKind, base64};
144
145 #[test]
149 fn the_event_schema_snapshot_holds() {
150 let mut event =
151 Event::opening(0, "2026-08-29T14:10:31Z".into(), "01K5NQ7X".into(), "setup");
152 assert_eq!(
153 serde_json::to_string(&event).expect("an event serializes"),
154 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,"detail":null}"#
155 );
156 event.seq = 12;
157 event.kind = EventKind::StepFinished;
158 event.step = Some("protect-tags".into());
159 event.status = Some("satisfied".into());
160 event.exit_code = Some(0);
161 event.duration_ms = Some(418);
162 event.detail = Some("the tag ruleset protects refs/tags/v*".into());
163 assert_eq!(
164 serde_json::to_string(&event).expect("an event serializes"),
165 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,"detail":"the tag ruleset protects refs/tags/v*"}"#
166 );
167 }
168
169 #[test]
172 fn a_child_output_event_carries_the_chunk_losslessly() {
173 let event = Event::opening(3, "2026-08-29T14:10:31Z".into(), "01K5NQ7X".into(), "setup")
174 .child_output(ChildStream::Stderr, &[0x66, 0x6f, 0x6f, 0xff, 0xfe]);
175 assert_eq!(
176 serde_json::to_string(&event).expect("an event serializes"),
177 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=","detail":null}"#
178 );
179 }
180
181 #[test]
184 fn the_base64_encoder_matches_the_rfc_vectors() {
185 for (input, expected) in [
186 (&b""[..], ""),
187 (b"f", "Zg=="),
188 (b"fo", "Zm8="),
189 (b"foo", "Zm9v"),
190 (b"foob", "Zm9vYg=="),
191 (b"fooba", "Zm9vYmE="),
192 (b"foobar", "Zm9vYmFy"),
193 ] {
194 assert_eq!(base64(input), expected);
195 }
196 }
197}