1use super::companion::CompanionEvent;
8use crate::approval::GrantChoice;
9use crate::approval::ipc::ApprovalRequest;
10use std::collections::VecDeque;
11use std::path::PathBuf;
12use std::time::Instant;
13
14const HISTORY_CAP: usize = 100;
16
17#[derive(Debug, Clone, PartialEq)]
18pub enum Status {
19 Connecting,
20 Waiting,
21 Disconnected(String),
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub struct PendingView {
26 pub request: ApprovalRequest,
27 pub arrived: Instant,
28 pub answering: Option<GrantChoice>,
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub struct HistoryEntry {
34 pub ts: String,
35 pub connection: String,
36 pub choice: String,
37 pub result: String,
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub struct ViewState {
42 pub socket: PathBuf,
43 pub status: Status,
44 pub pending: Option<PendingView>,
45 pub history: VecDeque<HistoryEntry>,
46 pub answered_total: u32,
47}
48
49impl ViewState {
50 pub fn new(socket: PathBuf) -> Self {
51 Self {
52 socket,
53 status: Status::Connecting,
54 pending: None,
55 history: VecDeque::new(),
56 answered_total: 0,
57 }
58 }
59}
60
61fn utc_hms() -> String {
62 time::OffsetDateTime::now_utc()
63 .format(&time::macros::format_description!(
64 "[hour]:[minute]:[second]"
65 ))
66 .unwrap_or_else(|_| "??:??:??".into())
67}
68
69fn choice_str(choice: GrantChoice) -> &'static str {
70 match choice {
71 GrantChoice::Once => "once",
72 GrantChoice::Session => "session",
73 GrantChoice::Decline => "decline",
74 }
75}
76
77pub fn apply_event(state: &mut ViewState, event: CompanionEvent) {
79 match event {
80 CompanionEvent::Connected { .. } | CompanionEvent::Waiting => {
81 state.status = Status::Waiting;
82 }
83 CompanionEvent::Empty => {
84 }
87 CompanionEvent::Request { request } => {
88 state.status = Status::Waiting;
89 state.pending = Some(PendingView {
90 request,
91 arrived: Instant::now(),
92 answering: None,
93 });
94 }
95 CompanionEvent::Acked { id, choice } => {
96 state.answered_total += 1;
97 let connection = state
98 .pending
99 .as_ref()
100 .filter(|p| p.request.id == id)
101 .map(|p| p.request.connection.clone())
102 .unwrap_or_default();
103 state.history.push_front(HistoryEntry {
104 ts: utc_hms(),
105 connection,
106 choice: choice_str(choice).into(),
107 result: "ok".into(),
108 });
109 state.pending = None;
110 truncate_history(state);
111 }
112 CompanionEvent::Stale { id } => {
113 let connection = state
114 .pending
115 .as_ref()
116 .filter(|p| p.request.id == id)
117 .map(|p| p.request.connection.clone())
118 .unwrap_or_default();
119 state.history.push_front(HistoryEntry {
120 ts: utc_hms(),
121 connection,
122 choice: "—".into(),
123 result: "stale (expired or answered elsewhere)".into(),
124 });
125 state.pending = None;
126 truncate_history(state);
127 }
128 CompanionEvent::BadChoice { id } => {
129 let connection = state
130 .pending
131 .as_ref()
132 .filter(|p| p.request.id == id)
133 .map(|p| p.request.connection.clone())
134 .unwrap_or_default();
135 state.history.push_front(HistoryEntry {
136 ts: utc_hms(),
137 connection,
138 choice: "—".into(),
139 result: "bad-choice (protocol misuse)".into(),
140 });
141 state.pending = None;
142 truncate_history(state);
143 }
144 CompanionEvent::Disconnected { reason } => {
145 if let Some(p) = state.pending.take() {
148 state.history.push_front(HistoryEntry {
149 ts: utc_hms(),
150 connection: p.request.connection.clone(),
151 choice: "—".into(),
152 result: format!("connection lost ({reason})"),
153 });
154 truncate_history(state);
155 }
156 state.status = Status::Disconnected(reason);
157 }
158 }
159}
160
161pub fn apply_not_delivered(state: &mut ViewState, choice: GrantChoice) {
164 if let Some(p) = state.pending.take() {
165 state.history.push_front(HistoryEntry {
166 ts: utc_hms(),
167 connection: p.request.connection.clone(),
168 choice: choice_str(choice).into(),
169 result: "not delivered".into(),
170 });
171 truncate_history(state);
172 }
173}
174
175fn truncate_history(state: &mut ViewState) {
176 while state.history.len() > HISTORY_CAP {
177 state.history.pop_back();
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 fn request(id: &str) -> ApprovalRequest {
186 ApprovalRequest {
187 id: id.into(),
188 category: "ddl".into(),
189 connection: "conn-a".into(),
190 database: None,
191 tables: vec![],
192 snippet: "DROP TABLE x".into(),
193 }
194 }
195
196 fn view() -> ViewState {
197 ViewState::new(PathBuf::from("/tmp/approval.sock"))
198 }
199
200 fn connected() -> CompanionEvent {
201 CompanionEvent::Connected {
202 socket: PathBuf::from("/tmp/approval.sock"),
203 }
204 }
205
206 #[test]
207 fn request_sets_pending_then_ack_clears_and_counts() {
208 let mut v = view();
209 apply_event(&mut v, connected());
210 apply_event(&mut v, CompanionEvent::Waiting);
211 assert_eq!(v.status, Status::Waiting);
212 apply_event(
213 &mut v,
214 CompanionEvent::Request {
215 request: request("r1"),
216 },
217 );
218 let pending = v.pending.as_ref().unwrap();
219 assert_eq!(pending.request.id, "r1");
220 assert!(pending.answering.is_none());
221 apply_event(
222 &mut v,
223 CompanionEvent::Acked {
224 id: "r1".into(),
225 choice: GrantChoice::Once,
226 },
227 );
228 assert!(v.pending.is_none());
229 assert_eq!(v.answered_total, 1);
230 assert_eq!(v.history[0].choice, "once");
231 assert_eq!(v.history[0].connection, "conn-a");
232 assert_eq!(v.history[0].result, "ok");
233 }
234
235 #[test]
236 fn stale_and_bad_choice_clear_pending_without_counting() {
237 let mut v = view();
238 apply_event(
239 &mut v,
240 CompanionEvent::Request {
241 request: request("r2"),
242 },
243 );
244 apply_event(&mut v, CompanionEvent::Stale { id: "r2".into() });
245 assert!(v.pending.is_none());
246 assert_eq!(v.answered_total, 0);
247 assert!(v.history[0].result.starts_with("stale"));
248
249 apply_event(
250 &mut v,
251 CompanionEvent::Request {
252 request: request("r3"),
253 },
254 );
255 apply_event(&mut v, CompanionEvent::BadChoice { id: "r3".into() });
256 assert!(v.pending.is_none());
257 assert_eq!(v.answered_total, 0);
258 assert!(v.history[0].result.starts_with("bad-choice"));
259 }
260
261 #[test]
262 fn disconnect_records_lost_request_and_status() {
263 let mut v = view();
264 apply_event(
265 &mut v,
266 CompanionEvent::Request {
267 request: request("r4"),
268 },
269 );
270 apply_event(
271 &mut v,
272 CompanionEvent::Disconnected {
273 reason: "connect: gone".into(),
274 },
275 );
276 assert!(v.pending.is_none());
277 assert_eq!(v.status, Status::Disconnected("connect: gone".into()));
278 assert!(v.history[0].result.starts_with("connection lost"));
279
280 let before = v.history.len();
282 apply_event(
283 &mut v,
284 CompanionEvent::Disconnected {
285 reason: "again".into(),
286 },
287 );
288 assert_eq!(v.history.len(), before);
289 }
290
291 #[test]
292 fn ack_with_wrong_id_does_not_steal_a_different_connection_label() {
293 let mut v = view();
294 apply_event(
295 &mut v,
296 CompanionEvent::Request {
297 request: request("real"),
298 },
299 );
300 apply_event(
301 &mut v,
302 CompanionEvent::Acked {
303 id: "other".into(),
304 choice: GrantChoice::Session,
305 },
306 );
307 assert!(v.pending.is_none());
310 assert_eq!(v.history[0].connection, "");
311 assert_eq!(v.history[0].choice, "session");
312 }
313
314 #[test]
315 fn history_is_bounded() {
316 let mut v = view();
317 for i in 0..(HISTORY_CAP as u32 + 25) {
318 apply_event(
319 &mut v,
320 CompanionEvent::Acked {
321 id: format!("r{i}"),
322 choice: GrantChoice::Decline,
323 },
324 );
325 }
326 assert_eq!(v.history.len(), HISTORY_CAP);
327 assert_eq!(v.answered_total, HISTORY_CAP as u32 + 25);
328 }
329
330 #[test]
331 fn not_delivered_records_fail_closed() {
332 let mut v = view();
333 apply_event(
334 &mut v,
335 CompanionEvent::Request {
336 request: request("r9"),
337 },
338 );
339 apply_not_delivered(&mut v, GrantChoice::Once);
340 assert!(v.pending.is_none());
341 assert_eq!(v.history[0].choice, "once");
342 assert_eq!(v.history[0].result, "not delivered");
343 assert_eq!(v.answered_total, 0);
344 }
345}