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