slite 0.0.1-dev

Declarative migrations and schema management for SQLite
Documentation
use std::sync::{
    atomic::{AtomicBool, Ordering},
    Arc,
};

use ansi_to_tui::IntoText;
use chrono::Local;
use tokio::{sync::mpsc, task};
use tracing::error;
use tui::{
    layout::{Alignment, Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Span, Spans, Text},
    widgets::{Block, BorderType, Borders, Clear, Paragraph, StatefulWidget, Widget, Wrap},
};

use crate::{
    error::{MigrationError, SqlFormatError},
    Options,
};

use super::{
    panel, BiPanel, BiPanelState, BroadcastWriter, Button, Message, MigratorFactory, Scrollable,
    ScrollableState,
};

pub struct MigrationView {}

impl StatefulWidget for MigrationView {
    type State = MigrationState;

    fn render(
        self,
        area: tui::layout::Rect,
        buf: &mut tui::buffer::Buffer,
        state: &mut Self::State,
    ) {
        let chunks = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Length(21), Constraint::Min(0)])
            .split(area);
        let controls_enabled = state.controls_enabled.load(Ordering::SeqCst);
        Widget::render(
            Paragraph::new(vec![
                Button::new("     Dry Run     ")
                    .fg(Color::Blue)
                    .selected(state.selected == 0)
                    .enabled(controls_enabled)
                    .build(),
                Spans::from(""),
                Button::new(" Generate Script ")
                    .fg(Color::Blue)
                    .selected(state.selected == 1)
                    .enabled(controls_enabled)
                    .build(),
                Spans::from(""),
                Button::new("     Migrate     ")
                    .fg(Color::Yellow)
                    .selected(state.selected == 2)
                    .enabled(controls_enabled)
                    .build(),
                Spans::from(""),
                Button::new("  Clear Output   ")
                    .fg(Color::Magenta)
                    .selected(state.selected == 3)
                    .enabled(controls_enabled)
                    .build(),
            ])
            .alignment(Alignment::Center)
            .block(state.bipanel_state.left_block("Controls")),
            chunks[0],
            buf,
        );

        StatefulWidget::render(
            Scrollable::new(
                Paragraph::new(state.formatted_logs.clone())
                    .block(state.bipanel_state.right_block(&state.log_title())),
            ),
            chunks[1],
            buf,
            &mut state.scroller,
        );

        if state.show_popup {
            let text = Paragraph::new(vec![
                Spans::from(vec![Span::from("Run database migration?")]),
                Spans::from(""),
            ])
            .wrap(Wrap { trim: false });
            let buttons = Paragraph::new(Spans::from(vec![
                Span::styled(
                    " Cancel ",
                    Style::default()
                        .bg(Color::Black)
                        .fg(Color::Blue)
                        .add_modifier(if state.popup_button_index == 0 {
                            Modifier::BOLD | Modifier::SLOW_BLINK | Modifier::REVERSED
                        } else {
                            Modifier::empty()
                        }),
                ),
                Span::from("  "),
                Span::styled(
                    " Migrate ",
                    Style::default()
                        .bg(Color::Black)
                        .fg(Color::Yellow)
                        .add_modifier(if state.popup_button_index == 1 {
                            Modifier::BOLD | Modifier::SLOW_BLINK | Modifier::REVERSED
                        } else {
                            Modifier::empty()
                        }),
                ),
                Span::from(" "),
            ]))
            .alignment(Alignment::Right);
            let block = Block::default()
                .title(Span::styled(
                    "Confirm Action",
                    Style::default().add_modifier(Modifier::BOLD),
                ))
                .borders(Borders::ALL)
                .border_type(BorderType::Rounded)
                .border_style(Style::default().fg(Color::Cyan));

            let area = centered_rect(30, 50, area);
            let popup_chunks = Layout::default()
                .direction(Direction::Vertical)
                .margin(1)
                .constraints([Constraint::Min(0), Constraint::Length(1)])
                .split(area);

            Widget::render(Clear, area, buf);
            Widget::render(block, area, buf);
            Widget::render(text, popup_chunks[0], buf);
            Widget::render(buttons, popup_chunks[1], buf);
        }
    }
}

fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints(
            [
                Constraint::Percentage((100 - percent_y) / 2),
                Constraint::Max(7),
                Constraint::Percentage((100 - percent_y) / 2),
            ]
            .as_ref(),
        )
        .split(r);

    Layout::default()
        .direction(Direction::Horizontal)
        .constraints(
            [
                Constraint::Percentage((100 - percent_x) / 2),
                Constraint::Max(30),
                Constraint::Percentage((100 - percent_x) / 2),
            ]
            .as_ref(),
        )
        .split(popup_layout[1])[1]
}

pub struct MigrationState {
    selected: i32,
    num_buttons: i32,
    show_popup: bool,
    popup_button_index: i32,
    logs: String,
    log_start_time: Option<chrono::DateTime<Local>>,
    formatted_logs: Text<'static>,
    scroller: ScrollableState,
    bipanel_state: BiPanelState,
    message_tx: mpsc::Sender<Message>,
    controls_enabled: Arc<AtomicBool>,
    migrator_factory: MigratorFactory,
}

impl MigrationState {
    pub fn new(migrator_factory: MigratorFactory, message_tx: mpsc::Sender<Message>) -> Self {
        Self {
            migrator_factory,
            selected: 0,
            scroller: ScrollableState::new(0),
            num_buttons: 4,
            show_popup: false,
            popup_button_index: 0,
            logs: "".to_owned(),
            bipanel_state: BiPanelState::default(),
            formatted_logs: Text::default(),
            log_start_time: None,
            message_tx,
            controls_enabled: Arc::new(AtomicBool::new(true)),
        }
    }

    pub fn next(&mut self) {
        panel::next(self, &self.bipanel_state.clone());
    }

    pub fn previous(&mut self) {
        panel::previous(self, &self.bipanel_state.clone());
    }

    pub fn toggle_focus(&mut self) {
        self.bipanel_state.toggle_focus();
    }

    pub fn execute(&mut self) -> Result<(), MigrationError> {
        if !self.controls_enabled.load(Ordering::SeqCst) {
            return Ok(());
        }

        if self.show_popup {
            let popup_button_index = self.popup_button_index;
            self.popup_button_index = 0;
            self.show_popup = false;
            if popup_button_index == 1 {
                self.clear_logs();
                self.log_start_time = Some(chrono::Local::now());
                let migrator = self.migrator_factory.create_migrator(Options {
                    allow_deletions: true,
                    dry_run: false,
                });
                let migration_script_tx = self.message_tx.clone();
                let controls_enabled = self.controls_enabled.clone();
                controls_enabled.store(false, Ordering::SeqCst);
                task::spawn_blocking(move || {
                    if let Err(e) = migrator.migrate() {
                        error!("{e}");
                    }
                    controls_enabled.store(true, Ordering::SeqCst);
                    if let Err(e) = migration_script_tx.blocking_send(Message::MigrationCompleted) {
                        error!("{e}");
                    }
                });
            }
        } else {
            match self.selected {
                0 => {
                    self.clear_logs();
                    self.log_start_time = Some(chrono::Local::now());
                    let migrator = self.migrator_factory.create_migrator(Options {
                        allow_deletions: true,
                        dry_run: true,
                    });
                    let migration_script_tx = self.message_tx.clone();
                    let controls_enabled = self.controls_enabled.clone();
                    controls_enabled.store(false, Ordering::SeqCst);
                    task::spawn_blocking(move || {
                        if let Err(e) = migrator.migrate() {
                            error!("{e}");
                        }
                        controls_enabled.store(true, Ordering::SeqCst);
                        if let Err(e) = migration_script_tx.blocking_send(Message::ProcessCompleted)
                        {
                            error!("{e}");
                        }
                    });
                }
                1 => {
                    self.clear_logs();
                    self.log_start_time = Some(chrono::Local::now());
                    BroadcastWriter::disable();
                    let migrator = self.migrator_factory.create_migrator(Options {
                        allow_deletions: true,
                        dry_run: true,
                    });
                    let migration_script_tx = self.message_tx.clone();
                    let controls_enabled = self.controls_enabled.clone();
                    controls_enabled.store(false, Ordering::SeqCst);
                    task::spawn_blocking(move || {
                        if let Err(e) = migrator.migrate_with_callback(|statement| {
                            if let Err(e) =
                                migration_script_tx.blocking_send(Message::Log(statement))
                            {
                                error!("{e}");
                            }
                        }) {
                            BroadcastWriter::enable();
                            error!("{e}");
                        };
                        BroadcastWriter::enable();
                        controls_enabled.store(true, Ordering::SeqCst);
                        if let Err(e) = migration_script_tx.blocking_send(Message::ProcessCompleted)
                        {
                            error!("{e}");
                        }
                    });
                }
                2 => {
                    self.show_popup = true;
                }
                3 => {
                    self.clear_logs();
                }
                _ => {}
            }
        }

        Ok(())
    }

    pub fn popup_active(&self) -> bool {
        self.show_popup
    }

    pub fn toggle_popup_confirm(&mut self) {
        self.popup_button_index = (self.popup_button_index + 1) % 2;
    }

    pub fn add_log(&mut self, log: String) -> Result<(), SqlFormatError> {
        self.logs += &log;
        self.formatted_logs = self
            .logs
            .into_text()
            .map_err(|e| SqlFormatError::TextFormattingFailure(log, e))?;
        self.scroller
            .set_content_height(self.formatted_logs.height() as u16);
        Ok(())
    }

    pub fn clear_logs(&mut self) {
        self.logs = "".to_owned();
        self.formatted_logs = Text::default();
        self.scroller.set_content_height(0);
        self.log_start_time = None;
    }

    pub fn migrator_factory(&mut self) -> &mut MigratorFactory {
        &mut self.migrator_factory
    }

    fn log_title(&self) -> String {
        match self.log_start_time {
            Some(start_time) => format!("Logs {}", start_time.format("%Y-%m-%d %H:%M:%S")),
            None => "Logs".to_owned(),
        }
    }
}

impl BiPanel for MigrationState {
    fn left_next(&mut self) {
        if !self.show_popup {
            self.selected = (self.selected + 1).rem_euclid(self.num_buttons);
        }
    }

    fn right_next(&mut self) {
        self.scroller.scroll_down();
    }

    fn left_previous(&mut self) {
        if !self.show_popup {
            self.selected = (self.selected - 1).rem_euclid(self.num_buttons);
        }
    }

    fn right_previous(&mut self) {
        self.scroller.scroll_up();
    }
}