auberge 0.6.15

CLI tool for managing self-hosted infrastructure with Ansible
use dialoguer::{MultiSelect, Select, theme::ColorfulTheme};
use eyre::Result;
use skim::prelude::*;
use std::io::{Cursor, IsTerminal, Write};

pub fn has_skim_support() -> bool {
    std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}

fn select_with_skim(items: &[String], prompt: &str) -> Option<String> {
    if items.is_empty() {
        return None;
    }

    let prompt_str = format!("{}> ", prompt);

    let options = SkimOptionsBuilder::default()
        .prompt(Some(&prompt_str))
        .height(Some("40%"))
        .multi(false)
        .reverse(true)
        .build()
        .ok()?;

    let input = items.join("\n");
    let item_reader = SkimItemReader::default();
    let items = item_reader.of_bufread(Cursor::new(input));

    let output = Skim::run_with(&options, Some(items))?;

    // Skim leaves terminal background color set after exit.
    // \x1b[0m resets all SGR attributes (fixes colored bands on subsequent lines).
    // \x1b[J clears from cursor to end of screen (removes phantom blank lines).
    let mut stderr = std::io::stderr().lock();
    let _ = stderr.write_all(b"\x1b[0m\x1b[J");
    let _ = stderr.flush();

    if output.is_abort {
        return None;
    }

    output
        .selected_items
        .first()
        .map(|item| item.output().to_string())
}

fn select_with_dialoguer(items: &[String], prompt: &str) -> Option<String> {
    if items.is_empty() {
        return None;
    }

    let selection = Select::with_theme(&ColorfulTheme::default())
        .with_prompt(prompt)
        .items(items)
        .default(0)
        .interact_opt()
        .ok()
        .flatten()?;

    Some(items[selection].clone())
}

pub fn select(items: &[String], prompt: &str) -> Option<String> {
    if items.is_empty() {
        return None;
    }

    if !has_skim_support() {
        if items.len() == 1 {
            return Some(items[0].clone());
        }
        return None;
    }

    select_with_skim(items, prompt).or_else(|| select_with_dialoguer(items, prompt))
}

pub fn select_item<T, F>(items: &[T], display_fn: F, prompt: &str) -> Result<Option<T>>
where
    T: Clone,
    F: Fn(&T) -> String,
{
    if items.is_empty() {
        return Ok(None);
    }

    let display_items: Vec<String> = items.iter().map(&display_fn).collect();
    let selected_display = select(&display_items, prompt);

    match selected_display {
        Some(display) => {
            let idx = display_items.iter().position(|d| d == &display);
            Ok(idx.map(|i| items[i].clone()))
        }
        None => Ok(None),
    }
}

fn select_multi_with_skim(items: &[String], prompt: &str) -> Option<Vec<String>> {
    if items.is_empty() {
        return None;
    }

    let prompt_str = format!("{}> ", prompt);

    let options = SkimOptionsBuilder::default()
        .prompt(Some(&prompt_str))
        .height(Some("40%"))
        .multi(true)
        .reverse(true)
        .build()
        .ok()?;

    let input = items.join("\n");
    let item_reader = SkimItemReader::default();
    let items = item_reader.of_bufread(Cursor::new(input));

    let output = Skim::run_with(&options, Some(items))?;

    let mut stderr = std::io::stderr().lock();
    let _ = stderr.write_all(b"\x1b[0m\x1b[J");
    let _ = stderr.flush();

    if output.is_abort {
        return None;
    }

    let selected: Vec<String> = output
        .selected_items
        .iter()
        .map(|item| item.output().to_string())
        .collect();

    if selected.is_empty() {
        None
    } else {
        Some(selected)
    }
}

fn select_multi_with_dialoguer(items: &[String], prompt: &str) -> Option<Vec<String>> {
    if items.is_empty() {
        return None;
    }

    let selections = MultiSelect::with_theme(&ColorfulTheme::default())
        .with_prompt(prompt)
        .items(items)
        .interact_opt()
        .ok()
        .flatten()?;

    if selections.is_empty() {
        return None;
    }

    Some(selections.iter().map(|&i| items[i].clone()).collect())
}

pub fn select_multi(items: &[String], prompt: &str) -> Option<Vec<String>> {
    if items.is_empty() {
        return None;
    }

    if !has_skim_support() {
        if items.len() == 1 {
            return Some(vec![items[0].clone()]);
        }
        return None;
    }

    if let Some(result) = select_multi_with_skim(items, prompt) {
        return Some(result);
    }

    select_multi_with_dialoguer(items, prompt)
}