atman_runtime/
injection.rs1use serde::{Deserialize, Serialize};
2
3use crate::event::TurnId;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
6#[serde(transparent)]
7pub struct InjectionId(pub uuid::Uuid);
8
9impl InjectionId {
10 pub fn now() -> Self {
11 Self(uuid::Uuid::now_v7())
12 }
13}
14
15impl std::fmt::Display for InjectionId {
16 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17 self.0.fmt(f)
18 }
19}
20
21#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(rename_all = "snake_case")]
23pub enum InjectionState {
24 Pending,
25 Injected,
26 Cancelled,
27}
28
29#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(rename_all = "snake_case")]
31pub enum InjectionLevel {
32 L1Nudge,
33 L2CourseCorrect,
34 L3Redirect,
35 L4HardStop,
36}
37
38impl InjectionLevel {
39 pub fn as_str(&self) -> &'static str {
40 match self {
41 InjectionLevel::L1Nudge => "l1_nudge",
42 InjectionLevel::L2CourseCorrect => "l2_course_correct",
43 InjectionLevel::L3Redirect => "l3_redirect",
44 InjectionLevel::L4HardStop => "l4_hard_stop",
45 }
46 }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub struct Injection {
51 pub id: InjectionId,
52 pub text: String,
53 pub turn_id: TurnId,
54 pub created_at: chrono::DateTime<chrono::Utc>,
55 pub state: InjectionState,
56 #[serde(default = "default_level")]
57 pub level: InjectionLevel,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub redirect_target: Option<String>,
60}
61
62fn default_level() -> InjectionLevel {
63 InjectionLevel::L1Nudge
64}
65
66impl Injection {
67 pub fn new_pending(turn_id: TurnId, text: impl Into<String>) -> Self {
68 Self::with_level(turn_id, text, InjectionLevel::L1Nudge, None)
69 }
70
71 pub fn with_level(
72 turn_id: TurnId,
73 text: impl Into<String>,
74 level: InjectionLevel,
75 redirect_target: Option<String>,
76 ) -> Self {
77 Self {
78 id: InjectionId::now(),
79 text: text.into(),
80 turn_id,
81 created_at: chrono::Utc::now(),
82 state: InjectionState::Pending,
83 level,
84 redirect_target,
85 }
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn injection_roundtrips_via_serde_json() {
95 let inj = Injection::new_pending(TurnId::now(), "remember to check tests");
96 let s = serde_json::to_string(&inj).unwrap();
97 let back: Injection = serde_json::from_str(&s).unwrap();
98 assert_eq!(inj, back);
99 }
100
101 #[test]
102 fn injection_ids_are_unique() {
103 let mut seen = std::collections::HashSet::new();
104 for _ in 0..1000 {
105 let id = InjectionId::now();
106 assert!(seen.insert(id));
107 }
108 }
109
110 #[test]
111 fn state_serializes_snake_case() {
112 assert_eq!(
113 serde_json::to_string(&InjectionState::Pending).unwrap(),
114 "\"pending\""
115 );
116 assert_eq!(
117 serde_json::to_string(&InjectionState::Injected).unwrap(),
118 "\"injected\""
119 );
120 assert_eq!(
121 serde_json::to_string(&InjectionState::Cancelled).unwrap(),
122 "\"cancelled\""
123 );
124 }
125
126 #[test]
127 fn new_pending_starts_in_pending_state() {
128 let inj = Injection::new_pending(TurnId::now(), "x");
129 assert_eq!(inj.state, InjectionState::Pending);
130 }
131}