haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! Terminal-IO driver for the `haz init` scaffold TUI.
//!
//! [`run_interactive_scaffold`] owns the terminal: it sets up raw
//! mode and the alternate screen, runs the per-screen event loops
//! (draw a frame, read an event, feed it to
//! [`crate::commands::init::tui::dispatch::dispatch`], and perform
//! the IO the dispatcher requests), and unconditionally restores
//! the terminal on the way out via the [`TerminalGuard`] RAII
//! helper. This is the only layer of the init TUI that touches the
//! real terminal.

use std::io::{self, Stdout, Write};
use std::path::Path;

use crossterm::{
    event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use miette::{IntoDiagnostic, Result, WrapErr, miette};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::{TerminalOptions, Viewport};

use crate::commands::init::commit::{CommitPlan, plan_commit};
use crate::commands::init::tui::dispatch::{
    apply_cycle_outcome_signal, apply_expand_outcome_signal, dispatch,
};
use crate::commands::init::tui::render::{render_confirmation_screen, render_tree_screen};
use crate::commands::init::tui::tree_io::{load_children_for_focused, reload_tree_for_visibility};
use crate::commands::init::tui::{DispatchResult, ScaffoldOutcome, TuiState};

/// Run the full interactive scaffold flow against `cwd_host`.
///
/// Sets up a ratatui terminal on stdout, drives the tree screen
/// to end-of-selection, builds a [`CommitPlan`], and then drives
/// the confirmation screen. The terminal is unconditionally
/// restored on return via the `TerminalGuard` RAII helper.
///
/// # Errors
///
/// Propagates the dispatcher's IO errors (`read_dir` failures,
/// raw mode setup) plus any [`crate::commands::init::commit::CommitPlanError`]
/// surfaced during the commit pass.
pub fn run_interactive_scaffold(cwd_host: &Path) -> Result<ScaffoldOutcome> {
    let mut state = TuiState::new(cwd_host.to_path_buf())?;

    let mut terminal = enter_terminal()?;
    let mut guard = TerminalGuard::armed();

    let outcome = drive_tree_screen(&mut terminal, &mut state);

    // Whichever outcome we got, we leave the tree screen with the
    // terminal still in raw mode. The confirmation pass reuses
    // the same terminal; the guard restores on the way out.
    let outcome = match outcome {
        Ok(DriverExit::Commit) => {
            let plan = plan_commit(&state.tree, cwd_host)
                .map_err(|err| miette!("{err}"))
                .wrap_err("haz init: AUX-005 commit-plan construction failed")?;
            drive_confirmation_screen(&mut terminal, &plan)?
        }
        Ok(DriverExit::Cancel) => ScaffoldOutcome::Declined,
        Ok(DriverExit::Abort) => ScaffoldOutcome::Aborted,
        Err(e) => {
            guard.disarm();
            restore_terminal();
            return Err(e);
        }
    };

    guard.disarm();
    restore_terminal();
    Ok(outcome)
}

#[derive(Debug)]
enum DriverExit {
    Commit,
    Cancel,
    Abort,
}

fn drive_tree_screen(
    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
    state: &mut TuiState,
) -> Result<DriverExit> {
    loop {
        terminal
            .draw(|frame| render_tree_screen(frame, state))
            .into_diagnostic()
            .wrap_err("haz init: failed to draw tree screen")?;

        if state.bell_pending {
            ring_bell();
            state.bell_pending = false;
        }

        let event = read_event_blocking()?;
        match dispatch(&event, state) {
            DispatchResult::Continue => {}
            DispatchResult::LoadChildrenThenRetryExpand => {
                load_children_for_focused(state)?;
                let outcome = state.tree.try_expand_focused();
                apply_expand_outcome_signal(state, outcome);
            }
            DispatchResult::LoadChildrenThenRetryCycle => {
                load_children_for_focused(state)?;
                let outcome = state.tree.cycle_focused();
                apply_cycle_outcome_signal(state, outcome);
            }
            DispatchResult::ReloadTreeForVisibility => {
                reload_tree_for_visibility(state)?;
            }
            DispatchResult::ExitCommit => return Ok(DriverExit::Commit),
            DispatchResult::ExitCancel => return Ok(DriverExit::Cancel),
            DispatchResult::Abort => return Ok(DriverExit::Abort),
        }
    }
}

fn drive_confirmation_screen(
    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
    plan: &CommitPlan,
) -> Result<ScaffoldOutcome> {
    loop {
        terminal
            .draw(|frame| render_confirmation_screen(frame, plan))
            .into_diagnostic()
            .wrap_err("haz init: failed to draw confirmation screen")?;

        let event = read_event_blocking()?;
        let Event::Key(key) = event else {
            continue;
        };
        if key.kind != KeyEventKind::Press {
            continue;
        }
        if key.modifiers.contains(KeyModifiers::CONTROL)
            && matches!(key.code, KeyCode::Char('c' | 'C'))
        {
            return Ok(ScaffoldOutcome::Aborted);
        }
        match key.code {
            KeyCode::Char('y' | 'Y') => {
                return Ok(ScaffoldOutcome::Confirmed(plan.clone()));
            }
            KeyCode::Char('n' | 'N' | 'q' | 'Q') | KeyCode::Esc => {
                return Ok(ScaffoldOutcome::Declined);
            }
            _ => {}
        }
    }
}

fn read_event_blocking() -> Result<Event> {
    // crossterm::event::read() blocks; the poll-with-timeout
    // pattern is for non-blocking redraws driven by external
    // state. Our redraws are entirely event-driven, so a plain
    // blocking read keeps the loop simple.
    event::read()
        .into_diagnostic()
        .wrap_err("haz init: failed to read terminal event")
}

fn ring_bell() {
    let _ = io::stderr().write_all(b"\x07");
    let _ = io::stderr().flush();
}

fn enter_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
    enable_raw_mode()
        .into_diagnostic()
        .wrap_err("haz init: failed to enable terminal raw mode")?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)
        .into_diagnostic()
        .wrap_err("haz init: failed to enter the alternate screen")?;
    let backend = CrosstermBackend::new(stdout);
    let terminal = Terminal::with_options(
        backend,
        TerminalOptions {
            viewport: Viewport::Fullscreen,
        },
    )
    .into_diagnostic()
    .wrap_err("haz init: failed to initialise the ratatui terminal")?;
    Ok(terminal)
}

fn restore_terminal() {
    let _ = disable_raw_mode();
    let _ = execute!(io::stdout(), LeaveAlternateScreen);
}

struct TerminalGuard {
    armed: bool,
}

impl TerminalGuard {
    fn armed() -> Self {
        Self { armed: true }
    }

    fn disarm(&mut self) {
        self.armed = false;
    }
}

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        if self.armed {
            restore_terminal();
        }
    }
}