fp-dotfiles-tui 0.2.5

Minimal premium TUI for fp-dotfiles-manager
//! Application state machine for the TUI.

use crate::commands::{all_commands, Category, CommandDef};
use std::{
    io,
    process::{Command, Stdio},
};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
    Menu,
    Prompt,
    Output,
    Confirm,
    Help,
}

#[derive(Clone, Debug)]
pub enum Action {
    Help,
    Run {
        needs_input: bool,
        needs_confirm: bool,
    },
}

pub struct App {
    pub cli: String,
    pub commands: Vec<CommandDef>,
    pub category_idx: usize,
    pub selected: usize,
    pub mode: Mode,
    pub input: String,
    pub prompt_hint: String,
    pub prompt_required: bool,
    pub output: String,
    pub output_scroll: usize,
    pub status: String,
    pub should_quit: bool,
    pub confirm_yes: bool,
    /// When set, main() runs this after leaving alternate screen.
    pending_interactive: Option<Vec<String>>,
    /// Snapshot of args built for confirm / prompt flow.
    pending_args: Option<Vec<String>>,
}

impl App {
    pub fn new(cli: String) -> Self {
        Self {
            cli,
            commands: all_commands(),
            category_idx: 0,
            selected: 0,
            mode: Mode::Menu,
            input: String::new(),
            prompt_hint: String::new(),
            prompt_required: false,
            output: String::new(),
            output_scroll: 0,
            status: "↑↓ navigate  ·  ←→ category  ·  ⏎ run  ·  ? help  ·  q quit".into(),
            should_quit: false,
            confirm_yes: false,
            pending_interactive: None,
            pending_args: None,
        }
    }

    pub fn categories(&self) -> &'static [Category] {
        &Category::ALL
    }

    pub fn current_category(&self) -> Category {
        Category::ALL[self.category_idx]
    }

    pub fn current_items(&self) -> Vec<&CommandDef> {
        let cat = self.current_category();
        self.commands
            .iter()
            .filter(|c| c.category == cat)
            .collect()
    }

    pub fn selected_cmd(&self) -> Option<&CommandDef> {
        self.current_items().get(self.selected).copied()
    }

    pub fn selected_action(&self) -> Action {
        match self.selected_cmd() {
            Some(cmd) => Action::Run {
                needs_input: cmd.needs_input,
                needs_confirm: cmd.needs_confirm,
            },
            None => Action::Help,
        }
    }

    pub fn next(&mut self) {
        let len = self.current_items().len();
        if len == 0 {
            return;
        }
        self.selected = (self.selected + 1) % len;
    }

    pub fn prev(&mut self) {
        let len = self.current_items().len();
        if len == 0 {
            return;
        }
        self.selected = if self.selected == 0 {
            len - 1
        } else {
            self.selected - 1
        };
    }

    pub fn select_first(&mut self) {
        self.selected = 0;
    }

    pub fn select_last(&mut self) {
        let len = self.current_items().len();
        if len > 0 {
            self.selected = len - 1;
        }
    }

    pub fn next_category(&mut self) {
        self.category_idx = (self.category_idx + 1) % Category::ALL.len();
        self.selected = 0;
    }

    pub fn prev_category(&mut self) {
        self.category_idx = if self.category_idx == 0 {
            Category::ALL.len() - 1
        } else {
            self.category_idx - 1
        };
        self.selected = 0;
    }

    pub fn begin_prompt(&mut self) {
        let Some((hint, required, name)) = self.selected_cmd().map(|c| {
            (c.input_hint, c.input_required, c.name)
        }) else {
            return;
        };
        self.prompt_hint = hint.into();
        self.prompt_required = required;
        self.input.clear();
        self.mode = Mode::Prompt;
        self.status = format!("enter args for `{name}`  ·  Esc cancel");
    }

    pub fn submit_prompt(&mut self) -> io::Result<()> {
        let mut args = self.base_args();
        for token in shell_split(&self.input) {
            args.push(token);
        }
        self.input.clear();
        self.execute(args)
    }

    pub fn run_selected(&mut self) -> io::Result<()> {
        let Some(cmd) = self.selected_cmd().cloned() else {
            return Ok(());
        };

        if cmd.needs_confirm && self.mode == Mode::Confirm && !self.confirm_yes {
            return Ok(());
        }
        self.confirm_yes = false;

        // After confirming a destructive command, optionally collect extra args.
        if cmd.needs_confirm && self.mode == Mode::Confirm {
            if cmd.needs_input {
                self.begin_prompt();
                return Ok(());
            }
        }

        if cmd.interactive {
            let mut args: Vec<String> = cmd.args.iter().map(|s| (*s).to_string()).collect();
            if !self.input.is_empty() {
                for token in shell_split(&self.input) {
                    args.push(token);
                }
                self.input.clear();
            }
            if cmd.needs_input && args.len() == cmd.args.len() {
                self.begin_prompt();
                return Ok(());
            }
            self.pending_interactive = Some(args);
            self.should_quit = true;
            self.status = format!("launching interactive `{}`…", cmd.name);
            return Ok(());
        }

        let args = self.base_args();
        self.execute(args)
    }

    fn base_args(&self) -> Vec<String> {
        if let Some(pending) = &self.pending_args {
            return pending.clone();
        }
        self.selected_cmd()
            .map(|c| c.args.iter().map(|s| (*s).to_string()).collect())
            .unwrap_or_default()
    }

    fn execute(&mut self, args: Vec<String>) -> io::Result<()> {
        self.pending_args = None;
        let label = args
            .first()
            .map(|s| s.as_str())
            .unwrap_or("?")
            .to_string();
        self.status = format!("running `{} {}`…", self.cli, args.join(" "));

        let is_interactive = self
            .selected_cmd()
            .map(|c| c.interactive)
            .unwrap_or(false);
        if is_interactive {
            self.pending_interactive = Some(args);
            self.should_quit = true;
            return Ok(());
        }

        let output = Command::new(&self.cli)
            .args(&args)
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .output();

        match output {
            Ok(out) => {
                let mut buf = String::new();
                let stdout = String::from_utf8_lossy(&out.stdout);
                let stderr = String::from_utf8_lossy(&out.stderr);
                if !stdout.is_empty() {
                    buf.push_str(&stdout);
                }
                if !stderr.is_empty() {
                    if !buf.is_empty() && !buf.ends_with('\n') {
                        buf.push('\n');
                    }
                    buf.push_str(&stderr);
                }
                if buf.is_empty() {
                    buf = if out.status.success() {
                        "(no output — success)".into()
                    } else {
                        format!("(no output — exit {})", out.status)
                    };
                }
                self.output = buf;
                self.output_scroll = 0;
                self.mode = Mode::Output;
                self.status = if out.status.success() {
                    format!("✓ {label} finished")
                } else {
                    format!("✗ {label} failed ({})", out.status)
                };
            }
            Err(e) => {
                self.output = format!(
                    "Failed to execute `{}`.\n\n{e}\n\n\
                     Tip: install fp-dotfiles-manager on PATH, or set:\n  \
                     export FP_DOTFILES_MANAGER=/path/to/fp-dotfiles-manager",
                    self.cli
                );
                self.output_scroll = 0;
                self.mode = Mode::Output;
                self.status = "✗ could not spawn CLI".into();
            }
        }
        Ok(())
    }

    pub fn scroll_output(&mut self, delta: isize) {
        let lines = self.output.lines().count();
        let max = lines.saturating_sub(1);
        if delta < 0 {
            self.output_scroll = self.output_scroll.saturating_sub((-delta) as usize);
        } else {
            self.output_scroll = (self.output_scroll + delta as usize).min(max);
        }
    }

    pub fn take_pending_interactive(&mut self) -> Option<Vec<String>> {
        self.pending_interactive.take()
    }
}

/// Minimal whitespace split (respects simple single/double quotes).
fn shell_split(s: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut cur = String::new();
    let mut chars = s.chars().peekable();
    let mut quote: Option<char> = None;

    while let Some(c) = chars.next() {
        match (quote, c) {
            (Some(q), c) if c == q => quote = None,
            (Some(_), c) => cur.push(c),
            (None, '\'' | '"') => quote = Some(c),
            (None, c) if c.is_whitespace() => {
                if !cur.is_empty() {
                    out.push(std::mem::take(&mut cur));
                }
            }
            (None, c) => cur.push(c),
        }
    }
    if !quote.is_none() {
        // Unclosed quote — still push what we have.
    }
    if !cur.is_empty() {
        out.push(cur);
    }
    out
}

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

    #[test]
    fn splits_simple() {
        assert_eq!(shell_split("a b c"), vec!["a", "b", "c"]);
    }

    #[test]
    fn splits_quoted() {
        assert_eq!(
            shell_split(r#"add "~/.config/my app" foo"#),
            vec!["add", "~/.config/my app", "foo"]
        );
    }
}