1use 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#[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 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 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 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 let query = self.build_query();
305 self.pending_query = Some(query);
306 true
307 }
308 KeyCode::Char('c') => {
309 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), Constraint::Min(0), ])
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 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 }
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 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 }
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 s.handle_key(key(KeyCode::Tab));
461 assert_eq!(s.focused, Field::Path);
462
463 s.handle_key(key(KeyCode::Enter));
465 assert!(s.editing);
466
467 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 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)); 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)); assert!(!s.editing);
489 assert_eq!(s.method, "GET"); }
491
492 #[test]
493 fn method_uppercased_on_commit() {
494 let mut s = VerificationScreen::new();
495 s.handle_key(key(KeyCode::Enter));
496 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 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"); }
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}