use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::digest;
use super::error::{AivcsError, Result};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentSpec {
pub spec_id: Uuid,
pub spec_digest: String,
pub git_sha: String,
pub graph_digest: String,
pub prompts_digest: String,
pub tools_digest: String,
pub config_digest: String,
pub created_at: DateTime<Utc>,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSpecFields {
pub git_sha: String,
pub graph_digest: String,
pub prompts_digest: String,
pub tools_digest: String,
pub config_digest: String,
}
impl AgentSpec {
pub fn new(
git_sha: String,
graph_digest: String,
prompts_digest: String,
tools_digest: String,
config_digest: String,
) -> Result<Self> {
if git_sha.is_empty() {
return Err(AivcsError::InvalidAgentSpec(
"git_sha cannot be empty".to_string(),
));
}
let fields = AgentSpecFields {
git_sha: git_sha.clone(),
graph_digest: graph_digest.clone(),
prompts_digest: prompts_digest.clone(),
tools_digest: tools_digest.clone(),
config_digest: config_digest.clone(),
};
let spec_digest = Self::compute_digest(&fields)?;
Ok(Self {
spec_id: Uuid::new_v4(),
spec_digest,
git_sha,
graph_digest,
prompts_digest,
tools_digest,
config_digest,
created_at: Utc::now(),
metadata: serde_json::json!({}),
})
}
pub fn compute_digest(fields: &AgentSpecFields) -> Result<String> {
let json = serde_json::to_value(fields)?;
digest::compute_digest(&json)
}
pub fn verify_digest(&self) -> Result<()> {
let fields = AgentSpecFields {
git_sha: self.git_sha.clone(),
graph_digest: self.graph_digest.clone(),
prompts_digest: self.prompts_digest.clone(),
tools_digest: self.tools_digest.clone(),
config_digest: self.config_digest.clone(),
};
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_agent_spec_serde_roundtrip() {
let spec = AgentSpec::new(
"abc123def456".to_string(),
"graph111".to_string(),
"prompts222".to_string(),
"tools333".to_string(),
"config444".to_string(),
)
.expect("create spec");
let json = serde_json::to_string(&spec).expect("serialize");
let deserialized: AgentSpec = serde_json::from_str(&json).expect("deserialize");
assert_eq!(spec, deserialized);
}
#[test]
fn test_agent_spec_digest_stable() {
let fields1 = AgentSpecFields {
git_sha: "abc123".to_string(),
graph_digest: "graph111".to_string(),
prompts_digest: "prompts222".to_string(),
tools_digest: "tools333".to_string(),
config_digest: "config444".to_string(),
};
let fields2 = AgentSpecFields {
git_sha: "abc123".to_string(),
graph_digest: "graph111".to_string(),
prompts_digest: "prompts222".to_string(),
tools_digest: "tools333".to_string(),
config_digest: "config444".to_string(),
};
let digest1 = AgentSpec::compute_digest(&fields1).expect("compute digest 1");
let digest2 = AgentSpec::compute_digest(&fields2).expect("compute digest 2");
assert_eq!(digest1, digest2, "same inputs should produce same digest");
}
#[test]
fn test_agent_spec_digest_changes_on_mutation() {
let fields1 = AgentSpecFields {
git_sha: "abc123".to_string(),
graph_digest: "graph111".to_string(),
prompts_digest: "prompts222".to_string(),
tools_digest: "tools333".to_string(),
config_digest: "config444".to_string(),
};
let fields2 = AgentSpecFields {
git_sha: "abc123".to_string(),
graph_digest: "graph111_MODIFIED".to_string(),
prompts_digest: "prompts222".to_string(),
tools_digest: "tools333".to_string(),
config_digest: "config444".to_string(),
};
let digest1 = AgentSpec::compute_digest(&fields1).expect("compute digest 1");
let digest2 = AgentSpec::compute_digest(&fields2).expect("compute digest 2");
assert_ne!(
digest1, digest2,
"changed field should produce different digest"
);
}
#[test]
fn test_agent_spec_verify_digest() {
let spec = AgentSpec::new(
"abc123".to_string(),
"graph111".to_string(),
"prompts222".to_string(),
"tools333".to_string(),
"config444".to_string(),
)
.expect("create spec");
assert!(spec.verify_digest().is_ok(), "spec digest should be valid");
}
#[test]
fn test_agent_spec_new_rejects_empty_git_sha() {
let result = AgentSpec::new(
"".to_string(),
"graph111".to_string(),
"prompts222".to_string(),
"tools333".to_string(),
"config444".to_string(),
);
assert!(
result.is_err(),
"creating spec with empty git_sha should fail"
);
}
#[test]
fn test_agent_spec_digest_golden_value() {
let fields = AgentSpecFields {
git_sha: "abc123def456".to_string(),
graph_digest: "graph111".to_string(),
prompts_digest: "prompts222".to_string(),
tools_digest: "tools333".to_string(),
config_digest: "config444".to_string(),
};
let digest = AgentSpec::compute_digest(&fields).expect("compute digest");
assert_eq!(digest.len(), 64);
assert!(digest.chars().all(|c: char| c.is_ascii_hexdigit()));
let digest2 = AgentSpec::compute_digest(&fields).expect("compute digest again");
assert_eq!(digest, digest2);
}
#[test]
fn test_agent_spec_field_order_invariant() {
let fields1 = AgentSpecFields {
git_sha: "abc123".to_string(),
graph_digest: "graph111".to_string(),
prompts_digest: "prompts222".to_string(),
tools_digest: "tools333".to_string(),
config_digest: "config444".to_string(),
};
let digest1 = AgentSpec::compute_digest(&fields1).expect("compute digest 1");
let json_str = serde_json::to_string(&fields1).expect("serialize");
let fields2: AgentSpecFields = serde_json::from_str(&json_str).expect("deserialize");
let digest2 = AgentSpec::compute_digest(&fields2).expect("compute digest 2");
assert_eq!(
digest1, digest2,
"digests should match regardless of construction path"
);
}
}