Skip to main content

atman_runtime/
injection.rs

1use 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
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
39#[serde(rename_all = "snake_case")]
40pub enum InjectionSource {
41    #[default]
42    User,
43    Watcher {
44        watcher_id: String,
45        kind: String,
46        handle: String,
47    },
48}
49
50impl InjectionLevel {
51    pub fn as_str(&self) -> &'static str {
52        match self {
53            InjectionLevel::L1Nudge => "l1_nudge",
54            InjectionLevel::L2CourseCorrect => "l2_course_correct",
55            InjectionLevel::L3Redirect => "l3_redirect",
56            InjectionLevel::L4HardStop => "l4_hard_stop",
57        }
58    }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62pub struct Injection {
63    pub id: InjectionId,
64    pub text: String,
65    pub turn_id: TurnId,
66    pub created_at: chrono::DateTime<chrono::Utc>,
67    pub state: InjectionState,
68    #[serde(default = "default_level")]
69    pub level: InjectionLevel,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub redirect_target: Option<String>,
72    #[serde(default)]
73    pub source: InjectionSource,
74}
75
76fn default_level() -> InjectionLevel {
77    InjectionLevel::L1Nudge
78}
79
80impl Injection {
81    pub fn new_pending(turn_id: TurnId, text: impl Into<String>) -> Self {
82        Self::with_level(turn_id, text, InjectionLevel::L1Nudge, None)
83    }
84
85    pub fn with_level(
86        turn_id: TurnId,
87        text: impl Into<String>,
88        level: InjectionLevel,
89        redirect_target: Option<String>,
90    ) -> Self {
91        Self {
92            id: InjectionId::now(),
93            text: text.into(),
94            turn_id,
95            created_at: chrono::Utc::now(),
96            state: InjectionState::Pending,
97            level,
98            redirect_target,
99            source: InjectionSource::User,
100        }
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn injection_roundtrips_via_serde_json() {
110        let inj = Injection::new_pending(TurnId::now(), "remember to check tests");
111        let s = serde_json::to_string(&inj).unwrap();
112        let back: Injection = serde_json::from_str(&s).unwrap();
113        assert_eq!(inj, back);
114    }
115
116    #[test]
117    fn injection_ids_are_unique() {
118        let mut seen = std::collections::HashSet::new();
119        for _ in 0..1000 {
120            let id = InjectionId::now();
121            assert!(seen.insert(id));
122        }
123    }
124
125    #[test]
126    fn state_serializes_snake_case() {
127        assert_eq!(
128            serde_json::to_string(&InjectionState::Pending).unwrap(),
129            "\"pending\""
130        );
131        assert_eq!(
132            serde_json::to_string(&InjectionState::Injected).unwrap(),
133            "\"injected\""
134        );
135        assert_eq!(
136            serde_json::to_string(&InjectionState::Cancelled).unwrap(),
137            "\"cancelled\""
138        );
139    }
140
141    #[test]
142    fn new_pending_starts_in_pending_state() {
143        let inj = Injection::new_pending(TurnId::now(), "x");
144        assert_eq!(inj.state, InjectionState::Pending);
145    }
146
147    #[test]
148    fn old_event_without_source_deserializes_as_user() {
149        let json = serde_json::json!({
150            "id": uuid::Uuid::now_v7(),
151            "text": "old message",
152            "turn_id": uuid::Uuid::now_v7().to_string(),
153            "created_at": "2026-01-01T00:00:00Z",
154            "state": "pending",
155            "level": "l1_nudge"
156        });
157        let inj: Injection = serde_json::from_value(json).unwrap();
158        assert_eq!(inj.source, InjectionSource::User);
159    }
160
161    #[test]
162    fn watcher_source_roundtrips() {
163        let inj = Injection {
164            id: InjectionId::now(),
165            text: "pattern found".into(),
166            turn_id: TurnId::now(),
167            created_at: chrono::Utc::now(),
168            state: InjectionState::Pending,
169            level: crate::injection::InjectionLevel::L1Nudge,
170            redirect_target: None,
171            source: InjectionSource::Watcher {
172                watcher_id: "w_abc".into(),
173                kind: "terminal".into(),
174                handle: "term_x".into(),
175            },
176        };
177        let s = serde_json::to_string(&inj).unwrap();
178        let back: Injection = serde_json::from_str(&s).unwrap();
179        assert_eq!(inj, back);
180    }
181}