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 use super::{ChildStream, Event, EventKind, base64};
142
143 #[test]
147 fn the_event_schema_snapshot_holds() {
148 let mut event =
149 Event::opening(0, "2026-08-29T14:10:31Z".into(), "01K5NQ7X".into(), "setup");
150 assert_eq!(
151 serde_json::to_string(&event).expect("an event serializes"),
152 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}"#
153 );
154 event.seq = 12;
155 event.kind = EventKind::StepFinished;
156 event.step = Some("protect-tags".into());
157 event.status = Some("satisfied".into());
158 event.exit_code = Some(0);
159 event.duration_ms = Some(418);
160 event.detail = Some("the tag ruleset protects refs/tags/v*".into());
161 assert_eq!(
162 serde_json::to_string(&event).expect("an event serializes"),
163 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*"}"#
164 );
165 }
166
167 #[test]
170 fn a_child_output_event_carries_the_chunk_losslessly() {
171 let event = Event::opening(3, "2026-08-29T14:10:31Z".into(), "01K5NQ7X".into(), "setup")
172 .child_output(ChildStream::Stderr, &[0x66, 0x6f, 0x6f, 0xff, 0xfe]);
173 assert_eq!(
174 serde_json::to_string(&event).expect("an event serializes"),
175 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}"#
176 );
177 }
178
179 #[test]
182 fn the_base64_encoder_matches_the_rfc_vectors() {
183 for (input, expected) in [
184 (&b""[..], ""),
185 (b"f", "Zg=="),
186 (b"fo", "Zm8="),
187 (b"foo", "Zm9v"),
188 (b"foob", "Zm9vYg=="),
189 (b"fooba", "Zm9vYmE="),
190 (b"foobar", "Zm9vYmFy"),
191 ] {
192 assert_eq!(base64(input), expected);
193 }
194 }
195}