1use super::content_hash::{compute_asset_id, AssetIdError};
7use super::gene::GeneCategory;
8use serde::{Deserialize, Serialize};
9
10#[derive(Clone, Debug, Serialize, Deserialize, Default)]
12pub struct CapsuleContent {
13 #[serde(default)]
15 pub intent: String,
16 #[serde(default)]
18 pub strategy: String,
19 #[serde(default)]
21 pub scope: String,
22 #[serde(default, rename = "changed_files")]
24 pub changed_files: Vec<String>,
25 #[serde(default)]
27 pub rationale: String,
28 #[serde(default)]
30 pub outcome: String,
31}
32
33#[derive(Clone, Debug, Serialize, Deserialize, Default)]
35pub struct TriggerContext {
36 #[serde(default)]
38 pub prompt: Option<String>,
39 #[serde(default, rename = "reasoning_trace")]
41 pub reasoning_trace: Option<String>,
42 #[serde(default, rename = "context_signals")]
44 pub context_signals: Vec<String>,
45 #[serde(default)]
47 pub session_id: Option<String>,
48 #[serde(default, rename = "agent_model")]
50 pub agent_model: Option<String>,
51}
52
53#[derive(Clone, Debug, Serialize, Deserialize, Default)]
55pub struct BlastRadius {
56 pub files: usize,
58 pub lines: usize,
60}
61
62#[derive(Clone, Debug, Serialize, Deserialize)]
64pub struct CapsuleOutcome {
65 pub status: CapsuleStatus,
67 pub score: f32,
69 #[serde(default)]
71 pub note: Option<String>,
72}
73
74#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "lowercase")]
76pub enum CapsuleStatus {
77 Success,
78 Failed,
79}
80
81impl Default for CapsuleOutcome {
82 fn default() -> Self {
83 Self {
84 status: CapsuleStatus::Failed,
85 score: 0.0,
86 note: None,
87 }
88 }
89}
90
91#[derive(Clone, Debug, Serialize, Deserialize, Default)]
93pub struct EnvFingerprint {
94 #[serde(default)]
96 pub runtime: Option<String>,
97 #[serde(default)]
99 pub os: Option<String>,
100 #[serde(flatten)]
102 pub extra: std::collections::HashMap<String, serde_json::Value>,
103}
104
105#[derive(Clone, Debug, Serialize, Deserialize)]
107pub struct GepCapsule {
108 #[serde(rename = "type")]
110 pub capsule_type: String,
111 #[serde(rename = "schema_version")]
113 pub schema_version: String,
114 pub id: String,
116 pub trigger: Vec<String>,
118 pub gene: String,
120 pub summary: String,
122 #[serde(default)]
124 pub content: Option<CapsuleContent>,
125 #[serde(default)]
127 pub diff: Option<String>,
128 #[serde(default)]
130 pub strategy: Option<Vec<String>>,
131 pub confidence: f32,
133 #[serde(default, rename = "blast_radius")]
135 pub blast_radius: BlastRadius,
136 pub outcome: CapsuleOutcome,
138 #[serde(default, rename = "success_streak")]
140 pub success_streak: Option<u32>,
141 #[serde(default, rename = "env_fingerprint")]
143 pub env_fingerprint: Option<EnvFingerprint>,
144 #[serde(default, rename = "model_name")]
146 pub model_name: Option<String>,
147 #[serde(rename = "asset_id")]
149 pub asset_id: String,
150 #[serde(default, rename = "trigger_context")]
152 pub trigger_context: Option<TriggerContext>,
153 #[serde(default, rename = "reused_asset_id")]
155 pub reused_asset_id: Option<String>,
156 #[serde(default, rename = "source_type")]
158 pub source_type: CapsuleSourceType,
159}
160
161#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
162#[serde(rename_all = "lowercase")]
163pub enum CapsuleSourceType {
164 Generated,
165 Reused,
166 Reference,
167}
168
169impl Default for CapsuleSourceType {
170 fn default() -> Self {
171 Self::Generated
172 }
173}
174
175impl GepCapsule {
176 pub fn new(
178 id: String,
179 trigger: Vec<String>,
180 gene: String,
181 summary: String,
182 diff: String,
183 confidence: f32,
184 ) -> Result<Self, AssetIdError> {
185 let outcome = CapsuleOutcome {
186 status: CapsuleStatus::Success,
187 score: confidence,
188 note: None,
189 };
190
191 let mut capsule = Self {
192 capsule_type: "Capsule".to_string(),
193 schema_version: super::GEP_SCHEMA_VERSION.to_string(),
194 id,
195 trigger,
196 gene,
197 summary,
198 content: None,
199 diff: Some(diff),
200 strategy: None,
201 confidence,
202 blast_radius: BlastRadius::default(),
203 outcome,
204 success_streak: None,
205 env_fingerprint: None,
206 model_name: None,
207 asset_id: String::new(),
208 trigger_context: None,
209 reused_asset_id: None,
210 source_type: CapsuleSourceType::Generated,
211 };
212
213 capsule.asset_id = compute_asset_id(&capsule, &["asset_id"])?;
214 Ok(capsule)
215 }
216
217 pub fn validate(&self) -> Result<(), String> {
219 if self.id.is_empty() {
220 return Err("Capsule id cannot be empty".to_string());
221 }
222 if self.gene.is_empty() {
223 return Err("Capsule gene cannot be empty".to_string());
224 }
225
226 let has_substance = self
228 .content
229 .as_ref()
230 .map(|c| c.intent.len() + c.rationale.len() + c.outcome.len())
231 .unwrap_or(0)
232 >= 50
233 || self.diff.as_ref().map(|d| d.len()).unwrap_or(0) >= 50
234 || self
235 .strategy
236 .as_ref()
237 .map(|s| s.join("").len())
238 .unwrap_or(0)
239 >= 50;
240
241 if !has_substance {
242 return Err("Capsule must have at least 50 characters of substance".to_string());
243 }
244
245 Ok(())
246 }
247
248 pub fn with_content(mut self, content: CapsuleContent) -> Self {
250 self.content = Some(content);
251 self
252 }
253
254 pub fn with_strategy(mut self, strategy: Vec<String>) -> Self {
256 self.strategy = Some(strategy);
257 self
258 }
259
260 pub fn with_blast_radius(mut self, files: usize, lines: usize) -> Self {
262 self.blast_radius = BlastRadius { files, lines };
263 self
264 }
265
266 pub fn with_trigger_context(mut self, ctx: TriggerContext) -> Self {
268 self.trigger_context = Some(ctx);
269 self
270 }
271
272 pub fn as_reused(mut self, reused_id: String) -> Self {
274 self.source_type = CapsuleSourceType::Reused;
275 self.reused_asset_id = Some(reused_id);
276 self
277 }
278}
279
280impl From<&crate::Capsule> for GepCapsule {
282 fn from(oris_capsule: &crate::Capsule) -> Self {
283 let outcome = CapsuleOutcome {
284 status: if oris_capsule.outcome.success {
285 CapsuleStatus::Success
286 } else {
287 CapsuleStatus::Failed
288 },
289 score: oris_capsule.confidence,
290 note: None,
291 };
292
293 GepCapsule {
294 capsule_type: "Capsule".to_string(),
295 schema_version: super::GEP_SCHEMA_VERSION.to_string(),
296 id: oris_capsule.id.clone(),
297 trigger: vec![], gene: oris_capsule.gene_id.clone(),
299 summary: format!("Capsule from mutation {}", oris_capsule.mutation_id),
300 content: None,
301 diff: Some(oris_capsule.diff_hash.clone()),
302 strategy: None,
303 confidence: oris_capsule.confidence,
304 blast_radius: BlastRadius {
305 files: oris_capsule.outcome.changed_files.len(),
306 lines: oris_capsule.outcome.lines_changed,
307 },
308 outcome,
309 success_streak: None,
310 env_fingerprint: Some(EnvFingerprint {
311 runtime: Some(oris_capsule.env.rustc_version.clone()),
312 os: Some(oris_capsule.env.os.clone()),
313 extra: std::collections::HashMap::new(),
314 }),
315 model_name: None,
316 asset_id: oris_capsule.diff_hash.clone(),
317 trigger_context: None,
318 reused_asset_id: None,
319 source_type: CapsuleSourceType::Generated,
320 }
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn test_capsule_creation() {
330 let capsule = GepCapsule::new(
331 "capsule_1708123456789".to_string(),
332 vec!["timeout".to_string(), "error".to_string()],
333 "gene_001".to_string(),
334 "Fixed connection timeout issue".to_string(),
335 "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,5 +1,5 @@\n".to_string(),
336 0.85,
337 )
338 .unwrap();
339
340 assert_eq!(capsule.capsule_type, "Capsule");
341 assert_eq!(capsule.schema_version, "1.5.0");
342 assert_eq!(capsule.outcome.status, CapsuleStatus::Success);
343 }
344
345 #[test]
346 fn test_capsule_validate() {
347 let capsule = GepCapsule::new(
348 "capsule_test".to_string(),
349 vec!["test".to_string()],
350 "gene_test".to_string(),
351 "Short".to_string(),
352 "short".to_string(), 0.5,
354 )
355 .unwrap();
356
357 assert!(capsule.validate().is_err());
358 }
359
360 #[test]
361 fn test_capsule_with_content() {
362 let capsule = GepCapsule::new(
363 "capsule_002".to_string(),
364 vec!["error".to_string()],
365 "gene_001".to_string(),
366 "Fixed bug".to_string(),
367 "diff content here that is definitely longer than fifty characters to pass validation"
368 .to_string(),
369 0.9,
370 )
371 .unwrap()
372 .with_content(CapsuleContent {
373 intent: "Fix error handling".to_string(),
374 strategy: "Add try-catch block".to_string(),
375 scope: "src/api.rs".to_string(),
376 changed_files: vec!["src/api.rs".to_string()],
377 rationale: "Error was unhandled".to_string(),
378 outcome: "Fixed".to_string(),
379 });
380
381 assert!(capsule.validate().is_ok());
382 }
383
384 #[test]
385 fn test_reused_capsule() {
386 let capsule = GepCapsule::new(
387 "capsule_reused".to_string(),
388 vec!["timeout".to_string()],
389 "gene_001".to_string(),
390 "Applied fix from capsule_001".to_string(),
391 "diff content that is definitely longer than fifty characters to pass validation checks".to_string(),
392 0.95,
393 ).unwrap()
394 .as_reused("capsule_original_001".to_string());
395
396 assert_eq!(capsule.source_type, CapsuleSourceType::Reused);
397 assert_eq!(
398 capsule.reused_asset_id,
399 Some("capsule_original_001".to_string())
400 );
401 }
402
403 #[test]
404 fn test_trigger_context() {
405 let ctx = TriggerContext {
406 prompt: Some("Fix the timeout bug".to_string()),
407 reasoning_trace: Some("Analyzed error logs, found timeout in connection".to_string()),
408 context_signals: vec!["signal1".to_string()],
409 session_id: Some("session_123".to_string()),
410 agent_model: Some("claude-sonnet-4".to_string()),
411 };
412
413 let capsule = GepCapsule::new(
414 "capsule_ctx".to_string(),
415 vec!["timeout".to_string()],
416 "gene_001".to_string(),
417 "Fixed with context".to_string(),
418 "diff content that is definitely longer than fifty characters to pass validation requirements".to_string(),
419 0.88,
420 ).unwrap()
421 .with_trigger_context(ctx);
422
423 assert!(capsule.trigger_context.is_some());
424 assert_eq!(
425 capsule.trigger_context.as_ref().unwrap().agent_model,
426 Some("claude-sonnet-4".to_string())
427 );
428 }
429}