turnout 0.5.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 anyhow::{Result, bail};
use dialoguer::{Confirm, Input};

use crate::cli::ServerCommand;
use crate::model::{Server, parse_ssh, validate_name, validate_url};
use crate::{pick, secrets, store};

pub fn run(command: ServerCommand) -> Result<()> {
    match command {
        ServerCommand::Add {
            name,
            url,
            label,
            ssh,
            insecure,
        } => add(name, url, label, ssh, insecure),
        ServerCommand::List => list(),
        ServerCommand::Show { name } => show(&resolve(name, "Show server")?),
        ServerCommand::Edit {
            name,
            url,
            label,
            ssh,
            ssh_key,
            insecure,
            secure,
            deploy_paths,
            restart_cmds,
        } => {
            let name = resolve(name, "Edit server")?;
            edit(&name, url, label, ssh, ssh_key, insecure, secure, deploy_paths, restart_cmds)
        }
        ServerCommand::Remove { name, assume_yes } => {
            let name = resolve(name, "Remove server")?;
            remove(&name, assume_yes)
        }
    }
}

/// Take the server name as given, or let the user pick one.
fn resolve(name: Option<String>, prompt: &str) -> Result<String> {
    match name {
        Some(name) => Ok(name),
        None => pick::server(&store::load_servers()?, prompt),
    }
}

fn add(name: Option<String>, url: Option<String>, label: Option<String>, ssh: Option<String>, insecure: bool) -> Result<()> {
    let mut servers = store::load_servers()?;
    let wizard = name.is_none() || url.is_none();
    if wizard {
        pick::ensure_interactive("server name and --url are required")?;
    }

    let name = match name {
        Some(name) => name,
        None => Input::new()
            .with_prompt("Server name")
            .validate_with(|s: &String| validate_name(s).map_err(|e| e.to_string()))
            .interact_text()?,
    };
    validate_name(&name)?;
    if servers.iter().any(|s| s.name == name) {
        bail!("server '{name}' already exists");
    }

    let url = match url {
        Some(url) => url,
        None => Input::new()
            .with_prompt("Base URL (e.g. https://staging.example.com)")
            .validate_with(|s: &String| validate_url(s).map_err(|e| e.to_string()))
            .interact_text()?,
    };
    validate_url(&url)?;

    let label = match label {
        Some(label) => Some(label),
        None if wizard => {
            let answer: String = Input::new().with_prompt("Label (empty to skip)").allow_empty(true).interact_text()?;
            if answer.trim().is_empty() { None } else { Some(answer.trim().to_string()) }
        }
        None => None,
    };

    let ssh = match ssh {
        Some(spec) => Some(parse_ssh(&spec)?),
        None if wizard => {
            let answer: String = Input::new()
                .with_prompt("SSH access as user@host[:port] (empty to skip)")
                .allow_empty(true)
                .interact_text()?;
            if answer.trim().is_empty() { None } else { Some(parse_ssh(answer.trim())?) }
        }
        None => None,
    };

    let accept_invalid_certs = if wizard && !insecure && url.starts_with("https://") {
        Confirm::new()
            .with_prompt("Accept self-signed / invalid TLS certificates for this server?")
            .default(false)
            .interact()?
    } else {
        insecure
    };

    servers.push(Server {
        name: name.clone(),
        label,
        url,
        ssh,
        accept_invalid_certs,
        deploy: Default::default(),
    });
    servers.sort_by(|a, b| a.name.cmp(&b.name));
    store::save_servers(&servers)?;
    crate::journal::record("server.add", None, Some(&name), None);
    println!("Server '{name}' added.");
    Ok(())
}

fn list() -> Result<()> {
    let servers = store::load_servers()?;
    if servers.is_empty() {
        println!("No servers yet - run `turnout server add`.");
        return Ok(());
    }
    let width = servers.iter().map(|s| s.name.len()).max().unwrap_or(0);
    for server in servers {
        let label = server.label.map(|l| format!("  ({l})")).unwrap_or_default();
        println!("{:width$}  {}{}", server.name, server.url, label);
    }
    Ok(())
}

fn show(name: &str) -> Result<()> {
    let servers = store::load_servers()?;
    let server = servers.iter().find(|s| s.name == name).ok_or_else(|| unknown_server(name))?;
    println!("{}", server.name);
    if let Some(label) = &server.label {
        println!("  Label:    {label}");
    }
    println!("  URL:      {}", server.url);
    match &server.ssh {
        Some(ssh) => {
            let key = ssh.key.as_ref().map(|k| format!(" (key: {k})")).unwrap_or_default();
            println!("  SSH:      {}@{}:{}{key}", ssh.user, ssh.host, ssh.port);
        }
        None => println!("  SSH:      not configured"),
    }
    println!(
        "  TLS:      {}",
        if server.accept_invalid_certs {
            "accept invalid certificates"
        } else {
            "verify certificates"
        }
    );
    let credentials = store::load_credentials()?;
    let kinds: Vec<&str> = credentials.iter().filter(|c| c.server == name).map(|c| c.kind.as_str()).collect();
    if !kinds.is_empty() {
        println!("  Access:   {} (secrets in the OS keyring)", kinds.join(", "));
    }
    if !server.deploy.is_empty() {
        println!("  Deploy:");
        for (app, target) in &server.deploy {
            match &target.restart {
                Some(restart) => println!("    {app}: {} (then: {restart})", target.path),
                None => println!("    {app}: {}", target.path),
            }
        }
    }
    let apps = store::load_apps()?;
    let used_by: Vec<&str> = apps.iter().filter(|a| a.servers.iter().any(|s| s == name)).map(|a| a.name.as_str()).collect();
    if !used_by.is_empty() {
        println!("  Used by:  {}", used_by.join(", "));
    }
    Ok(())
}

#[allow(clippy::too_many_arguments)]
fn edit(
    name: &str,
    url: Option<String>,
    label: Option<String>,
    ssh: Option<String>,
    ssh_key: Option<String>,
    insecure: bool,
    secure: bool,
    deploy_paths: Vec<String>,
    restart_cmds: Vec<String>,
) -> Result<()> {
    let mut servers = store::load_servers()?;
    let index = servers.iter().position(|s| s.name == name).ok_or_else(|| unknown_server(name))?;
    let no_flags =
        url.is_none() && label.is_none() && ssh.is_none() && ssh_key.is_none() && !insecure && !secure && deploy_paths.is_empty() && restart_cmds.is_empty();

    if no_flags {
        pick::ensure_interactive("nothing to change: pass flags to edit non-interactively")?;
        let server = &mut servers[index];
        let url: String = Input::new()
            .with_prompt("Base URL")
            .default(server.url.clone())
            .validate_with(|s: &String| validate_url(s).map_err(|e| e.to_string()))
            .interact_text()?;
        server.url = url;
        let label: String = Input::new()
            .with_prompt("Label (empty to unset)")
            .default(server.label.clone().unwrap_or_default())
            .allow_empty(true)
            .interact_text()?;
        server.label = if label.trim().is_empty() { None } else { Some(label.trim().to_string()) };
        let current_ssh = server.ssh.as_ref().map(|s| format!("{}@{}:{}", s.user, s.host, s.port)).unwrap_or_default();
        let ssh: String = Input::new()
            .with_prompt("SSH as user@host[:port] (empty to unset)")
            .default(current_ssh)
            .allow_empty(true)
            .interact_text()?;
        server.ssh = if ssh.trim().is_empty() { None } else { Some(parse_ssh(ssh.trim())?) };
        server.accept_invalid_certs = Confirm::new()
            .with_prompt("Accept self-signed / invalid TLS certificates?")
            .default(server.accept_invalid_certs)
            .interact()?;
    } else {
        let server = &mut servers[index];
        if let Some(url) = url {
            validate_url(&url)?;
            server.url = url;
        }
        if label.is_some() {
            server.label = label;
        }
        if let Some(spec) = ssh {
            // Re-parsing replaces host/port/user; keep an already-configured key.
            let key = server.ssh.as_ref().and_then(|s| s.key.clone());
            let mut parsed = parse_ssh(&spec)?;
            parsed.key = key;
            server.ssh = Some(parsed);
        }
        if let Some(key) = ssh_key {
            let Some(ssh) = server.ssh.as_mut() else {
                bail!("set SSH access first: `turnout server edit {name} --ssh USER@HOST[:PORT]`");
            };
            ssh.key = if key.trim().is_empty() { None } else { Some(key) };
        }
        if insecure {
            server.accept_invalid_certs = true;
        }
        if secure {
            server.accept_invalid_certs = false;
        }
        let known_apps = store::load_apps()?;
        for spec in &deploy_paths {
            let (app, dir) = split_spec(spec)?;
            if dir.is_empty() {
                server.deploy.remove(app);
            } else {
                crate::model::validate_remote_path(dir)?;
                if !known_apps.iter().any(|a| a.name == app) {
                    bail!("no app named '{app}' - see `turnout app list`");
                }
                server
                    .deploy
                    .entry(app.to_string())
                    .or_insert_with(|| crate::model::DeployTarget {
                        path: String::new(),
                        restart: None,
                    })
                    .path = crate::model::normalize_remote_path(dir);
            }
        }
        for spec in &restart_cmds {
            let (app, cmd) = split_spec(spec)?;
            let Some(target) = server.deploy.get_mut(app) else {
                bail!("app '{app}' has no deploy path on '{name}' - set it first with --deploy-path {app}=DIR");
            };
            target.restart = if cmd.is_empty() { None } else { Some(cmd.to_string()) };
        }
    }
    store::save_servers(&servers)?;
    println!("Server '{name}' updated.");
    Ok(())
}

fn split_spec(spec: &str) -> Result<(&str, &str)> {
    let (app, value) = spec
        .split_once('=')
        .ok_or_else(|| anyhow::anyhow!("invalid value '{spec}': expected APP=VALUE"))?;
    if app.is_empty() {
        bail!("invalid value '{spec}': expected APP=VALUE");
    }
    Ok((app, value))
}

fn remove(name: &str, assume_yes: bool) -> Result<()> {
    let mut servers = store::load_servers()?;
    if !servers.iter().any(|s| s.name == name) {
        return Err(unknown_server(name));
    }
    let confirmed = pick::confirm_destructive(format!("Remove server '{name}' from the catalog?"), assume_yes)?;
    if !confirmed {
        println!("Cancelled.");
        return Ok(());
    }
    servers.retain(|s| s.name != name);
    store::save_servers(&servers)?;

    let mut apps = store::load_apps()?;
    let mut touched = Vec::new();
    for app in apps.iter_mut() {
        if app.servers.iter().any(|s| s == name) {
            app.servers.retain(|s| s != name);
            touched.push(app.name.clone());
        }
    }
    if !touched.is_empty() {
        store::save_apps(&apps)?;
        println!("Removed '{name}' from apps: {}.", touched.join(", "));
    }

    let mut credentials = store::load_credentials()?;
    let kinds: Vec<String> = credentials.iter().filter(|c| c.server == name).map(|c| c.kind.clone()).collect();
    if !kinds.is_empty() {
        for kind in &kinds {
            if let Err(err) = secrets::delete(name, kind) {
                eprintln!("warning: {err:#}");
            }
        }
        credentials.retain(|c| c.server != name);
        store::save_credentials(&credentials)?;
        println!("Removed stored access: {}.", kinds.join(", "));
    }
    crate::journal::record("server.remove", None, Some(name), None);
    println!("Server '{name}' removed.");
    Ok(())
}

fn unknown_server(name: &str) -> anyhow::Error {
    anyhow::anyhow!("no server named '{name}' - see `turnout server list`")
}