cotis-cli 0.1.0-alpha

Plugin host for Cotis build, check, and run routines
Documentation
use std::io::{self, IsTerminal};

use dialoguer::{Confirm, Input, Select, theme::ColorfulTheme};
use directories::ProjectDirs;

use cotis_cli::routines::{installed_routine_names, installed_versions_for_routine};

pub fn require_interactive() -> Result<(), String> {
    if io::stdin().is_terminal() && io::stdout().is_terminal() {
        Ok(())
    } else {
        Err(
            "A TTY is required to prompt for missing arguments; re-run with full arguments (see --help)."
                .to_string(),
        )
    }
}

/// Full `path:` / `crate:` / `gh:` spec string.
pub fn prompt_install_spec() -> Result<String, String> {
    require_interactive()?;
    let theme = ColorfulTheme::default();
    let kind = Select::with_theme(&theme)
        .with_prompt("Install from")
        .items([
            "Local Cargo project (path:...)",
            "crates.io (crate:...)",
            "GitHub release (gh:...)",
        ])
        .default(0)
        .interact()
        .map_err(|e| e.to_string())?;

    match kind {
        0 => {
            let path: String = Input::with_theme(&theme)
                .with_prompt("Path to the Cargo project directory")
                .interact_text()
                .map_err(|e| e.to_string())?;
            let path = path.trim();
            if path.is_empty() {
                return Err("Path must not be empty.".to_string());
            }
            Ok(format!("path:{path}"))
        }
        1 => {
            let name: String = Input::with_theme(&theme)
                .with_prompt("Crate name")
                .interact_text()
                .map_err(|e| e.to_string())?;
            let version: String = Input::with_theme(&theme)
                .with_prompt("Crate version")
                .interact_text()
                .map_err(|e| e.to_string())?;
            let name = name.trim();
            let version = version.trim();
            if name.is_empty() || version.is_empty() {
                return Err("Crate name and version are required.".to_string());
            }
            Ok(format!("crate:{name}@{version}"))
        }
        2 => {
            let repo: String = Input::with_theme(&theme)
                .with_prompt("Repository (owner/repo)")
                .interact_text()
                .map_err(|e| e.to_string())?;
            let tag: String = Input::with_theme(&theme)
                .with_prompt("Release tag, or \"latest\"")
                .interact_text()
                .map_err(|e| e.to_string())?;
            let repo = repo.trim();
            let tag = tag.trim();
            if repo.is_empty() || tag.is_empty() {
                return Err("owner/repo and tag are required.".to_string());
            }
            Ok(format!("gh:{repo}@{tag}"))
        }
        _ => Err("Invalid selection.".to_string()),
    }
}

/// Optional install alias; only used when the spec was chosen interactively.
pub fn prompt_optional_alias() -> Result<Option<String>, String> {
    require_interactive()?;
    let theme = ColorfulTheme::default();
    let use_alias = Confirm::with_theme(&theme)
        .with_prompt("Install under a different routine name (alias)?")
        .default(false)
        .interact()
        .map_err(|e| e.to_string())?;
    if !use_alias {
        return Ok(None);
    }
    let alias: String = Input::with_theme(&theme)
        .with_prompt("Routine name alias")
        .interact_text()
        .map_err(|e| e.to_string())?;
    let alias = alias.trim().to_string();
    if alias.is_empty() {
        Ok(None)
    } else {
        Ok(Some(alias))
    }
}

pub fn prompt_routine(proj_dirs: &ProjectDirs, prompt: &str) -> Result<String, String> {
    require_interactive()?;
    let names = installed_routine_names(proj_dirs);
    if names.is_empty() {
        return Err("No routines are installed yet. Use `cotis-cli install` first.".to_string());
    }
    let theme = ColorfulTheme::default();
    let items: Vec<&str> = names.iter().map(String::as_str).collect();
    let i = Select::with_theme(&theme)
        .with_prompt(prompt)
        .items(&items)
        .default(0)
        .interact()
        .map_err(|e| e.to_string())?;
    Ok(names[i].clone())
}

pub fn prompt_routine_spec(proj_dirs: &ProjectDirs, prompt: &str) -> Result<String, String> {
    require_interactive()?;
    let name = prompt_routine(proj_dirs, prompt)?;
    let versions = installed_versions_for_routine(proj_dirs, &name);
    if versions.is_empty() {
        return Err(format!("Routine `{name}` has no version directories."));
    }
    if versions.len() == 1 {
        return Ok(format!("{}@{}", name, versions[0]));
    }
    let theme = ColorfulTheme::default();
    let labels: Vec<String> = versions.iter().map(|v| format!("{name}@{v}")).collect();
    let items: Vec<&str> = labels.iter().map(String::as_str).collect();
    let i = Select::with_theme(&theme)
        .with_prompt("Which version?")
        .items(&items)
        .default(labels.len().saturating_sub(1))
        .interact()
        .map_err(|e| e.to_string())?;
    Ok(labels[i].clone())
}