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
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}