use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CiSnapshot {
pub repo_sha: String,
pub workspace_hash: String,
pub local_ci_config_hash: String,
pub env_hash: String,
}
impl CiSnapshot {
pub fn digest(&self) -> String {
digest_json(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CiCommand {
pub program: String,
pub args: Vec<String>,
pub env: BTreeMap<String, String>,
pub cwd: Option<String>,
}
impl CiCommand {
pub fn digest(&self) -> String {
digest_json(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CiStepSpec {
pub name: String,
pub command: CiCommand,
pub timeout_secs: Option<u64>,
pub allow_failure: bool,
}
impl CiStepSpec {
pub fn digest(&self) -> String {
digest_json(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CiPipelineSpec {
pub name: String,
pub steps: Vec<CiStepSpec>,
}
impl CiPipelineSpec {
pub fn digest(&self) -> String {
digest_json(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CiRunStatus {
Queued,
Running,
Succeeded,
Failed,
Cancelled,
}
impl CiRunStatus {
pub fn is_terminal(self) -> bool {
matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CiStepResult {
pub step_name: String,
pub status: CiRunStatus,
pub exit_code: Option<i32>,
pub started_at: Option<String>,
pub finished_at: Option<String>,
pub stdout_digest: Option<String>,
pub stderr_digest: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CiArtifact {
pub name: String,
pub path: String,
pub digest: String,
pub size_bytes: u64,
pub media_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CiRunRecord {
pub run_id: String,
pub snapshot_digest: String,
pub pipeline_digest: String,
pub status: CiRunStatus,
pub step_results: Vec<CiStepResult>,
pub artifacts: Vec<CiArtifact>,
pub started_at: Option<String>,
pub finished_at: Option<String>,
pub metadata: BTreeMap<String, String>,
}
impl CiRunRecord {
pub fn queued(snapshot_digest: impl AsRef<str>, pipeline_digest: impl AsRef<str>) -> Self {
let snapshot_digest = snapshot_digest.as_ref().to_string();
let pipeline_digest = pipeline_digest.as_ref().to_string();
let run_id = digest_two(&snapshot_digest, &pipeline_digest);
Self {
run_id,
snapshot_digest,
pipeline_digest,
status: CiRunStatus::Queued,
step_results: Vec::new(),
artifacts: Vec::new(),
started_at: None,
finished_at: None,
metadata: BTreeMap::new(),
}
}
pub fn digest(&self) -> String {
digest_json(self)
}
}
fn digest_json<T: Serialize>(value: &T) -> String {
let bytes =
serde_json::to_vec(value).expect("CI domain objects must be serializable for hashing");
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
fn digest_two(left: &str, right: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(left.as_bytes());
hasher.update([0u8]);
hasher.update(right.as_bytes());
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_digest_is_deterministic() {
let a = CiSnapshot {
repo_sha: "abc123".to_string(),
workspace_hash: "ws1".to_string(),
local_ci_config_hash: "cfg1".to_string(),
env_hash: "env1".to_string(),
};
let b = a.clone();
assert_eq!(a.digest(), b.digest());
}
#[test]
fn pipeline_digest_changes_when_step_changes() {
let mut env = BTreeMap::new();
env.insert("RUST_LOG".to_string(), "info".to_string());
let p1 = CiPipelineSpec {
name: "default".to_string(),
steps: vec![CiStepSpec {
name: "test".to_string(),
command: CiCommand {
program: "cargo".to_string(),
args: vec!["test".to_string()],
env: env.clone(),
cwd: None,
},
timeout_secs: Some(600),
allow_failure: false,
}],
};
let p2 = CiPipelineSpec {
steps: vec![CiStepSpec {
name: "test".to_string(),
command: CiCommand {
program: "cargo".to_string(),
args: vec!["test".to_string(), "--all-features".to_string()],
env,
cwd: None,
},
timeout_secs: Some(600),
allow_failure: false,
}],
..p1.clone()
};
assert_ne!(p1.digest(), p2.digest());
}
#[test]
fn queued_run_id_is_stable_for_same_inputs() {
let r1 = CiRunRecord::queued("snap-a", "pipe-b");
let r2 = CiRunRecord::queued("snap-a", "pipe-b");
assert_eq!(r1.run_id, r2.run_id);
assert_eq!(r1.status, CiRunStatus::Queued);
}
#[test]
fn status_terminal_semantics_are_correct() {
assert!(!CiRunStatus::Queued.is_terminal());
assert!(!CiRunStatus::Running.is_terminal());
assert!(CiRunStatus::Succeeded.is_terminal());
assert!(CiRunStatus::Failed.is_terminal());
assert!(CiRunStatus::Cancelled.is_terminal());
}
}