Skip to main content

sequel_mcp/gui/
app.rs

1//! The GUI's plain-data view state and its event reducer — deliberately
2//! free of egui types so the mapping from companion events to what the
3//! user sees is unit-testable without a window system. The egui layer
4//! above this (`ApprovalsApp`) only renders `ViewState` and forwards
5//! button clicks as `GrantChoice`s.
6
7use 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
14/// Recent-history bound (most recent first).
15const 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    /// Some(choice) once sent, until the ack (or loss) resolves it.
29    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    /// One-shot windows close on a terminal event; monitors may keep watching.
48    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
80/// Fold one companion event into the view state.
81pub 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            // Window ended with nothing pending; the companion reconnects
92            // on its own in monitor mode. A one-shot window is finished.
93            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            // A request that was still showing can no longer be answered
168            // through the dead connection: record it as lost.
169            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
183/// Record a choice the UI tried to send but could not deliver (the
184/// companion loop is gone): the request stays unanswered, fail-closed.
185pub 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        // A disconnect with nothing pending must not invent history.
304        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        // An acknowledgement for another request must not dismiss this one.
331        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}