1use crate::analysis::state::Confidence;
2use crate::report::violations::Violation;
3use anyhow::Result;
4use crossterm::{
5 cursor,
6 event::{self, Event, KeyCode, KeyEventKind},
7 execute, queue,
8 style::{Color, Print, ResetColor, SetForegroundColor},
9 terminal::{
10 Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
11 enable_raw_mode,
12 },
13};
14use std::io::{IsTerminal, Write, stdin, stdout};
15use terminal_size::{Height, Width, terminal_size};
16
17struct TerminalGuard;
18
19impl Drop for TerminalGuard {
20 fn drop(&mut self) {
21 let _ = disable_raw_mode();
22 let mut stdout = stdout();
23 let _ = execute!(
24 stdout,
25 cursor::Show,
26 Clear(ClearType::All),
27 LeaveAlternateScreen,
28 cursor::MoveTo(0, 0)
29 );
30 }
31}
32
33pub fn run_interactive(violations: &[Violation], confidence: &Confidence) -> Result<()> {
34 if violations.is_empty() {
35 println!("No violations found!");
36 return Ok(());
37 }
38
39 if !stdin().is_terminal() || !stdout().is_terminal() {
40 anyhow::bail!(
41 "Interactive mode requires a terminal connected to standard input and output"
42 );
43 }
44
45 enable_raw_mode()?;
46 let _guard = TerminalGuard;
47
48 let mut stdout = stdout();
49 execute!(
50 stdout,
51 EnterAlternateScreen,
52 cursor::Hide,
53 Clear(ClearType::All),
54 cursor::MoveTo(0, 0)
55 )?;
56
57 let mut selected: usize = 0;
58
59 loop {
60 queue!(stdout, Clear(ClearType::All), cursor::MoveTo(0, 0))?;
61 queue!(
62 stdout,
63 SetForegroundColor(Color::Cyan),
64 Print(format!(
65 "Safe-Migrate Interactive Viewer ({} violations) [Confidence: {:?}]\r\n\r\n",
66 violations.len(),
67 confidence
68 )),
69 ResetColor,
70 )?;
71
72 let (_w, Height(h)) = terminal_size().unwrap_or((Width(80), Height(24)));
73 let window_size = (h.saturating_sub(18) as usize).max(3);
75
76 let start = selected.saturating_sub(window_size / 2);
77 let end = std::cmp::min(start + window_size, violations.len());
78
79 if start > 0 {
80 queue!(stdout, Print(" ...\r\n"))?;
81 }
82
83 for (i, v) in violations.iter().enumerate().take(end).skip(start) {
84 let prefix = if i == selected { " > " } else { " " };
85 let color = match v.tier {
86 crate::report::violations::ViolationTier::Tier1 => Color::Red,
87 crate::report::violations::ViolationTier::Tier2 => Color::Yellow,
88 crate::report::violations::ViolationTier::Tier3 => Color::Green,
89 };
90
91 queue!(
92 stdout,
93 Print(prefix),
94 SetForegroundColor(color),
95 Print(format!("[{:?}] ", v.tier)),
96 ResetColor,
97 Print(format!("{} (rule: {})\r\n", v.operation_kind, v.rule_id))
98 )?;
99 }
100
101 if end < violations.len() {
102 queue!(stdout, Print(" ...\r\n"))?;
103 }
104
105 queue!(
106 stdout,
107 Print("\r\n------------------------------------------------------------\r\n")
108 )?;
109
110 let active = &violations[selected];
111 queue!(
112 stdout,
113 SetForegroundColor(Color::White),
114 Print("Reason: "),
115 ResetColor,
116 Print(format!("{}\r\n", active.reason)),
117 SetForegroundColor(Color::White),
118 Print("Recipe: "),
119 ResetColor,
120 Print(format!("{}\r\n", active.recipe)),
121 )?;
122
123 if let Some(sql) = &active.sql {
124 let mut sql_lines: Vec<&str> = sql.lines().collect();
126 let mut truncated = false;
127 if sql_lines.len() > 5 {
128 sql_lines.truncate(5);
129 truncated = true;
130 }
131
132 queue!(
133 stdout,
134 SetForegroundColor(Color::White),
135 Print("\r\nSQL Context:\r\n"),
136 SetForegroundColor(Color::DarkGrey),
137 Print(format!("{}\r\n", sql_lines.join("\r\n"))),
139 )?;
140
141 if truncated {
142 queue!(stdout, Print("... (truncated)\r\n"))?;
143 }
144 queue!(stdout, ResetColor)?;
145 }
146
147 queue!(stdout, Print("\r\n[Up/Down] Navigate | [q/Esc] Quit\r\n"))?;
148
149 stdout.flush()?;
150
151 if let Event::Key(key) = event::read()?
152 && key.kind == KeyEventKind::Press
153 {
154 match key.code {
155 KeyCode::Char('q') | KeyCode::Esc => break,
156 KeyCode::Up if selected > 0 => selected -= 1,
157 KeyCode::Down if selected < violations.len() - 1 => selected += 1,
158 _ => {}
159 }
160 }
161 }
162
163 drop(_guard);
164 println!("Exited interactive mode.");
165
166 Ok(())
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
173
174 #[test]
175 fn interactive_mode_explains_when_no_terminal_is_available() {
176 let violation = Violation {
177 source_range: None,
178 rule_id: "test-rule",
179 operation_kind: OperationKind::Other("test".to_string()),
180 object_kind: ObjectKind::Unknown,
181 object_name: "test".to_string(),
182 tier: ViolationTier::Tier3,
183 reason: "test".to_string(),
184 recipe: "test",
185 dedup_key: None,
186 sql: None,
187 fk_dependency_related: false,
188 };
189
190 let error = run_interactive(&[violation], &Confidence::Exact).unwrap_err();
191 assert!(
192 error
193 .to_string()
194 .contains("requires a terminal connected to standard input and output")
195 );
196 }
197}