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}
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
77/// Fold one companion event into the view state.
78pub 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            // Window ended with nothing pending; the companion reconnects
85            // on its own. Status stays Waiting.
86        }
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            // A request that was still showing can no longer be answered
146            // through the dead connection: record it as lost.
147            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
161/// Record a choice the UI tried to send but could not deliver (the
162/// companion loop is gone): the request stays unanswered, fail-closed.
163pub 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        // A disconnect with nothing pending must not invent history.
281        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        // The pending request is still superseded (single slot), but the
308        // history row must not misattribute the connection.
309        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}