safe-migrate 0.9.0

Check PostgreSQL migrations against a synchronized database baseline
Documentation
use crate::_internal::analysis::state::Confidence;
use crate::_internal::report::reporter::{terminal_block, terminal_inline};
use crate::_internal::report::violations::Violation;
use anyhow::Result;
use crossterm::{
    cursor,
    event::{self, Event, KeyCode, KeyEventKind},
    execute, queue,
    style::{Color, Print, ResetColor, SetForegroundColor},
    terminal::{
        Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
        enable_raw_mode,
    },
};
use std::io::{IsTerminal, Write, stdin, stdout};
use terminal_size::{Height, Width, terminal_size};

struct TerminalGuard;

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        let _ = disable_raw_mode();
        let mut stdout = stdout();
        let _ = execute!(
            stdout,
            cursor::Show,
            Clear(ClearType::All),
            LeaveAlternateScreen,
            cursor::MoveTo(0, 0)
        );
    }
}

fn require_interactive_terminal(stdin_is_terminal: bool, stdout_is_terminal: bool) -> Result<()> {
    if !stdin_is_terminal || !stdout_is_terminal {
        anyhow::bail!(
            "Interactive mode requires a terminal connected to standard input and output"
        );
    }
    Ok(())
}

pub(crate) fn run_interactive(violations: &[Violation], confidence: &Confidence) -> Result<()> {
    if violations.is_empty() {
        println!("No violations found!");
        return Ok(());
    }

    require_interactive_terminal(stdin().is_terminal(), stdout().is_terminal())?;

    enable_raw_mode()?;
    let _guard = TerminalGuard;

    let mut stdout = stdout();
    execute!(
        stdout,
        EnterAlternateScreen,
        cursor::Hide,
        Clear(ClearType::All),
        cursor::MoveTo(0, 0)
    )?;

    let mut selected: usize = 0;

    loop {
        queue!(stdout, Clear(ClearType::All), cursor::MoveTo(0, 0))?;
        queue!(
            stdout,
            SetForegroundColor(Color::Cyan),
            Print(format!(
                "Safe-Migrate Interactive Viewer ({} violations) [Confidence: {:?}]\r\n\r\n",
                violations.len(),
                confidence
            )),
            ResetColor,
        )?;

        let (_w, Height(h)) = terminal_size().unwrap_or((Width(80), Height(24)));
        // Reserve space for wrapped details without scrolling the alternate screen.
        let window_size = (h.saturating_sub(18) as usize).max(3);

        let start = selected.saturating_sub(window_size / 2);
        let end = std::cmp::min(start + window_size, violations.len());

        if start > 0 {
            queue!(stdout, Print("   ...\r\n"))?;
        }

        for (i, v) in violations.iter().enumerate().take(end).skip(start) {
            let prefix = if i == selected { " > " } else { "   " };
            let color = match v.tier {
                crate::_internal::report::violations::ViolationTier::Tier1 => Color::Red,
                crate::_internal::report::violations::ViolationTier::Tier2 => Color::Yellow,
                crate::_internal::report::violations::ViolationTier::Tier3 => Color::Green,
            };

            queue!(
                stdout,
                Print(prefix),
                SetForegroundColor(color),
                Print(format!("[{:?}] ", v.tier)),
                ResetColor,
                Print(format!(
                    "{} (rule: {})\r\n",
                    terminal_inline(&v.operation_kind.to_string()),
                    terminal_inline(v.rule_id)
                ))
            )?;
        }

        if end < violations.len() {
            queue!(stdout, Print("   ...\r\n"))?;
        }

        queue!(
            stdout,
            Print("\r\n------------------------------------------------------------\r\n")
        )?;

        let active = &violations[selected];
        queue!(
            stdout,
            SetForegroundColor(Color::White),
            Print("Reason: "),
            ResetColor,
            Print(format!("{}\r\n", terminal_inline(&active.reason))),
            SetForegroundColor(Color::White),
            Print("Recipe: "),
            ResetColor,
            Print(format!("{}\r\n", terminal_inline(active.recipe))),
        )?;

        if let Some(sql) = &active.sql {
            // Bound detail height so navigation remains visible.
            let mut sql_lines: Vec<&str> = sql.lines().collect();
            let mut truncated = false;
            if sql_lines.len() > 5 {
                sql_lines.truncate(5);
                truncated = true;
            }

            queue!(
                stdout,
                SetForegroundColor(Color::White),
                Print("\r\nSQL Context:\r\n"),
                SetForegroundColor(Color::DarkGrey),
                // Raw terminal output uses CRLF line endings.
                Print(format!(
                    "{}\r\n",
                    terminal_block(&sql_lines.join("\n")).replace('\n', "\r\n")
                )),
            )?;

            if truncated {
                queue!(stdout, Print("... (truncated)\r\n"))?;
            }
            queue!(stdout, ResetColor)?;
        }

        queue!(stdout, Print("\r\n[Up/Down] Navigate  |  [q/Esc] Quit\r\n"))?;

        stdout.flush()?;

        if let Event::Key(key) = event::read()?
            && key.kind == KeyEventKind::Press
        {
            match key.code {
                KeyCode::Char('q') | KeyCode::Esc => break,
                KeyCode::Up if selected > 0 => selected -= 1,
                KeyCode::Down if selected < violations.len() - 1 => selected += 1,
                _ => {}
            }
        }
    }

    drop(_guard);
    println!("Exited interactive mode.");

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn interactive_mode_explains_when_terminal_is_incomplete() {
        for (stdin_is_terminal, stdout_is_terminal) in
            [(false, false), (false, true), (true, false)]
        {
            let error =
                require_interactive_terminal(stdin_is_terminal, stdout_is_terminal).unwrap_err();
            assert!(
                error
                    .to_string()
                    .contains("requires a terminal connected to standard input and output")
            );
        }

        assert!(require_interactive_terminal(true, true).is_ok());
    }
}