rootle 0.8.4

Modal TUI for browsing remote source-control systems (GitHub in-tree, any forge via stdio providers) from the terminal
Documentation
//! Declared-provider consent (plans/0019 M2): the config names a
//! provider this machine doesn't have — rootle asks before it
//! downloads and runs anything. `y` accepts the verified install
//! (status-line progress, then a hot-swap); `n`/Esc declines into
//! honest degraded mode. The declaration stays visible either way.

use crate::action::{Action, DeclarationState};
use crate::components::centered;
use crate::provider::Declaration;
use crate::theme::Theme;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};

pub struct ConsentPopup {
    decl: Declaration,
    /// None = asking; Some(Installing) = worker running;
    /// Some(Failed) = refused, Esc closes.
    state: Option<DeclarationState>,
}

impl ConsentPopup {
    pub fn new(decl: Declaration) -> Self {
        ConsentPopup { decl, state: None }
    }

    pub fn declaration(&self) -> &Declaration {
        &self.decl
    }

    /// Worker state landing (via `Action::DeclarationState`).
    pub fn set_state(&mut self, state: DeclarationState) {
        self.state = Some(state);
    }

    pub fn handle_key(&mut self, key: KeyEvent) -> Action {
        match (key.code, &self.state) {
            (KeyCode::Char('y'), None) => Action::DeclarationAccept,
            (KeyCode::Char('n') | KeyCode::Esc, _) => Action::DeclarationDecline,
            // While installing or after a failure only Esc/n leave;
            // everything else is a Noop the app ignores.
            _ => Action::Noop,
        }
    }

    pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
        let sem = &theme.semantic;
        let area = centered(area, 64, 40);
        let decl = &self.decl;
        let mut lines = vec![
            Line::from(vec![
                Span::styled("config declares ", Style::default().fg(sem.subtext0)),
                Span::styled(
                    decl.name.clone(),
                    Style::default().fg(sem.text).add_modifier(Modifier::BOLD),
                ),
                Span::styled(
                    " — not installed on this machine",
                    Style::default().fg(sem.subtext0),
                ),
            ]),
            Line::from(vec![
                Span::styled("source  ", Style::default().fg(sem.subtext0)),
                Span::styled(decl.repo.clone(), Style::default().fg(sem.text)),
            ]),
        ];
        if decl.tag.is_some() || decl.sha.is_some() {
            let pins = [
                decl.tag.clone().map(|t| format!("tag {t}")),
                decl.sha.is_some().then(|| "sha256".to_string()),
            ]
            .into_iter()
            .flatten()
            .collect::<Vec<_>>()
            .join(" · ");
            lines.push(Line::from(vec![
                Span::styled("pins    ", Style::default().fg(sem.subtext0)),
                Span::styled(pins, Style::default().fg(sem.warning)),
            ]));
        }
        lines.push(Line::raw(""));
        lines.push(Line::from(Span::styled(
            format!(
                "you are trusting {} — the tarball is sha256-verified{} before anything runs",
                decl.repo,
                if decl.sha.is_some() {
                    " against your config's pin and its sidecar"
                } else {
                    " against its release sidecar"
                }
            ),
            Style::default().fg(sem.hint),
        )));
        match &self.state {
            None => lines.push(Line::from(vec![
                Span::styled(
                    "y",
                    Style::default().fg(sem.text).add_modifier(Modifier::BOLD),
                ),
                Span::styled(" install  ·  ", Style::default().fg(sem.subtext0)),
                Span::styled(
                    "n",
                    Style::default().fg(sem.text).add_modifier(Modifier::BOLD),
                ),
                Span::styled(" browse github instead", Style::default().fg(sem.subtext0)),
            ])),
            Some(DeclarationState::Installing) => lines.push(Line::from(Span::styled(
                "installing — verified download running…",
                Style::default().fg(sem.hint),
            ))),
            Some(DeclarationState::Failed(e)) => {
                lines.push(Line::from(Span::styled(
                    format!("failed: {e}"),
                    Style::default().fg(sem.error),
                )));
                lines.push(Line::from(Span::styled(
                    "esc — browse github (the declaration stays in your config)",
                    Style::default().fg(sem.subtext0),
                )));
            }
        }
        let block = Block::default()
            .borders(Borders::ALL)
            .border_type(theme.border_type())
            .border_style(Style::default().fg(sem.border_focused))
            .title(Span::styled(
                " install provider? ",
                Style::default().fg(sem.text).add_modifier(Modifier::BOLD),
            ));
        frame.render_widget(Clear, area);
        frame.render_widget(
            Paragraph::new(lines)
                .block(block)
                .wrap(ratatui::widgets::Wrap { trim: false }),
            area,
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ratatui::{Terminal, backend::TestBackend};
    fn decl() -> Declaration {
        Declaration {
            name: "gitlab".into(),
            repo: "rootledev/rootle-gitlab".into(),
            tag: None,
            sha: None,
        }
    }

    fn screen(popup: &mut ConsentPopup) -> String {
        let backend = TestBackend::new(100, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|f| popup.render(f, f.area(), &Theme::catppuccin_mocha()))
            .unwrap();
        let buf = terminal.backend().buffer();
        (0..buf.area.height)
            .map(|y| {
                (0..buf.area.width)
                    .map(|x| buf[(x, y)].symbol().to_string())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, ratatui::crossterm::event::KeyModifiers::NONE)
    }

    /// 0019 M2: the popup asks — declaration, trust line, both keys
    /// named; y accepts, n declines, nothing else leaks out.
    #[test]
    fn asks_with_trust_line_and_maps_keys() {
        let mut popup = ConsentPopup::new(decl());
        let s = screen(&mut popup);
        assert!(s.contains("config declares gitlab"), "{s}");
        assert!(s.contains("rootledev/rootle-gitlab"), "{s}");
        assert!(
            s.contains("you are trusting rootledev/rootle-gitlab"),
            "{s}"
        );
        assert!(s.contains("y install"), "{s}");
        assert!(s.contains("n browse github"), "{s}");

        assert_eq!(
            popup.handle_key(key(KeyCode::Char('y'))),
            Action::DeclarationAccept
        );
        assert_eq!(
            popup.handle_key(key(KeyCode::Char('n'))),
            Action::DeclarationDecline
        );
        assert_eq!(
            popup.handle_key(key(KeyCode::Esc)),
            Action::DeclarationDecline
        );
        assert_eq!(popup.handle_key(key(KeyCode::Char('q'))), Action::Noop);
    }

    /// The pin fields surface: tag and sha both ride the source row.
    #[test]
    fn pins_surface_in_the_popup() {
        let mut popup = ConsentPopup::new(Declaration {
            tag: Some("v0.2.1".into()),
            sha: Some("deadbeef".into()),
            ..decl()
        });
        let s = screen(&mut popup);
        assert!(s.contains("pins"), "{s}");
        assert!(s.contains("tag v0.2.1"), "{s}");
        assert!(s.contains("sha256"), "{s}");
        assert!(s.contains("against your config's pin"), "{s}");
    }

    /// Worker state renders: installing progress, failure with the
    /// error and the honest escape hatch.
    #[test]
    fn installing_and_failed_states_render() {
        let mut popup = ConsentPopup::new(decl());
        popup.set_state(DeclarationState::Installing);
        assert!(screen(&mut popup).contains("installing — verified download running"));

        popup.set_state(DeclarationState::Failed("network: refused".into()));
        let s = screen(&mut popup);
        assert!(s.contains("failed: network: refused"), "{s}");
        assert!(s.contains("esc — browse github"), "{s}");
        // A failed install still declines cleanly.
        assert_eq!(
            popup.handle_key(key(KeyCode::Esc)),
            Action::DeclarationDecline
        );
    }
}