haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! Interactive scaffold TUI for `haz init` per
//! `AUX-003`..`AUX-005`.
//!
//! Two layers live here:
//!
//! - `dispatch` is a pure function from a crossterm event and a
//!   [`TuiState`] to a [`DispatchResult`]. It mutates the state
//!   in-place and performs no IO; the side effects the driver
//!   needs to perform (loading children from disk, leaving the
//!   tree screen to commit) are returned as enum variants. The
//!   purity makes every key binding exhaustively unit-testable
//!   without a terminal.
//! - [`run_interactive_scaffold`] is the IO-driven loop that owns
//!   the terminal: it sets up raw mode + the alternate screen,
//!   draws each frame, reads events, calls `dispatch`, and
//!   performs the IO actions the dispatcher requests.
//!
//! The screen layout is a single navigable tree with a synthetic
//! `[ Commit selection ]` button just below it; pressing `Enter`
//! from any focus position commits, while arrow / space cycle
//! state for tree rows.
//!
//! Hidden-directory handling: by default the tree omits
//! dotfile-prefixed entries (`.git`, `.cache`, ...) and entries
//! matched by the cwd's `.gitignore` (and git's global excludes).
//! `H` toggles their visibility; when shown, dotfile rows render
//! dim and gitignored rows render dim with a warning-coloured
//! foreground so the user can still distinguish them from
//! ordinary content. The classification is captured per-row via
//! [`super::model::HiddenKind`].

pub(super) mod dispatch;
pub(super) mod driver;
pub(super) mod gitignore;
pub(super) mod render;
pub(super) mod tree_io;

use std::ffi::OsString;
use std::path::{Path, PathBuf};

use miette::Result;

use super::commit::CommitPlan;
use super::model::{Node, Tree};

use crate::commands::init::tui::gitignore::GitignoreMatcher;
use crate::commands::init::tui::tree_io::read_dir_children;

pub use crate::commands::init::tui::driver::run_interactive_scaffold;

/// What the dispatcher's caller (the driver loop) should do next.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DispatchResult {
    /// Stay in the loop: redraw, then read another event.
    Continue,
    /// The driver reads the focused node's children and then
    /// retries the expand in the same keystroke. Used when Right
    /// was pressed on a directory whose children were not yet
    /// loaded: without the retry, the keypress would silently
    /// flip `expanded` (or do nothing for an empty directory)
    /// without giving the user the AUX-004-style refusal signal
    /// the empty case warrants.
    LoadChildrenThenRetryExpand,
    /// The driver reads the focused node's children and then
    /// retries `cycle_focused` in the same keystroke. Used when
    /// Space was pressed on a node whose children were not yet
    /// loaded: without the retry the keypress would only do a
    /// silent `read_dir` and the user would see "nothing happen".
    LoadChildrenThenRetryCycle,
    /// The user toggled `hidden_visible` and the tree's
    /// currently-loaded subtrees must be re-read against the new
    /// filter so the visual state matches the flag. The driver
    /// walks every already-loaded directory, re-reads its
    /// children with the new visibility, preserves
    /// expanded/state by basename, then re-anchors focus.
    ReloadTreeForVisibility,
    /// Leave the tree screen and move to the commit pass.
    ExitCommit,
    /// User declined the scaffold (`q` / `Esc`). Exit cleanly.
    ExitCancel,
    /// User aborted (`Ctrl+C`). Exit with the signal-interrupt
    /// code.
    Abort,
}

/// Final outcome of the scaffold flow as far as the executor is
/// concerned.
#[derive(Debug)]
pub enum ScaffoldOutcome {
    /// User confirmed the plan. Apply it.
    Confirmed(CommitPlan),
    /// User declined either the tree screen (`q`) or the
    /// confirmation prompt (`n`). Exit 0 with no filesystem
    /// changes per `AUX-005` step 4 and `AUX-008`.
    Declined,
    /// User aborted via `Ctrl+C`. Exit 130 per `EXEC-021`.
    Aborted,
}

/// Which on-screen element currently carries the focus highlight.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiFocus {
    /// A tree row, identified by the underlying [`Tree`]'s focus
    /// path.
    Tree,
    /// The synthetic `[ Commit selection ]` button below the tree.
    CommitButton,
}

/// The full mutable TUI state. Held by the driver, mutated by
/// `dispatch`.
#[derive(Debug)]
pub struct TuiState {
    /// The interactive tree per [`super::model`].
    pub tree: Tree,
    /// Which on-screen element gets the focus highlight.
    pub focus: TuiFocus,
    /// Host filesystem path of the cwd. Displayed in the header;
    /// used by the commit-pass plumbing when leaving the tree
    /// screen.
    pub cwd_host: PathBuf,
    /// Gitignore matcher consulted at directory-read time to
    /// classify entries as `HiddenKind::Gitignored`.
    pub gitignore: GitignoreMatcher,
    /// Whether dotfile-prefixed and gitignored sibling
    /// directories are visible in the tree. Defaults to `false`.
    /// Toggled by `H`; the toggle returns
    /// [`DispatchResult::ReloadTreeForVisibility`] so the driver
    /// can re-read every already-loaded directory against the new
    /// flag (state and expanded-ness are preserved by basename).
    pub hidden_visible: bool,
    /// Transient status / refusal message rendered above the
    /// keybinding hint. Cleared on every new keystroke before
    /// dispatch.
    pub status: Option<String>,
    /// Whether the help overlay is currently open. Any keystroke
    /// dismisses it.
    pub help_visible: bool,
    /// Whether the driver should ring the terminal bell on the
    /// next render. Cleared after the bell fires.
    pub bell_pending: bool,
}

impl TuiState {
    /// Construct an initial state rooted at `cwd_host`. Loads the
    /// cwd's direct subdirectories (filtered per [`Self::hidden_visible`])
    /// so the user has something to navigate immediately.
    fn new(cwd_host: PathBuf) -> Result<Self> {
        let gitignore = GitignoreMatcher::new(&cwd_host);
        let children = read_dir_children(&cwd_host, Path::new("/"), &gitignore, false)?;
        let mut root = Node::new(OsString::from("/"), PathBuf::from("/"));
        root.children = children;
        root.children_loaded = true;
        root.expanded = true;
        Ok(Self {
            tree: Tree::new(root),
            focus: TuiFocus::Tree,
            cwd_host,
            gitignore,
            hidden_visible: false,
            status: None,
            help_visible: false,
            bell_pending: false,
        })
    }
}