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}