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 Milestone,
34 Snapshot,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "snake_case")]
42pub enum ChildStream {
43 Stdout,
45 Stderr,
47}
48
49#[derive(Debug, Serialize)]
54pub struct Event {
55 pub schema: &'static str,
57 pub seq: u64,
59 pub time: String,
61 pub run_id: String,
63 pub command: &'static str,
65 #[serde(rename = "type")]
67 pub kind: EventKind,
68 pub step: Option<String>,
70 pub status: Option<String>,
72 pub reason: Option<Reason>,
74 pub exit_code: Option<i32>,
76 pub duration_ms: Option<u64>,
78 pub stream: Option<ChildStream>,
80 pub data_b64: Option<String>,
83 pub detail: Option<String>,
87}
88
89impl Event {
90 #[must_use]
92 pub const fn opening(seq: u64, time: String, run_id: String, command: &'static str) -> Self {
93 Self {
94 schema: EVENTS_SCHEMA,
95 seq,
96 time,
97 run_id,
98 command,
99 kind: EventKind::Schema,
100 step: None,
101 status: None,
102 reason: None,
103 exit_code: None,
104 duration_ms: None,
105 stream: None,
106 data_b64: None,
107 detail: None,
108 }
109 }
110
111 #[must_use]
113 pub fn child_output(mut self, stream: ChildStream, bytes: &[u8]) -> Self {
114 self.kind = EventKind::ChildOutput;
115 self.stream = Some(stream);
116 self.data_b64 = Some(base64(bytes));
117 self
118 }
119}
120
121fn base64(bytes: &[u8]) -> String {
124 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
125 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
126 for chunk in bytes.chunks(3) {
127 let b = [
128 chunk[0],
129 *chunk.get(1).unwrap_or(&0),
130 *chunk.get(2).unwrap_or(&0),
131 ];
132 let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
133 for (idx, shift) in [18u32, 12, 6, 0].into_iter().enumerate() {
134 if idx <= chunk.len() {
135 out.push(char::from(ALPHABET[(n >> shift) as usize & 0x3f]));
136 } else {
137 out.push('=');
138 }
139 }
140 }
141 out
142}
143
144#[cfg(test)]
145mod tests {
146 use super::{ChildStream, Event, EventKind, base64};
147
148 #[test]
152 fn the_event_schema_snapshot_holds() {
153 let mut event =
154 Event::opening(0, "2026-08-29T14:10:31Z".into(), "01K5NQ7X".into(), "setup");
155 assert_eq!(
156 serde_json::to_string(&event).expect("an event serializes"),
157 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}"#
158 );
159 event.seq = 12;
160 event.kind = EventKind::StepFinished;
161 event.step = Some("protect-tags".into());
162 event.status = Some("satisfied".into());
163 event.exit_code = Some(0);
164 event.duration_ms = Some(418);
165 event.detail = Some("the tag ruleset protects refs/tags/v*".into());
166 assert_eq!(
167 serde_json::to_string(&event).expect("an event serializes"),
168 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*"}"#
169 );
170 }
171
172 #[test]
175 fn a_child_output_event_carries_the_chunk_losslessly() {
176 let event = Event::opening(3, "2026-08-29T14:10:31Z".into(), "01K5NQ7X".into(), "setup")
177 .child_output(ChildStream::Stderr, &[0x66, 0x6f, 0x6f, 0xff, 0xfe]);
178 assert_eq!(
179 serde_json::to_string(&event).expect("an event serializes"),
180 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}"#
181 );
182 }
183
184 #[test]
187 fn the_base64_encoder_matches_the_rfc_vectors() {
188 for (input, expected) in [
189 (&b""[..], ""),
190 (b"f", "Zg=="),
191 (b"fo", "Zm8="),
192 (b"foo", "Zm9v"),
193 (b"foob", "Zm9vYg=="),
194 (b"fooba", "Zm9vYmE="),
195 (b"foobar", "Zm9vYmFy"),
196 ] {
197 assert_eq!(base64(input), expected);
198 }
199 }
200}