1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use super::digest;
8use super::error::{AivcsError, Result};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct AgentSpec {
13 pub spec_id: Uuid,
15
16 pub spec_digest: String,
18
19 pub git_sha: String,
21
22 pub graph_digest: String,
24
25 pub prompts_digest: String,
27
28 pub tools_digest: String,
30
31 pub config_digest: String,
33
34 pub created_at: DateTime<Utc>,
36
37 pub metadata: serde_json::Value,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct AgentSpecFields {
44 pub git_sha: String,
45 pub graph_digest: String,
46 pub prompts_digest: String,
47 pub tools_digest: String,
48 pub config_digest: String,
49}
50
51impl AgentSpec {
52 pub fn new(
54 git_sha: String,
55 graph_digest: String,
56 prompts_digest: String,
57 tools_digest: String,
58 config_digest: String,
59 ) -> Result<Self> {
60 if git_sha.is_empty() {
61 return Err(AivcsError::InvalidAgentSpec(
62 "git_sha cannot be empty".to_string(),
63 ));
64 }
65
66 let fields = AgentSpecFields {
67 git_sha: git_sha.clone(),
68 graph_digest: graph_digest.clone(),
69 prompts_digest: prompts_digest.clone(),
70 tools_digest: tools_digest.clone(),
71 config_digest: config_digest.clone(),
72 };
73
74 let spec_digest = Self::compute_digest(&fields)?;
75
76 Ok(Self {
77 spec_id: Uuid::new_v4(),
78 spec_digest,
79 git_sha,
80 graph_digest,
81 prompts_digest,
82 tools_digest,
83 config_digest,
84 created_at: Utc::now(),
85 metadata: serde_json::json!({}),
86 })
87 }
88
89 pub fn compute_digest(fields: &AgentSpecFields) -> Result<String> {
91 let json = serde_json::to_value(fields)?;
92 digest::compute_digest(&json)
93 }
94
95 pub fn verify_digest(&self) -> Result<()> {
97 let fields = AgentSpecFields {
98 git_sha: self.git_sha.clone(),
99 graph_digest: self.graph_digest.clone(),
100 prompts_digest: self.prompts_digest.clone(),
101 tools_digest: self.tools_digest.clone(),
102 config_digest: self.config_digest.clone(),
103 };
104
105 let computed = Self::compute_digest(&fields)?;
106 if computed != self.spec_digest {
107 return Err(AivcsError::DigestMismatch {
108 expected: self.spec_digest.clone(),
109 actual: computed,
110 });
111 }
112 Ok(())
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn test_agent_spec_serde_roundtrip() {
122 let spec = AgentSpec::new(
123 "abc123def456".to_string(),
124 "graph111".to_string(),
125 "prompts222".to_string(),
126 "tools333".to_string(),
127 "config444".to_string(),
128 )
129 .expect("create spec");
130
131 let json = serde_json::to_string(&spec).expect("serialize");
132 let deserialized: AgentSpec = serde_json::from_str(&json).expect("deserialize");
133
134 assert_eq!(spec, deserialized);
135 }
136
137 #[test]
138 fn test_agent_spec_digest_stable() {
139 let fields1 = AgentSpecFields {
140 git_sha: "abc123".to_string(),
141 graph_digest: "graph111".to_string(),
142 prompts_digest: "prompts222".to_string(),
143 tools_digest: "tools333".to_string(),
144 config_digest: "config444".to_string(),
145 };
146
147 let fields2 = AgentSpecFields {
148 git_sha: "abc123".to_string(),
149 graph_digest: "graph111".to_string(),
150 prompts_digest: "prompts222".to_string(),
151 tools_digest: "tools333".to_string(),
152 config_digest: "config444".to_string(),
153 };
154
155 let digest1 = AgentSpec::compute_digest(&fields1).expect("compute digest 1");
156 let digest2 = AgentSpec::compute_digest(&fields2).expect("compute digest 2");
157
158 assert_eq!(digest1, digest2, "same inputs should produce same digest");
159 }
160
161 #[test]
162 fn test_agent_spec_digest_changes_on_mutation() {
163 let fields1 = AgentSpecFields {
164 git_sha: "abc123".to_string(),
165 graph_digest: "graph111".to_string(),
166 prompts_digest: "prompts222".to_string(),
167 tools_digest: "tools333".to_string(),
168 config_digest: "config444".to_string(),
169 };
170
171 let fields2 = AgentSpecFields {
172 git_sha: "abc123".to_string(),
173 graph_digest: "graph111_MODIFIED".to_string(),
174 prompts_digest: "prompts222".to_string(),
175 tools_digest: "tools333".to_string(),
176 config_digest: "config444".to_string(),
177 };
178
179 let digest1 = AgentSpec::compute_digest(&fields1).expect("compute digest 1");
180 let digest2 = AgentSpec::compute_digest(&fields2).expect("compute digest 2");
181
182 assert_ne!(
183 digest1, digest2,
184 "changed field should produce different digest"
185 );
186 }
187
188 #[test]
189 fn test_agent_spec_verify_digest() {
190 let spec = AgentSpec::new(
191 "abc123".to_string(),
192 "graph111".to_string(),
193 "prompts222".to_string(),
194 "tools333".to_string(),
195 "config444".to_string(),
196 )
197 .expect("create spec");
198
199 assert!(spec.verify_digest().is_ok(), "spec digest should be valid");
200 }
201
202 #[test]
203 fn test_agent_spec_new_rejects_empty_git_sha() {
204 let result = AgentSpec::new(
205 "".to_string(),
206 "graph111".to_string(),
207 "prompts222".to_string(),
208 "tools333".to_string(),
209 "config444".to_string(),
210 );
211
212 assert!(
213 result.is_err(),
214 "creating spec with empty git_sha should fail"
215 );
216 }
217
218 #[test]
219 fn test_agent_spec_digest_golden_value() {
220 let fields = AgentSpecFields {
222 git_sha: "abc123def456".to_string(),
223 graph_digest: "graph111".to_string(),
224 prompts_digest: "prompts222".to_string(),
225 tools_digest: "tools333".to_string(),
226 config_digest: "config444".to_string(),
227 };
228
229 let digest = AgentSpec::compute_digest(&fields).expect("compute digest");
230
231 assert_eq!(digest.len(), 64);
233 assert!(digest.chars().all(|c: char| c.is_ascii_hexdigit()));
234
235 let digest2 = AgentSpec::compute_digest(&fields).expect("compute digest again");
237 assert_eq!(digest, digest2);
238 }
239
240 #[test]
241 fn test_agent_spec_field_order_invariant() {
242 let fields1 = AgentSpecFields {
244 git_sha: "abc123".to_string(),
245 graph_digest: "graph111".to_string(),
246 prompts_digest: "prompts222".to_string(),
247 tools_digest: "tools333".to_string(),
248 config_digest: "config444".to_string(),
249 };
250
251 let digest1 = AgentSpec::compute_digest(&fields1).expect("compute digest 1");
252
253 let json_str = serde_json::to_string(&fields1).expect("serialize");
255 let fields2: AgentSpecFields = serde_json::from_str(&json_str).expect("deserialize");
256 let digest2 = AgentSpec::compute_digest(&fields2).expect("compute digest 2");
257
258 assert_eq!(
259 digest1, digest2,
260 "digests should match regardless of construction path"
261 );
262 }
263}