1use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct CiSnapshot {
14 pub repo_sha: String,
16 pub workspace_hash: String,
18 pub local_ci_config_hash: String,
20 pub env_hash: String,
22}
23
24impl CiSnapshot {
25 pub fn digest(&self) -> String {
27 digest_json(self)
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct CiCommand {
34 pub program: String,
36 pub args: Vec<String>,
38 pub env: BTreeMap<String, String>,
40 pub cwd: Option<String>,
42}
43
44impl CiCommand {
45 pub fn digest(&self) -> String {
47 digest_json(self)
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct CiStepSpec {
54 pub name: String,
56 pub command: CiCommand,
58 pub timeout_secs: Option<u64>,
60 pub allow_failure: bool,
62}
63
64impl CiStepSpec {
65 pub fn digest(&self) -> String {
67 digest_json(self)
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct CiPipelineSpec {
74 pub name: String,
76 pub steps: Vec<CiStepSpec>,
78}
79
80impl CiPipelineSpec {
81 pub fn digest(&self) -> String {
83 digest_json(self)
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum CiRunStatus {
91 Queued,
92 Running,
93 Succeeded,
94 Failed,
95 Cancelled,
96}
97
98impl CiRunStatus {
99 pub fn is_terminal(self) -> bool {
101 matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled)
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct CiStepResult {
108 pub step_name: String,
110 pub status: CiRunStatus,
112 pub exit_code: Option<i32>,
114 pub started_at: Option<String>,
116 pub finished_at: Option<String>,
118 pub stdout_digest: Option<String>,
120 pub stderr_digest: Option<String>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct CiArtifact {
127 pub name: String,
129 pub path: String,
131 pub digest: String,
133 pub size_bytes: u64,
135 pub media_type: Option<String>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct CiRunRecord {
142 pub run_id: String,
144 pub snapshot_digest: String,
146 pub pipeline_digest: String,
148 pub status: CiRunStatus,
150 pub step_results: Vec<CiStepResult>,
152 pub artifacts: Vec<CiArtifact>,
154 pub started_at: Option<String>,
156 pub finished_at: Option<String>,
158 pub metadata: BTreeMap<String, String>,
160}
161
162impl CiRunRecord {
163 pub fn queued(snapshot_digest: impl AsRef<str>, pipeline_digest: impl AsRef<str>) -> Self {
165 let snapshot_digest = snapshot_digest.as_ref().to_string();
166 let pipeline_digest = pipeline_digest.as_ref().to_string();
167 let run_id = digest_two(&snapshot_digest, &pipeline_digest);
168
169 Self {
170 run_id,
171 snapshot_digest,
172 pipeline_digest,
173 status: CiRunStatus::Queued,
174 step_results: Vec::new(),
175 artifacts: Vec::new(),
176 started_at: None,
177 finished_at: None,
178 metadata: BTreeMap::new(),
179 }
180 }
181
182 pub fn digest(&self) -> String {
184 digest_json(self)
185 }
186}
187
188fn digest_json<T: Serialize>(value: &T) -> String {
189 let bytes =
190 serde_json::to_vec(value).expect("CI domain objects must be serializable for hashing");
191 let mut hasher = Sha256::new();
192 hasher.update(bytes);
193 hex::encode(hasher.finalize())
194}
195
196fn digest_two(left: &str, right: &str) -> String {
197 let mut hasher = Sha256::new();
198 hasher.update(left.as_bytes());
199 hasher.update([0u8]);
200 hasher.update(right.as_bytes());
201 hex::encode(hasher.finalize())
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn snapshot_digest_is_deterministic() {
210 let a = CiSnapshot {
211 repo_sha: "abc123".to_string(),
212 workspace_hash: "ws1".to_string(),
213 local_ci_config_hash: "cfg1".to_string(),
214 env_hash: "env1".to_string(),
215 };
216 let b = a.clone();
217 assert_eq!(a.digest(), b.digest());
218 }
219
220 #[test]
221 fn pipeline_digest_changes_when_step_changes() {
222 let mut env = BTreeMap::new();
223 env.insert("RUST_LOG".to_string(), "info".to_string());
224
225 let p1 = CiPipelineSpec {
226 name: "default".to_string(),
227 steps: vec![CiStepSpec {
228 name: "test".to_string(),
229 command: CiCommand {
230 program: "cargo".to_string(),
231 args: vec!["test".to_string()],
232 env: env.clone(),
233 cwd: None,
234 },
235 timeout_secs: Some(600),
236 allow_failure: false,
237 }],
238 };
239
240 let p2 = CiPipelineSpec {
241 steps: vec![CiStepSpec {
242 name: "test".to_string(),
243 command: CiCommand {
244 program: "cargo".to_string(),
245 args: vec!["test".to_string(), "--all-features".to_string()],
246 env,
247 cwd: None,
248 },
249 timeout_secs: Some(600),
250 allow_failure: false,
251 }],
252 ..p1.clone()
253 };
254
255 assert_ne!(p1.digest(), p2.digest());
256 }
257
258 #[test]
259 fn queued_run_id_is_stable_for_same_inputs() {
260 let r1 = CiRunRecord::queued("snap-a", "pipe-b");
261 let r2 = CiRunRecord::queued("snap-a", "pipe-b");
262 assert_eq!(r1.run_id, r2.run_id);
263 assert_eq!(r1.status, CiRunStatus::Queued);
264 }
265
266 #[test]
267 fn status_terminal_semantics_are_correct() {
268 assert!(!CiRunStatus::Queued.is_terminal());
269 assert!(!CiRunStatus::Running.is_terminal());
270 assert!(CiRunStatus::Succeeded.is_terminal());
271 assert!(CiRunStatus::Failed.is_terminal());
272 assert!(CiRunStatus::Cancelled.is_terminal());
273 }
274}