Skip to main content

safe_migrate/report/
interactive.rs

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        // Reserve space for wrapped details without scrolling the alternate screen.
74        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            // Bound detail height so navigation remains visible.
125            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                // Raw terminal output uses CRLF line endings.
138                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}