bath 0.4.0

A TUI tool to manage and export environment variable profiles
use crate::db;
use crate::tui::daisyui_themes;
use crate::tui::dialogs::{create_custom_var_dialog, create_or_edit_item_dialog};
use crate::tui::state::AppState;
use crate::tui::view::View;
use anyhow::Result;
use ratatui::backend::Backend;
use ratatui::Terminal;

fn all_commands() -> Vec<String> {
    vec![
        "quit".to_string(),
        "profiles".to_string(),
        "vars".to_string(),
        "defs".to_string(),
        "parts".to_string(),
        "items".to_string(),
        "preview".to_string(),
        "export".to_string(),
        "use".to_string(),
        "themes".to_string(),
        "theme".to_string(),
        "new-var".to_string(),
        "new-item".to_string(),
        "help".to_string(),
    ]
}

/// Maximum number of suggestion rows shown in the palette overlay.
pub const MAX_VISIBLE_SUGGESTIONS: usize = 8;

/// Clamp a palette selection index against the suggestion list length.
///
/// This is the single place selection clamping happens: input handling
/// (Up/Down/Tab), rendering and execution all consult it, so the highlighted
/// entry and the executed entry can never diverge.
pub fn clamp_selection(selected: usize, len: usize) -> usize {
    if len == 0 {
        0
    } else {
        selected.min(len - 1)
    }
}

/// Scroll window over `len` suggestions that always keeps the selection
/// visible. Returns `(window_start, highlight_row)`: render suggestions
/// `[window_start..window_start + visible]` and highlight `highlight_row`
/// within that slice; `window_start + highlight_row` is the clamped
/// selection.
pub fn suggestion_window(selected: usize, len: usize, visible: usize) -> (usize, usize) {
    if len == 0 || visible == 0 {
        return (0, 0);
    }
    let selected = clamp_selection(selected, len);
    let start = selected
        .saturating_sub(visible - 1)
        .min(len.saturating_sub(visible));
    (start, selected - start)
}

pub fn refresh_command_suggestions(app: &mut AppState) {
    let input = app.command_input.trim_start();
    let mut suggestions = Vec::new();

    if input.starts_with("use ") {
        let q = input.trim_start_matches("use ").trim().to_lowercase();
        for p in &app.profiles {
            if q.is_empty() || p.name.to_lowercase().contains(&q) {
                suggestions.push(format!("use {}", p.name));
            }
        }
    } else if input.starts_with("theme ") {
        let q = input.trim_start_matches("theme ").trim().to_lowercase();
        for name in daisyui_themes::names() {
            if q.is_empty() || name.to_lowercase().contains(&q) {
                suggestions.push(format!("theme {name}"));
            }
        }
    } else {
        let cmds = all_commands();
        let q = input.to_lowercase();
        for c in cmds {
            if q.is_empty() || c.to_lowercase().starts_with(&q) || c.to_lowercase().contains(&q) {
                suggestions.push(c);
            }
        }
    }

    suggestions.sort();
    suggestions.dedup();
    app.command_suggestions = suggestions;
    app.command_selected = clamp_selection(app.command_selected, app.command_suggestions.len());
}

pub fn pick_command_to_execute(app: &AppState) -> String {
    let typed = app.command_input.trim().to_string();
    let selected = clamp_selection(app.command_selected, app.command_suggestions.len());
    if typed.is_empty() {
        return app
            .command_suggestions
            .get(selected)
            .cloned()
            .unwrap_or_default();
    }
    if app.command_suggestions.is_empty() {
        return typed;
    }
    // If user typed something that isn't an exact command, execute the selected suggestion.
    if app.command_suggestions.iter().any(|s| s == &typed) {
        return typed;
    }
    app.command_suggestions
        .get(selected)
        .cloned()
        .unwrap_or(typed)
}

pub fn execute_command<B: Backend>(
    terminal: &mut Terminal<B>,
    app: &mut AppState,
    cmd: &str,
) -> Result<bool> {
    let cmd = cmd.trim();
    if cmd.is_empty() {
        return Ok(false);
    }

    if cmd == "quit" || cmd == "q" || cmd == "exit" {
        return Ok(true);
    }
    if cmd == "profiles" {
        app.active_view = View::Profiles;
        return Ok(false);
    }
    if cmd == "vars" {
        app.active_view = View::Vars;
        return Ok(false);
    }
    if cmd == "defs" {
        app.active_view = View::Defs;
        return Ok(false);
    }
    if cmd == "parts" {
        app.active_view = View::Parts;
        return Ok(false);
    }
    if cmd == "items" {
        app.active_view = View::Items;
        return Ok(false);
    }
    if cmd == "preview" {
        app.active_view = View::Preview;
        return Ok(false);
    }
    if cmd == "export" {
        app.active_view = View::Export;
        return Ok(false);
    }
    if cmd == "themes" {
        app.active_view = View::Help;
        app.status = format!(
            "{} themes available (use :theme <name>)",
            daisyui_themes::THEMES.len()
        );
        return Ok(false);
    }
    if cmd == "theme" {
        app.status = "Usage: theme <name>".to_string();
        return Ok(false);
    }
    if cmd == "new-var" {
        if let Some(def) = create_custom_var_dialog(terminal)? {
            db::save_custom_var_def(&app.conn, &def)?;
            app.refresh_var_options()?;
            app.status = format!("saved var def: {}", def.name);
        }
        return Ok(false);
    }
    if cmd == "new-item" {
        if let Some(mut item) = create_or_edit_item_dialog(terminal, None)? {
            db::save_item(&app.conn, &mut item)?;
            app.refresh_items()?;
            app.status = format!("saved item: {}", item.value);
        }
        return Ok(false);
    }
    if cmd == "help" {
        app.active_view = View::Help;
        return Ok(false);
    }

    if let Some(rest) = cmd.strip_prefix("use ") {
        let name = rest.trim();
        if let Some((idx, _)) = app
            .profiles
            .iter()
            .enumerate()
            .find(|(_, p)| p.name == name)
        {
            app.active_profile_index = idx;
            app.status = format!("profile: {name}");
        } else {
            app.status = format!("profile not found: {name}");
        }
        return Ok(false);
    }

    if let Some(rest) = cmd.strip_prefix("theme ") {
        let name = rest.trim();
        if name.is_empty() {
            app.status = "Usage: theme <name>".to_string();
            return Ok(false);
        }
        if app.set_theme_preset(name, true)? {
            app.status = format!("theme: {}", app.theme.name);
        } else {
            app.status = format!("theme not found: {name}");
        }
        return Ok(false);
    }

    Ok(false)
}

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

    #[test]
    fn clamp_selection_clamps_to_last_index_and_zero_when_empty() {
        assert_eq!(clamp_selection(0, 14), 0);
        assert_eq!(clamp_selection(13, 14), 13);
        assert_eq!(clamp_selection(20, 14), 13);
        assert_eq!(clamp_selection(5, 0), 0);
    }

    #[test]
    fn suggestion_window_keeps_selection_visible() {
        // Selection inside the first window: no scroll.
        assert_eq!(suggestion_window(0, 14, 8), (0, 0));
        assert_eq!(suggestion_window(3, 14, 8), (0, 3));
        assert_eq!(suggestion_window(7, 14, 8), (0, 7));
        // Selection past the window scrolls so it stays visible.
        assert_eq!(suggestion_window(8, 14, 8), (1, 7));
        assert_eq!(suggestion_window(13, 14, 8), (6, 7));
        // Out-of-range selection is clamped first.
        assert_eq!(suggestion_window(50, 14, 8), (6, 7));
        // Fewer suggestions than the window: nothing scrolls.
        assert_eq!(suggestion_window(4, 5, 8), (0, 4));
        // Degenerate cases must not panic or select anything.
        assert_eq!(suggestion_window(3, 0, 8), (0, 0));
        assert_eq!(suggestion_window(3, 14, 0), (0, 0));
    }

    #[test]
    fn suggestion_window_start_plus_row_is_the_clamped_selection() {
        for len in 1..20usize {
            for sel in 0..25usize {
                for vis in 1..10usize {
                    let (start, row) = suggestion_window(sel, len, vis);
                    assert_eq!(
                        start + row,
                        clamp_selection(sel, len),
                        "sel={sel} len={len} vis={vis}"
                    );
                    assert!(
                        row < vis,
                        "highlight row must lie inside the window: sel={sel} len={len} vis={vis}"
                    );
                    assert!(
                        start + vis.min(len) <= len,
                        "window must not run past the list: sel={sel} len={len} vis={vis}"
                    );
                }
            }
        }
    }
}