turnout 0.1.0

A developer's switchyard: point local apps at any backend stand, keep servers and secrets at hand, build and deploy from any directory
use std::io::{IsTerminal, Read};

use anyhow::{Context, Result, bail};
use dialoguer::{Confirm, Input, Password, Select};

use crate::cli::PassCommand;
use crate::model::Credential;
use crate::{secrets, store};

pub fn run(command: PassCommand) -> Result<()> {
    match command {
        PassCommand::Set { server, kind, login } => set(server, &kind, login),
        PassCommand::Copy { server, kind, login, show } => copy(&server, &kind, login, show),
        PassCommand::Show { server } => show(&server),
        PassCommand::List => list(),
        PassCommand::Remove { server, kind, assume_yes } => remove(&server, &kind, assume_yes),
    }
}

fn set(server: Option<String>, kind: &str, login: Option<String>) -> Result<()> {
    let servers = store::load_servers()?;
    if servers.is_empty() {
        bail!("no servers in the catalog yet - run `turnout server add` first");
    }
    let interactive = std::io::stdin().is_terminal();

    let server = match server {
        Some(server) => server,
        None => {
            if !interactive {
                bail!("server name is required outside an interactive terminal");
            }
            let names: Vec<&str> = servers.iter().map(|s| s.name.as_str()).collect();
            let picked = Select::new().with_prompt("Server").items(&names).default(0).interact()?;
            names[picked].to_string()
        }
    };
    if !servers.iter().any(|s| s.name == server) {
        bail!("no server named '{server}' - see `turnout server list`");
    }

    let mut credentials = store::load_credentials()?;
    let existing = credentials.iter().position(|c| c.server == server && c.kind == kind);

    let login = match login {
        Some(login) => login,
        None => {
            if !interactive {
                bail!("--login is required outside an interactive terminal");
            }
            let current = existing.map(|i| credentials[i].login.clone()).unwrap_or_default();
            let mut input = Input::new().with_prompt("Login");
            if !current.is_empty() {
                input = input.default(current);
            }
            input.interact_text()?
        }
    };

    let secret = if interactive {
        Password::new()
            .with_prompt(format!("Secret ({kind})"))
            .with_confirmation("Repeat to confirm", "Values do not match")
            .interact()?
    } else {
        // Scripted use: the secret arrives on stdin so it never lands in shell history.
        let mut buffer = String::new();
        std::io::stdin().read_to_string(&mut buffer).context("cannot read the secret from stdin")?;
        buffer.trim_end_matches(['\r', '\n']).to_string()
    };
    if secret.is_empty() {
        bail!("empty secret - nothing saved");
    }

    secrets::set(&server, kind, &secret)?;
    match existing {
        Some(index) => credentials[index].login = login,
        None => credentials.push(Credential {
            server: server.clone(),
            kind: kind.to_string(),
            login,
        }),
    }
    credentials.sort_by(|a, b| (a.server.as_str(), a.kind.as_str()).cmp(&(b.server.as_str(), b.kind.as_str())));
    store::save_credentials(&credentials)?;
    println!("Access to '{server}' ({kind}) saved.");
    Ok(())
}

fn copy(server: &str, kind: &str, login: bool, show: bool) -> Result<()> {
    let credentials = store::load_credentials()?;
    let credential = credentials
        .iter()
        .find(|c| c.server == server && c.kind == kind)
        .ok_or_else(|| anyhow::anyhow!("no access saved for '{server}' ({kind}) - run `turnout pass set {server}`"))?;
    let (value, what) = if login {
        (credential.login.clone(), "Login")
    } else {
        (secrets::get(server, kind)?, "Secret")
    };
    if show {
        println!("{value}");
        return Ok(());
    }
    arboard::Clipboard::new()
        .and_then(|mut clipboard| clipboard.set_text(value))
        .context("cannot access the clipboard (use --show to print instead)")?;
    println!("{what} for '{server}' ({kind}) copied to the clipboard.");
    Ok(())
}

fn show(server: &str) -> Result<()> {
    let credentials = store::load_credentials()?;
    let matching: Vec<&Credential> = credentials.iter().filter(|c| c.server == server).collect();
    if matching.is_empty() {
        println!("No access saved for '{server}'.");
        return Ok(());
    }
    println!("{server}");
    for credential in matching {
        println!("  {:10} login {}", credential.kind, credential.login);
    }
    println!("Secrets are stored in the OS keyring; copy with `turnout pass copy {server}`.");
    Ok(())
}

fn list() -> Result<()> {
    let credentials = store::load_credentials()?;
    if credentials.is_empty() {
        println!("No access saved yet - run `turnout pass set`.");
        return Ok(());
    }
    let width = credentials.iter().map(|c| c.server.len()).max().unwrap_or(0);
    for credential in credentials {
        println!("{:width$}  {:10} login {}", credential.server, credential.kind, credential.login);
    }
    Ok(())
}

fn remove(server: &str, kind: &str, assume_yes: bool) -> Result<()> {
    let mut credentials = store::load_credentials()?;
    if !credentials.iter().any(|c| c.server == server && c.kind == kind) {
        bail!("no access saved for '{server}' ({kind})");
    }
    let confirmed = assume_yes
        || Confirm::new()
            .with_prompt(format!("Remove access to '{server}' ({kind})?"))
            .default(false)
            .interact()?;
    if !confirmed {
        println!("Cancelled.");
        return Ok(());
    }
    secrets::delete(server, kind)?;
    credentials.retain(|c| !(c.server == server && c.kind == kind));
    store::save_credentials(&credentials)?;
    println!("Access to '{server}' ({kind}) removed.");
    Ok(())
}