git-file-history 0.1.0

TUI for browsing the Git history of a single file
use std::io::{self, Write};

use crossterm::{
    event::{self, Event, KeyEventKind},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};

use crate::{
    app::App,
    error::{AppError, Result},
    terminal::keymap::action_for_key,
    ui::{draw, prepare},
};

/// Runs the application in an alternate-screen terminal UI.
pub(crate) fn run(mut app: App) -> Result<()> {
    let mut session = TerminalSession::new(io::stdout())?;

    session.run(&mut app)
}

struct TerminalSession<W: Write> {
    terminal: Terminal<CrosstermBackend<W>>,
}

impl<W: Write> TerminalSession<W> {
    fn new(writer: W) -> Result<Self> {
        let backend = CrosstermBackend::new(writer);
        let terminal =
            Terminal::new(backend).map_err(|err| AppError::io("failed to create terminal", err))?;

        Ok(Self { terminal })
    }

    fn run(&mut self, app: &mut App) -> Result<()> {
        self.enter()?;

        let result = self.event_loop(app);
        let cleanup_result = self.restore();

        Cleanup::finish(result, cleanup_result)
    }

    fn enter(&mut self) -> Result<()> {
        enable_raw_mode().map_err(|err| AppError::io("failed to enable raw mode", err))?;

        if let Err(err) = execute!(self.terminal.backend_mut(), EnterAlternateScreen) {
            // Raw mode may be active even though alternate-screen entry failed.
            // Always run backend cleanup to restore the terminal as far as possible.
            let enter_error = AppError::io("failed to enter alternate screen", err);
            let cleanup_result = self.restore_backend();
            return Cleanup::finish(Err(enter_error), cleanup_result);
        }

        Ok(())
    }

    fn event_loop(&mut self, app: &mut App) -> Result<()> {
        loop {
            self.terminal
                .draw(|frame| {
                    prepare(app, frame.area());
                    draw(frame, app);
                })
                .map_err(|err| AppError::io("failed to draw UI", err))?;

            if app.should_quit() {
                return Ok(());
            }

            match event::read().map_err(|err| AppError::io("failed to read terminal event", err))? {
                Event::Key(key) if key.kind == KeyEventKind::Press => {
                    if let Some(action) = action_for_key(key) {
                        app.apply(action);
                    }
                }
                Event::Resize(_, _) => {}
                _ => {}
            }
        }
    }

    fn restore(&mut self) -> Result<()> {
        let mut cleanup = Cleanup::default();

        cleanup.push(self.restore_backend());
        cleanup.push(
            self.terminal
                .show_cursor()
                .map_err(|err| AppError::io("failed to show cursor", err)),
        );

        cleanup.into_result()
    }

    fn restore_backend(&mut self) -> Result<()> {
        let mut cleanup = Cleanup::default();

        if let Err(err) = disable_raw_mode() {
            cleanup.push_error(AppError::io("failed to disable raw mode", err));
        }
        if let Err(err) = execute!(self.terminal.backend_mut(), LeaveAlternateScreen) {
            cleanup.push_error(AppError::io("failed to leave alternate screen", err));
        }

        cleanup.into_result()
    }
}

#[derive(Default)]
struct Cleanup {
    errors: Vec<String>,
}

impl Cleanup {
    fn push(&mut self, result: Result<()>) {
        if let Err(err) = result {
            self.push_error(err);
        }
    }

    fn push_error(&mut self, err: AppError) {
        self.errors.push(err.to_string());
    }

    fn into_result(self) -> Result<()> {
        if self.errors.is_empty() {
            Ok(())
        } else {
            Err(AppError::message(self.errors.join("\n")))
        }
    }

    fn finish(primary: Result<()>, cleanup: Result<()>) -> Result<()> {
        match (primary, cleanup) {
            (Ok(()), Ok(())) => Ok(()),
            (Err(err), Ok(())) | (Ok(()), Err(err)) => Err(err),
            (Err(primary), Err(cleanup)) => Err(AppError::message(format!(
                "{primary}\ncleanup also failed:\n{cleanup}"
            ))),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn combine_results_returns_cleanup_only_error() {
        assert_eq!(
            Cleanup::finish(Ok(()), Err(AppError::message("cleanup")))
                .expect_err("cleanup should fail")
                .to_string(),
            "cleanup"
        );
    }

    #[test]
    fn combine_results_returns_primary_and_cleanup_errors() {
        assert_eq!(
            Cleanup::finish(
                Err(AppError::message("primary")),
                Err(AppError::message("cleanup"))
            )
            .expect_err("combined error should fail")
            .to_string(),
            "primary\ncleanup also failed:\ncleanup"
        );
    }

    #[test]
    fn collect_cleanup_result_accumulates_multiple_failures() {
        let mut cleanup = Cleanup::default();

        cleanup.push(Err(AppError::message("first")));
        cleanup.push(Ok(()));
        cleanup.push(Err(AppError::message("second")));

        assert_eq!(
            cleanup
                .into_result()
                .expect_err("cleanup should fail")
                .to_string(),
            "first\nsecond"
        );
    }
}