xript-wiz 0.4.1

Interactive TUI wizard for the xript toolchain — powered by xript fragments.
use std::collections::HashMap;

use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;

use crate::completion::CompletionState;
use crate::screens::scaffold::ScaffoldField;
use crate::screens::Screen;

pub struct App {
    pub screen: Screen,
    pub selected: usize,
    pub input: String,
    pub result_fragment: Option<serde_json::Value>,
    pub result_bindings: HashMap<String, serde_json::Value>,
    pub status_message: String,
    pub should_quit: bool,
    pub scaffold_field: ScaffoldField,
    pub scaffold_name: String,
    pub scaffold_type_idx: usize,
    pub scaffold_lang_idx: usize,
    pub completion: CompletionState,
}

impl App {
    pub fn new() -> Self {
        Self {
            screen: Screen::Home,
            selected: 0,
            input: String::new(),
            result_fragment: None,
            result_bindings: HashMap::new(),
            status_message: String::new(),
            should_quit: false,
            scaffold_field: ScaffoldField::Name,
            scaffold_name: String::new(),
            scaffold_type_idx: 0,
            scaffold_lang_idx: 0,
            completion: CompletionState::new(),
        }
    }

    pub fn navigate_to(&mut self, screen: Screen) {
        self.screen = screen;
        self.input.clear();
        self.result_fragment = None;
        self.result_bindings.clear();
        self.scaffold_field = ScaffoldField::Name;
        self.scaffold_name.clear();
        self.scaffold_type_idx = 0;
        self.scaffold_lang_idx = 0;
        self.completion = CompletionState::new();
        if matches!(screen, Screen::Validate | Screen::Sanitize | Screen::Audit | Screen::Diff) {
            self.completion.update("");
        }
    }

    pub fn go_home(&mut self) {
        self.navigate_to(Screen::Home);
    }

    pub fn draw(&self, frame: &mut Frame) {
        let area = frame.area();

        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(10),
                Constraint::Length(1),
                Constraint::Min(5),
                Constraint::Length(1),
            ])
            .split(area);

        self.render_header(frame, chunks[0]);
        self.render_breadcrumb(frame, chunks[1]);
        self.render_main(frame, chunks[2]);
        self.render_status(frame, chunks[3]);
    }

    fn render_header(&self, frame: &mut Frame, area: Rect) {
        use ansi_to_tui::IntoText;

        let logo_raw = include_str!("logo.ansi");
        let logo_text = logo_raw.into_text().unwrap_or_default();
        let logo = Paragraph::new(logo_text);

        let logo_area = Rect {
            x: area.x + area.width.saturating_sub(40) / 2,
            y: area.y,
            width: 40.min(area.width),
            height: 10.min(area.height),
        };
        frame.render_widget(logo, logo_area);
    }

    fn render_breadcrumb(&self, frame: &mut Frame, area: Rect) {
        let screen_title = self.screen.title();
        let breadcrumb = if self.screen == Screen::Home {
            Line::from(vec![
                Span::styled(
                    " xript",
                    Style::default()
                        .fg(Color::Cyan)
                        .add_modifier(Modifier::BOLD),
                ),
            ])
        } else {
            Line::from(vec![
                Span::styled(" xript", Style::default().fg(Color::DarkGray)),
                Span::styled(" > ", Style::default().fg(Color::DarkGray)),
                Span::styled(
                    screen_title,
                    Style::default()
                        .fg(Color::Cyan)
                        .add_modifier(Modifier::BOLD),
                ),
            ])
        };

        let para = Paragraph::new(breadcrumb);
        frame.render_widget(para, area);
    }

    fn render_main(&self, frame: &mut Frame, area: Rect) {
        match self.screen {
            Screen::Home => crate::screens::home::render(frame, area, self),
            Screen::Validate => crate::screens::validate::render(frame, area, self),
            Screen::Scaffold => crate::screens::scaffold::render(frame, area, self),
            Screen::Sanitize => crate::screens::sanitize::render(frame, area, self),
            Screen::Audit => crate::screens::audit::render(frame, area, self),
            Screen::Diff => crate::screens::diff::render(frame, area, self),
        }
    }

    fn render_status(&self, frame: &mut Frame, area: Rect) {
        let help_text = match self.screen {
            Screen::Home => {
                "\u{2191}\u{2193} navigate \u{00b7} Enter select \u{00b7} q quit"
            }
            _ if self.result_fragment.is_some() => {
                "Press any key to return home"
            }
            _ => {
                "\u{2191}\u{2193} navigate \u{00b7} Enter select \u{00b7} Esc back \u{00b7} q quit"
            }
        };

        let status = Paragraph::new(Line::from(vec![Span::styled(
            format!(" {}", help_text),
            Style::default().fg(Color::DarkGray),
        )]));

        frame.render_widget(status, area);
    }
}

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

    #[test]
    fn app_starts_on_home_screen() {
        let app = App::new();
        assert_eq!(app.screen, Screen::Home);
        assert_eq!(app.selected, 0);
        assert!(!app.should_quit);
    }

    #[test]
    fn navigate_to_resets_state() {
        let mut app = App::new();
        app.input = "some input".to_string();
        app.result_fragment = Some(serde_json::json!("result"));
        app.selected = 2;

        app.navigate_to(Screen::Validate);
        assert_eq!(app.screen, Screen::Validate);
        assert!(app.input.is_empty());
        assert!(app.result_fragment.is_none());
    }

    #[test]
    fn go_home_returns_to_home_screen() {
        let mut app = App::new();
        app.navigate_to(Screen::Sanitize);
        app.go_home();
        assert_eq!(app.screen, Screen::Home);
    }

    #[test]
    fn scaffold_field_navigation() {
        assert_eq!(ScaffoldField::Name.next(), Some(ScaffoldField::Type));
        assert_eq!(ScaffoldField::Type.next(), Some(ScaffoldField::Lang));
        assert_eq!(ScaffoldField::Lang.next(), None);

        assert_eq!(ScaffoldField::Name.prev(), None);
        assert_eq!(ScaffoldField::Type.prev(), Some(ScaffoldField::Name));
        assert_eq!(ScaffoldField::Lang.prev(), Some(ScaffoldField::Type));
    }

    #[test]
    fn screen_titles() {
        assert_eq!(Screen::Home.title(), "Home");
        assert_eq!(Screen::Validate.title(), "Validate Manifest");
        assert_eq!(Screen::Scaffold.title(), "Scaffold Project");
        assert_eq!(Screen::Sanitize.title(), "Sanitize Fragment");
        assert_eq!(Screen::Audit.title(), "Audit Manifest");
        assert_eq!(Screen::Diff.title(), "Diff Manifest");
    }

    #[test]
    fn navigate_to_resets_completion() {
        let mut app = App::new();
        app.completion.suggestions = vec!["test".to_string()];
        app.navigate_to(Screen::Scaffold);
        assert!(app.completion.suggestions.is_empty());
    }
}