use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::domain::digest;
use crate::domain::error::{AivcsError, Result};
pub const DEFAULT_STAGE_TIMEOUT_MS: u64 = 300_000;
pub const DEFAULT_TOTAL_TIMEOUT_MS: u64 = 1_200_000;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CITrigger {
Manual,
PreMerge,
PostCommit,
Scheduled,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CIRunSpec {
pub run_id: Uuid,
pub spec_digest: String,
pub git_sha: String,
pub stages: Vec<String>,
pub trigger: CITrigger,
pub stage_timeout_ms: u64,
pub total_timeout_ms: u64,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CIRunSpecFields {
pub git_sha: String,
pub stages: Vec<String>,
pub trigger: CITrigger,
pub stage_timeout_ms: u64,
pub total_timeout_ms: u64,
}
impl CIRunSpec {
pub fn new(git_sha: String, stages: Vec<String>, trigger: CITrigger) -> Result<Self> {
Self::new_with_timeouts(
git_sha,
stages,
trigger,
DEFAULT_STAGE_TIMEOUT_MS,
DEFAULT_TOTAL_TIMEOUT_MS,
)
}
pub fn new_with_timeouts(
git_sha: String,
stages: Vec<String>,
trigger: CITrigger,
stage_timeout_ms: u64,
total_timeout_ms: u64,
) -> Result<Self> {
if git_sha.is_empty() {
return Err(AivcsError::InvalidCIRunSpec(
"git_sha cannot be empty".to_string(),
));
}
if stages.is_empty() {
return Err(AivcsError::InvalidCIRunSpec(
"stages cannot be empty".to_string(),
));
}
if stage_timeout_ms == 0 {
return Err(AivcsError::InvalidCIRunSpec(
"stage_timeout_ms must be > 0".to_string(),
));
}
if total_timeout_ms == 0 {
return Err(AivcsError::InvalidCIRunSpec(
"total_timeout_ms must be > 0".to_string(),
));
}
if total_timeout_ms < stage_timeout_ms {
return Err(AivcsError::InvalidCIRunSpec(
"total_timeout_ms must be >= stage_timeout_ms".to_string(),
));
}
let fields = CIRunSpecFields {
git_sha: git_sha.clone(),
stages: stages.clone(),
trigger,
stage_timeout_ms,
total_timeout_ms,
};
let spec_digest = Self::compute_digest(&fields)?;
Ok(Self {
run_id: Uuid::new_v4(),
spec_digest,
git_sha,
stages,
trigger,
stage_timeout_ms,
total_timeout_ms,
created_at: Utc::now(),
})
}
pub fn compute_digest(fields: &CIRunSpecFields) -> Result<String> {
let json = serde_json::to_value(fields)?;
digest::compute_digest(&json)
}
pub fn verify_digest(&self) -> Result<()> {
let fields = CIRunSpecFields {
git_sha: self.git_sha.clone(),
stages: self.stages.clone(),
trigger: self.trigger,
stage_timeout_ms: self.stage_timeout_ms,
total_timeout_ms: self.total_timeout_ms,
};
let computed = Self::compute_digest(&fields)?;
if computed != self.spec_digest {
return Err(AivcsError::DigestMismatch {
expected: self.spec_digest.clone(),
actual: computed,
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ci_run_spec_serde_roundtrip() {
let spec = CIRunSpec::new(
"abc123".to_string(),
vec!["fmt".to_string(), "clippy".to_string(), "test".to_string()],
CITrigger::PreMerge,
)
.expect("create spec");
let json = serde_json::to_string(&spec).expect("serialize");
let deserialized: CIRunSpec = serde_json::from_str(&json).expect("deserialize");
assert_eq!(spec, deserialized);
}
#[test]
fn test_ci_run_spec_digest_stable() {
let fields1 = CIRunSpecFields {
git_sha: "abc123".to_string(),
stages: vec!["fmt".to_string(), "test".to_string()],
trigger: CITrigger::Manual,
stage_timeout_ms: DEFAULT_STAGE_TIMEOUT_MS,
total_timeout_ms: DEFAULT_TOTAL_TIMEOUT_MS,
};
let fields2 = fields1.clone();
let d1 = CIRunSpec::compute_digest(&fields1).expect("digest 1");
let d2 = CIRunSpec::compute_digest(&fields2).expect("digest 2");
assert_eq!(d1, d2);
}
#[test]
fn test_ci_run_spec_digest_changes_on_mutation() {
let fields1 = CIRunSpecFields {
git_sha: "abc123".to_string(),
stages: vec!["fmt".to_string()],
trigger: CITrigger::Manual,
stage_timeout_ms: DEFAULT_STAGE_TIMEOUT_MS,
total_timeout_ms: DEFAULT_TOTAL_TIMEOUT_MS,
};
let fields2 = CIRunSpecFields {
git_sha: "abc123".to_string(),
stages: vec!["fmt".to_string(), "test".to_string()],
trigger: CITrigger::Manual,
stage_timeout_ms: DEFAULT_STAGE_TIMEOUT_MS,
total_timeout_ms: DEFAULT_TOTAL_TIMEOUT_MS,
};
let d1 = CIRunSpec::compute_digest(&fields1).expect("digest 1");
let d2 = CIRunSpec::compute_digest(&fields2).expect("digest 2");
assert_ne!(d1, d2);
}
#[test]
fn test_ci_run_spec_verify_digest() {
let spec = CIRunSpec::new(
"abc123".to_string(),
vec!["fmt".to_string()],
CITrigger::PostCommit,
)
.expect("create spec");
assert!(spec.verify_digest().is_ok());
}
#[test]
fn test_ci_run_spec_rejects_empty_git_sha() {
let result = CIRunSpec::new("".to_string(), vec!["fmt".to_string()], CITrigger::Manual);
assert!(matches!(result, Err(AivcsError::InvalidCIRunSpec(_))));
}
#[test]
fn test_ci_run_spec_rejects_empty_stages() {
let result = CIRunSpec::new("abc123".to_string(), vec![], CITrigger::Manual);
assert!(matches!(result, Err(AivcsError::InvalidCIRunSpec(_))));
}
#[test]
fn test_ci_run_spec_accepts_custom_timeouts() {
let spec = CIRunSpec::new_with_timeouts(
"abc123".to_string(),
vec!["fmt".to_string(), "test".to_string()],
CITrigger::Manual,
60_000,
600_000,
)
.expect("create spec");
assert_eq!(spec.stage_timeout_ms, 60_000);
assert_eq!(spec.total_timeout_ms, 600_000);
}
#[test]
fn test_ci_trigger_serde() {
let triggers = [
CITrigger::Manual,
CITrigger::PreMerge,
CITrigger::PostCommit,
CITrigger::Scheduled,
];
for trigger in &triggers {
let json = serde_json::to_string(trigger).expect("serialize");
let deserialized: CITrigger = serde_json::from_str(&json).expect("deserialize");
assert_eq!(*trigger, deserialized);
}
}
}