Skip to main content

mockforge_tui/screens/
verification.rs

1//! Verification screen — interactive query interface for verifying recorded requests.
2
3use crossterm::event::{KeyCode, KeyEvent};
4use ratatui::{
5    layout::{Constraint, Layout, Rect},
6    style::Style,
7    text::{Line, Span},
8    widgets::{Block, Borders, Paragraph},
9    Frame,
10};
11use tokio::sync::mpsc;
12
13use crate::api::client::MockForgeClient;
14use crate::api::models::VerificationResult;
15use crate::event::Event;
16use crate::screens::Screen;
17use crate::theme::Theme;
18
19/// Which input field is focused.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21enum Field {
22    Method,
23    Path,
24    MinCount,
25}
26
27impl Field {
28    fn next(self) -> Self {
29        match self {
30            Self::Method => Self::Path,
31            Self::Path => Self::MinCount,
32            Self::MinCount => Self::Method,
33        }
34    }
35
36    fn prev(self) -> Self {
37        match self {
38            Self::Method => Self::MinCount,
39            Self::Path => Self::Method,
40            Self::MinCount => Self::Path,
41        }
42    }
43}
44
45pub struct VerificationScreen {
46    method: String,
47    path: String,
48    min_count: String,
49    focused: Field,
50    editing: bool,
51    input_buf: String,
52    input_cursor: usize,
53    last_result: Option<VerificationResult>,
54    pending_query: Option<serde_json::Value>,
55    error: Option<String>,
56    status_message: Option<(bool, String)>,
57}
58
59impl VerificationScreen {
60    pub fn new() -> Self {
61        Self {
62            method: "GET".into(),
63            path: String::new(),
64            min_count: "1".into(),
65            focused: Field::Method,
66            editing: false,
67            input_buf: String::new(),
68            input_cursor: 0,
69            last_result: None,
70            pending_query: None,
71            error: None,
72            status_message: None,
73        }
74    }
75
76    fn start_edit(&mut self) {
77        self.editing = true;
78        self.input_buf = match self.focused {
79            Field::Method => self.method.clone(),
80            Field::Path => self.path.clone(),
81            Field::MinCount => self.min_count.clone(),
82        };
83        self.input_cursor = self.input_buf.len();
84    }
85
86    fn commit_edit(&mut self) {
87        match self.focused {
88            Field::Method => self.method = self.input_buf.to_uppercase(),
89            Field::Path => self.path = self.input_buf.clone(),
90            Field::MinCount => {
91                // Validate: must be a non-negative integer.
92                if self.input_buf.parse::<u64>().is_ok() {
93                    self.min_count = self.input_buf.clone();
94                }
95            }
96        }
97        self.editing = false;
98        self.input_buf.clear();
99    }
100
101    fn cancel_edit(&mut self) {
102        self.editing = false;
103        self.input_buf.clear();
104    }
105
106    fn render_form(&self, frame: &mut Frame, area: Rect) {
107        let form_block = Block::default()
108            .title(" Verification Query ")
109            .title_style(Theme::title())
110            .borders(Borders::ALL)
111            .border_style(Theme::dim())
112            .style(Theme::surface());
113
114        let fields = [
115            ("Method", &self.method, Field::Method),
116            ("Path", &self.path, Field::Path),
117            ("Min Count", &self.min_count, Field::MinCount),
118        ];
119
120        let mut lines = vec![Line::from("")];
121        for (label, value, field) in &fields {
122            let is_focused = *field == self.focused;
123            let indicator = if is_focused { "▸ " } else { "  " };
124            let label_style = if is_focused {
125                Theme::title()
126            } else {
127                Theme::dim()
128            };
129
130            if self.editing && is_focused {
131                let before = &self.input_buf[..self.input_cursor];
132                let after = &self.input_buf[self.input_cursor..];
133                lines.push(Line::from(vec![
134                    Span::styled(format!("{indicator}{label:<10} "), label_style),
135                    Span::styled(before.to_string(), Style::default().fg(Theme::FG)),
136                    Span::styled("▏", Style::default().fg(Theme::BLUE)),
137                    Span::styled(after.to_string(), Style::default().fg(Theme::FG)),
138                ]));
139            } else {
140                let display = if value.is_empty() {
141                    "(empty)".to_string()
142                } else {
143                    (*value).clone()
144                };
145                lines.push(Line::from(vec![
146                    Span::styled(format!("{indicator}{label:<10} "), label_style),
147                    Span::styled(display, Style::default().fg(Theme::FG)),
148                ]));
149            }
150        }
151
152        lines.push(Line::from(""));
153        lines.push(Line::from(vec![
154            Span::styled("  ", Theme::dim()),
155            Span::styled("[v]", Style::default().fg(Theme::BLUE)),
156            Span::styled(" Submit query    ", Theme::dim()),
157            Span::styled("[c]", Style::default().fg(Theme::BLUE)),
158            Span::styled(" Clear results", Theme::dim()),
159        ]));
160
161        let form = Paragraph::new(lines).block(form_block);
162        frame.render_widget(form, area);
163    }
164
165    fn render_results(&self, frame: &mut Frame, area: Rect) {
166        let result_block = Block::default()
167            .title(" Results ")
168            .title_style(Theme::title())
169            .borders(Borders::ALL)
170            .border_style(Theme::dim())
171            .style(Theme::surface());
172
173        let mut result_lines = vec![Line::from("")];
174
175        if let Some(ref result) = self.last_result {
176            let match_style = if result.matched {
177                Theme::success()
178            } else {
179                Theme::error()
180            };
181            let match_text = if result.matched {
182                "MATCHED"
183            } else {
184                "NOT MATCHED"
185            };
186
187            result_lines.push(Line::from(vec![
188                Span::styled("  Status: ", Theme::dim()),
189                Span::styled(match_text, match_style),
190            ]));
191            result_lines.push(Line::from(vec![
192                Span::styled("  Count:  ", Theme::dim()),
193                Span::styled(result.count.to_string(), Style::default().fg(Theme::FG)),
194            ]));
195
196            if !result.details.is_null() {
197                result_lines.push(Line::from(""));
198                result_lines.push(Line::from(Span::styled("  Details:", Theme::dim())));
199                let formatted = serde_json::to_string_pretty(&result.details)
200                    .unwrap_or_else(|_| result.details.to_string());
201                for detail_line in formatted.lines().take(20) {
202                    result_lines.push(Line::from(Span::styled(
203                        format!("    {detail_line}"),
204                        Style::default().fg(Theme::FG),
205                    )));
206                }
207            }
208        } else if let Some((success, ref msg)) = self.status_message {
209            let style = if success {
210                Theme::success()
211            } else {
212                Theme::error()
213            };
214            result_lines.push(Line::from(vec![
215                Span::styled("  ", Theme::dim()),
216                Span::styled(msg.as_str(), style),
217            ]));
218        } else {
219            result_lines.push(Line::from(Span::styled(
220                "  Submit a query with 'v' to see results here.",
221                Theme::dim(),
222            )));
223        }
224
225        let results = Paragraph::new(result_lines).block(result_block);
226        frame.render_widget(results, area);
227    }
228
229    fn build_query(&self) -> serde_json::Value {
230        let mut query = serde_json::json!({
231            "method": self.method,
232        });
233        if !self.path.is_empty() {
234            query["path"] = serde_json::Value::String(self.path.clone());
235        }
236        if let Ok(n) = self.min_count.parse::<u64>() {
237            if n > 0 {
238                query["min_count"] = serde_json::json!(n);
239            }
240        }
241        query
242    }
243}
244
245impl Screen for VerificationScreen {
246    fn title(&self) -> &str {
247        "Verification"
248    }
249
250    fn handle_key(&mut self, key: KeyEvent) -> bool {
251        // Inline editing mode.
252        if self.editing {
253            match key.code {
254                KeyCode::Enter => {
255                    self.commit_edit();
256                    return true;
257                }
258                KeyCode::Esc => {
259                    self.cancel_edit();
260                    return true;
261                }
262                KeyCode::Backspace => {
263                    if self.input_cursor > 0 {
264                        self.input_cursor -= 1;
265                        self.input_buf.remove(self.input_cursor);
266                    }
267                    return true;
268                }
269                KeyCode::Left => {
270                    self.input_cursor = self.input_cursor.saturating_sub(1);
271                    return true;
272                }
273                KeyCode::Right => {
274                    if self.input_cursor < self.input_buf.len() {
275                        self.input_cursor += 1;
276                    }
277                    return true;
278                }
279                KeyCode::Char(c) => {
280                    self.input_buf.insert(self.input_cursor, c);
281                    self.input_cursor += 1;
282                    return true;
283                }
284                _ => return true,
285            }
286        }
287
288        // Normal mode.
289        match key.code {
290            KeyCode::Tab | KeyCode::Char('j') | KeyCode::Down => {
291                self.focused = self.focused.next();
292                true
293            }
294            KeyCode::BackTab | KeyCode::Char('k') | KeyCode::Up => {
295                self.focused = self.focused.prev();
296                true
297            }
298            KeyCode::Enter | KeyCode::Char('e') => {
299                self.start_edit();
300                true
301            }
302            KeyCode::Char('v') => {
303                // Submit the verification query.
304                let query = self.build_query();
305                self.pending_query = Some(query);
306                true
307            }
308            KeyCode::Char('c') => {
309                // Clear results.
310                self.last_result = None;
311                self.status_message = None;
312                true
313            }
314            _ => false,
315        }
316    }
317
318    fn render(&self, frame: &mut Frame, area: Rect) {
319        let chunks = Layout::vertical([
320            Constraint::Length(10), // Query form
321            Constraint::Min(0),     // Results
322        ])
323        .split(area);
324
325        self.render_form(frame, chunks[0]);
326        self.render_results(frame, chunks[1]);
327    }
328
329    fn tick(&mut self, client: &MockForgeClient, tx: &mpsc::UnboundedSender<Event>) {
330        // Handle pending query.
331        if let Some(query) = self.pending_query.take() {
332            let client = client.clone();
333            let tx = tx.clone();
334            tokio::spawn(async move {
335                let result = match client.verify(&query).await {
336                    Ok(vr) => serde_json::json!({
337                        "type": "verification_result",
338                        "matched": vr.matched,
339                        "count": vr.count,
340                        "details": vr.details,
341                    }),
342                    Err(e) => serde_json::json!({
343                        "type": "verification_error",
344                        "message": e.to_string(),
345                    }),
346                };
347                let _ = tx.send(Event::Data {
348                    screen: "verification",
349                    payload: serde_json::to_string(&result).unwrap_or_default(),
350                });
351            });
352        }
353        // On-demand only — no periodic fetch.
354    }
355
356    fn on_data(&mut self, payload: &str) {
357        if let Ok(val) = serde_json::from_str::<serde_json::Value>(payload) {
358            match val.get("type").and_then(|v| v.as_str()) {
359                Some("verification_result") => {
360                    self.last_result = Some(VerificationResult {
361                        matched: val.get("matched").and_then(|v| v.as_bool()).unwrap_or(false),
362                        count: val.get("count").and_then(|v| v.as_u64()).unwrap_or(0),
363                        details: val.get("details").cloned().unwrap_or(serde_json::Value::Null),
364                    });
365                    self.status_message = None;
366                    self.error = None;
367                }
368                Some("verification_error") => {
369                    let message = val
370                        .get("message")
371                        .and_then(|v| v.as_str())
372                        .unwrap_or("Unknown error")
373                        .to_string();
374                    self.status_message = Some((false, message));
375                    self.last_result = None;
376                }
377                _ => {
378                    // Generic data (unlikely for verification).
379                    self.error = None;
380                }
381            }
382        }
383    }
384
385    fn on_error(&mut self, message: &str) {
386        self.error = Some(message.to_string());
387    }
388
389    fn error(&self) -> Option<&str> {
390        self.error.as_deref()
391    }
392
393    fn force_refresh(&mut self) {
394        // No-op: on-demand only.
395    }
396
397    fn status_hint(&self) -> &str {
398        "Tab/j/k:fields  Enter/e:edit  v:verify  c:clear"
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use crossterm::event::{KeyEventKind, KeyEventState, KeyModifiers};
406
407    fn key(code: KeyCode) -> KeyEvent {
408        KeyEvent {
409            code,
410            modifiers: KeyModifiers::NONE,
411            kind: KeyEventKind::Press,
412            state: KeyEventState::NONE,
413        }
414    }
415
416    #[test]
417    fn new_creates_default_screen() {
418        let s = VerificationScreen::new();
419        assert_eq!(s.method, "GET");
420        assert!(s.path.is_empty());
421        assert_eq!(s.min_count, "1");
422        assert_eq!(s.focused, Field::Method);
423        assert!(!s.editing);
424        assert!(s.last_result.is_none());
425    }
426
427    #[test]
428    fn tab_cycles_fields_forward() {
429        let mut s = VerificationScreen::new();
430        assert_eq!(s.focused, Field::Method);
431        s.handle_key(key(KeyCode::Tab));
432        assert_eq!(s.focused, Field::Path);
433        s.handle_key(key(KeyCode::Tab));
434        assert_eq!(s.focused, Field::MinCount);
435        s.handle_key(key(KeyCode::Tab));
436        assert_eq!(s.focused, Field::Method);
437    }
438
439    #[test]
440    fn j_k_navigate_fields() {
441        let mut s = VerificationScreen::new();
442        s.handle_key(key(KeyCode::Char('j')));
443        assert_eq!(s.focused, Field::Path);
444        s.handle_key(key(KeyCode::Char('k')));
445        assert_eq!(s.focused, Field::Method);
446    }
447
448    #[test]
449    fn enter_starts_edit_mode() {
450        let mut s = VerificationScreen::new();
451        s.handle_key(key(KeyCode::Enter));
452        assert!(s.editing);
453        assert_eq!(s.input_buf, "GET");
454    }
455
456    #[test]
457    fn edit_and_commit() {
458        let mut s = VerificationScreen::new();
459        // Focus path field.
460        s.handle_key(key(KeyCode::Tab));
461        assert_eq!(s.focused, Field::Path);
462
463        // Start editing.
464        s.handle_key(key(KeyCode::Enter));
465        assert!(s.editing);
466
467        // Type a path.
468        s.handle_key(key(KeyCode::Char('/')));
469        s.handle_key(key(KeyCode::Char('a')));
470        s.handle_key(key(KeyCode::Char('p')));
471        s.handle_key(key(KeyCode::Char('i')));
472
473        // Commit.
474        s.handle_key(key(KeyCode::Enter));
475        assert!(!s.editing);
476        assert_eq!(s.path, "/api");
477    }
478
479    #[test]
480    fn edit_and_cancel() {
481        let mut s = VerificationScreen::new();
482        s.handle_key(key(KeyCode::Enter)); // Edit method
483        s.handle_key(key(KeyCode::Backspace));
484        s.handle_key(key(KeyCode::Backspace));
485        s.handle_key(key(KeyCode::Backspace));
486        s.handle_key(key(KeyCode::Char('P')));
487        s.handle_key(key(KeyCode::Esc)); // Cancel
488        assert!(!s.editing);
489        assert_eq!(s.method, "GET"); // Unchanged
490    }
491
492    #[test]
493    fn method_uppercased_on_commit() {
494        let mut s = VerificationScreen::new();
495        s.handle_key(key(KeyCode::Enter));
496        // Clear existing.
497        s.input_buf.clear();
498        s.input_cursor = 0;
499        s.handle_key(key(KeyCode::Char('p')));
500        s.handle_key(key(KeyCode::Char('o')));
501        s.handle_key(key(KeyCode::Char('s')));
502        s.handle_key(key(KeyCode::Char('t')));
503        s.handle_key(key(KeyCode::Enter));
504        assert_eq!(s.method, "POST");
505    }
506
507    #[test]
508    fn invalid_min_count_rejected() {
509        let mut s = VerificationScreen::new();
510        // Focus min_count.
511        s.handle_key(key(KeyCode::Tab));
512        s.handle_key(key(KeyCode::Tab));
513        assert_eq!(s.focused, Field::MinCount);
514
515        s.handle_key(key(KeyCode::Enter));
516        s.input_buf = "abc".into();
517        s.input_cursor = 3;
518        s.handle_key(key(KeyCode::Enter));
519        assert_eq!(s.min_count, "1"); // Unchanged
520    }
521
522    #[test]
523    fn v_key_sets_pending_query() {
524        let mut s = VerificationScreen::new();
525        s.handle_key(key(KeyCode::Char('v')));
526        assert!(s.pending_query.is_some());
527        let q = s.pending_query.as_ref().unwrap();
528        assert_eq!(q["method"], "GET");
529    }
530
531    #[test]
532    fn build_query_includes_path_when_set() {
533        let mut s = VerificationScreen::new();
534        s.path = "/api/users".into();
535        let q = s.build_query();
536        assert_eq!(q["path"], "/api/users");
537    }
538
539    #[test]
540    fn build_query_omits_empty_path() {
541        let s = VerificationScreen::new();
542        let q = s.build_query();
543        assert!(q.get("path").is_none());
544    }
545
546    #[test]
547    fn on_data_parses_verification_result() {
548        let mut s = VerificationScreen::new();
549        let result = serde_json::json!({
550            "type": "verification_result",
551            "matched": true,
552            "count": 5,
553            "details": {"methods": ["GET"]},
554        });
555        s.on_data(&serde_json::to_string(&result).unwrap());
556        assert!(s.last_result.is_some());
557        let r = s.last_result.as_ref().unwrap();
558        assert!(r.matched);
559        assert_eq!(r.count, 5);
560    }
561
562    #[test]
563    fn on_data_parses_verification_error() {
564        let mut s = VerificationScreen::new();
565        let result = serde_json::json!({
566            "type": "verification_error",
567            "message": "No recordings found",
568        });
569        s.on_data(&serde_json::to_string(&result).unwrap());
570        assert!(s.last_result.is_none());
571        assert!(s.status_message.is_some());
572        let (success, msg) = s.status_message.as_ref().unwrap();
573        assert!(!success);
574        assert_eq!(msg, "No recordings found");
575    }
576
577    #[test]
578    fn c_key_clears_results() {
579        let mut s = VerificationScreen::new();
580        s.last_result = Some(VerificationResult {
581            matched: true,
582            count: 3,
583            details: serde_json::Value::Null,
584        });
585        s.handle_key(key(KeyCode::Char('c')));
586        assert!(s.last_result.is_none());
587    }
588
589    #[test]
590    fn status_hint_shows_verify() {
591        let s = VerificationScreen::new();
592        assert!(s.status_hint().contains("verify"));
593    }
594}