bot-forge 1.0.2

Rust CLI for installing agent skills and developer tools from configurable forms.
Documentation
//! Interactive confirmation and line-oriented stdin handling.

use std::io::{self, IsTerminal};
use std::path::PathBuf;

use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};

use crate::error::ForgeError;
use crate::ui::{
    RawModeGuard, RenderOptions, StatusKind, confirm_key_help, overwrite_key_help, status_line,
    stdout_line,
};

#[derive(Debug, Clone, PartialEq, Eq)]
/// Result of an interactive confirmation request.
pub(crate) enum PromptOutcome<T> {
    Confirmed(T),
    Cancelled,
    Interrupted,
    Unavailable,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// User decision for replacing installed components whose versions are outdated.
pub(crate) enum OverwriteChoice {
    All,
    KeepExisting,
    Custom,
}

/// Ask for confirmation, returning `Unavailable` when non-interactive input is not allowed.
///
/// # Errors
///
/// Returns [`ForgeError`] when the input worker cannot be accessed or stdin reaches an unexpected
/// end.
pub(crate) fn confirm(message: &str, allow_stdin: bool) -> Result<PromptOutcome<()>, ForgeError> {
    if !io::stdin().is_terminal() && !allow_stdin {
        return Ok(PromptOutcome::Unavailable);
    }
    // Confirmation owns the terminal until the user decides. Keep background activity rows
    // cleared so they cannot imply that execution started before authorization was granted.
    let _progress = crate::ui::progress::pause();
    let options = RenderOptions::stdout();
    stdout_line(&format!(
        "{}  {}",
        status_line(StatusKind::Hint, message, &options),
        confirm_key_help(&options)
    ));
    let answer = if io::stdin().is_terminal() {
        read_confirmation_key()?
    } else {
        read_user_line()?
    };
    let answer = answer.trim();
    if answer.eq_ignore_ascii_case("y") {
        Ok(PromptOutcome::Confirmed(()))
    } else if answer.eq_ignore_ascii_case("interrupt") {
        Ok(PromptOutcome::Interrupted)
    } else {
        Ok(PromptOutcome::Cancelled)
    }
}

/// Ask whether outdated installations should all, never, or selectively be replaced.
///
/// # Errors
///
/// Returns [`ForgeError`] when terminal input or raw-mode restoration fails.
pub(crate) fn choose_overwrite(
    message: &str,
) -> Result<PromptOutcome<OverwriteChoice>, ForgeError> {
    if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
        return Ok(PromptOutcome::Unavailable);
    }
    let _progress = crate::ui::progress::pause();
    let options = RenderOptions::stdout();
    stdout_line(&format!(
        "{}  {}",
        status_line(StatusKind::Hint, message, &options),
        overwrite_key_help(&options)
    ));
    let mut raw_mode = RawModeGuard::acquire().map_err(|error| {
        ForgeError::Command(format!("failed to read overwrite choice: {error}"))
    })?;
    let outcome = loop {
        let event = event::read().map_err(|error| {
            ForgeError::Command(format!("failed to read overwrite choice: {error}"))
        })?;
        let Event::Key(key) = event else {
            continue;
        };
        if let Some(outcome) = overwrite_key_event(key) {
            break outcome;
        }
    };
    raw_mode.restore().map_err(|error| {
        ForgeError::Command(format!("failed to restore terminal input mode: {error}"))
    })?;
    Ok(outcome)
}

/// Read the next line from stdin.
///
/// # Errors
///
/// Returns [`ForgeError`] when stdin cannot be read or closes unexpectedly.
pub(crate) fn read_user_line() -> Result<String, ForgeError> {
    let mut line = String::new();
    let bytes = io::stdin()
        .read_line(&mut line)
        .map_err(|source| ForgeError::Io {
            path: PathBuf::from("stdin"),
            source,
        })?;
    if bytes == 0 {
        return Err(ForgeError::Io {
            path: PathBuf::from("stdin"),
            source: io::Error::new(io::ErrorKind::UnexpectedEof, "stdin closed"),
        });
    }
    Ok(line)
}

/// Wait for the user to press Enter or interrupt the prompt with Ctrl-C.
///
/// Interactive terminals are read in raw mode so Ctrl-C can be handled as a key event instead of
/// being delivered as an operating-system signal. Non-interactive input keeps the line-oriented
/// behavior used by pipes and scripted invocations.
///
/// # Errors
///
/// Returns [`ForgeError`] when input cannot be read, raw mode cannot be entered or the terminal
/// cannot be restored.
pub(crate) fn wait_for_enter_or_interrupt() -> Result<(), ForgeError> {
    if !io::stdin().is_terminal() {
        read_user_line().map(|_| ())
    } else {
        let mut raw_mode = RawModeGuard::acquire().map_err(|source| ForgeError::Io {
            path: PathBuf::from("stdin"),
            source,
        })?;
        let result = loop {
            let event = event::read().map_err(|source| ForgeError::Io {
                path: PathBuf::from("stdin"),
                source,
            })?;
            let Event::Key(key) = event else {
                continue;
            };
            if key.kind != KeyEventKind::Press {
                continue;
            }
            if key.code == KeyCode::Enter {
                break Ok(());
            }
            if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
                break Err(ForgeError::Command(
                    "operation interrupted by Ctrl-C".to_string(),
                ));
            }
        };
        raw_mode.restore().map_err(|source| ForgeError::Io {
            path: PathBuf::from("stdin"),
            source,
        })?;
        result
    }
}

fn read_confirmation_key() -> Result<String, ForgeError> {
    let mut raw_mode = RawModeGuard::acquire().map_err(|source| ForgeError::Io {
        path: PathBuf::from("stdin"),
        source,
    })?;
    let answer = loop {
        let event = event::read().map_err(|source| ForgeError::Io {
            path: PathBuf::from("stdin"),
            source,
        })?;
        let Event::Key(key) = event else {
            continue;
        };
        if key.kind != KeyEventKind::Press {
            continue;
        }
        break confirmation_key(key);
    };
    raw_mode.restore().map_err(|source| ForgeError::Io {
        path: PathBuf::from("stdin"),
        source,
    })?;
    Ok(answer)
}

fn confirmation_key(key: KeyEvent) -> String {
    match key.code {
        KeyCode::Char('y' | 'Y') => "y".to_string(),
        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
            "interrupt".to_string()
        }
        _ => String::new(),
    }
}

fn overwrite_key(key: KeyEvent) -> Option<PromptOutcome<OverwriteChoice>> {
    if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
        return Some(PromptOutcome::Interrupted);
    }
    match key.code {
        KeyCode::Char('y' | 'Y') => Some(PromptOutcome::Confirmed(OverwriteChoice::All)),
        KeyCode::Char('n' | 'N') | KeyCode::Esc => {
            Some(PromptOutcome::Confirmed(OverwriteChoice::KeepExisting))
        }
        KeyCode::Char('c' | 'C') => Some(PromptOutcome::Confirmed(OverwriteChoice::Custom)),
        _ => None,
    }
}

fn overwrite_key_event(key: KeyEvent) -> Option<PromptOutcome<OverwriteChoice>> {
    (key.kind == KeyEventKind::Press)
        .then(|| overwrite_key(key))
        .flatten()
}

#[cfg(test)]
mod tests {
    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};

    use crate::ui::input::{
        OverwriteChoice, PromptOutcome, confirmation_key, overwrite_key, overwrite_key_event,
    };

    #[test]
    fn confirmation_keys_have_stable_outcomes() {
        assert_eq!(
            confirmation_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)),
            "y"
        );
        assert_eq!(
            confirmation_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::NONE)),
            ""
        );
        assert_eq!(
            confirmation_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
            "interrupt"
        );
    }

    #[test]
    fn overwrite_keys_have_stable_outcomes() {
        for (key, expected) in [
            (
                KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE),
                PromptOutcome::Confirmed(OverwriteChoice::All),
            ),
            (
                KeyEvent::new(KeyCode::Char('N'), KeyModifiers::NONE),
                PromptOutcome::Confirmed(OverwriteChoice::KeepExisting),
            ),
            (
                KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE),
                PromptOutcome::Confirmed(OverwriteChoice::Custom),
            ),
            (
                KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
                PromptOutcome::Confirmed(OverwriteChoice::KeepExisting),
            ),
            (
                KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
                PromptOutcome::Interrupted,
            ),
        ] {
            assert_eq!(overwrite_key(key), Some(expected));
        }
        assert_eq!(
            overwrite_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
            None
        );
        for kind in [KeyEventKind::Repeat, KeyEventKind::Release] {
            assert_eq!(
                overwrite_key_event(KeyEvent::new_with_kind(
                    KeyCode::Char('Y'),
                    KeyModifiers::NONE,
                    kind,
                )),
                None
            );
        }
    }
}