railwayapp 4.15.0

Interact with Railway via CLI
use anyhow::bail;
use is_terminal::IsTerminal;
use std::{
    collections::{BTreeMap, HashMap},
    time::Duration,
};
use strum::{Display, EnumIs, EnumIter, IntoEnumIterator};

use crate::{
    consts::TICK_STRING,
    controllers::{
        database::DatabaseType, project::ensure_project_and_environment_exist, variables::Variable,
    },
    util::prompt::{
        self, fake_select, prompt_multi_options, prompt_options, prompt_text,
        prompt_text_with_placeholder_disappear, prompt_text_with_placeholder_if_blank,
    },
};

use super::*;

/// Add a service to your project
#[derive(Parser)]
pub struct Args {
    /// The name of the database to add
    #[arg(short, long, value_enum)]
    database: Vec<DatabaseType>,

    /// The name of the service to create (leave blank for randomly generated)
    #[clap(short, long)]
    service: Option<Option<String>>,

    /// The repo to link to the service
    #[clap(short, long)]
    repo: Option<String>,

    /// The docker image to link to the service
    #[clap(short, long)]
    image: Option<String>,

    /// The "{key}={value}" environment variable pair to set the service variables.
    /// Example:
    ///
    /// railway add --service --variables "MY_SPECIAL_ENV_VAR=1" --variables "BACKEND_PORT=3000"
    #[clap(short, long)]
    variables: Vec<Variable>,

    /// Verbose logging
    #[clap(long, action = clap::ArgAction::Set, num_args = 0..=1, default_missing_value = "true")]
    verbose: Option<bool>,
}

pub async fn command(args: Args) -> Result<()> {
    let mut configs = Configs::new()?;
    let client = GQLClient::new_authorized(&configs)?;
    let linked_project = configs.get_linked_project().await?;
    let verbose = args.verbose.unwrap_or(false);

    ensure_project_and_environment_exist(&client, &configs, &linked_project).await?;
    if verbose {
        println!("project and environment exist in linked project exist")
    }
    let type_of_create = if !args.database.is_empty() {
        fake_select("What do you need?", "Database");
        CreateKind::Database(args.database)
    } else if args.repo.is_some() {
        fake_select("What do you need?", "GitHub Repo");
        CreateKind::GithubRepo {
            repo: prompt_repo(args.repo)?,
            variables: prompt_variables(args.variables)?,
            name: prompt_name(args.service)?,
        }
    } else if args.image.is_some() {
        fake_select("What do you need?", "Docker Image");
        CreateKind::DockerImage {
            image: prompt_image(args.image)?,
            variables: prompt_variables(args.variables)?,
            name: prompt_name(args.service)?,
        }
    } else if args.service.is_some() {
        fake_select("What do you need?", "Empty Service");
        CreateKind::EmptyService {
            name: prompt_name(args.service)?,
            variables: prompt_variables(args.variables)?,
        }
    } else {
        let need = prompt_options("What do you need?", CreateKind::iter().collect())?;
        match need {
            CreateKind::Database(_) => CreateKind::Database(prompt_database()?),
            CreateKind::EmptyService { .. } => CreateKind::EmptyService {
                name: prompt_name(args.service)?,
                variables: prompt_variables(args.variables)?,
            },
            CreateKind::GithubRepo { .. } => {
                let repo = prompt_repo(args.repo)?;
                let variables = prompt_variables(args.variables)?;
                CreateKind::GithubRepo {
                    repo,
                    variables,
                    name: prompt_name(args.service)?,
                }
            }
            CreateKind::DockerImage { .. } => {
                let image = prompt_image(args.image)?;
                let variables = prompt_variables(args.variables)?;
                CreateKind::DockerImage {
                    image,
                    variables,
                    name: prompt_name(args.service)?,
                }
            }
        }
    };
    if verbose {
        println!("{:?}", type_of_create);
    }
    match type_of_create {
        CreateKind::Database(databases) => {
            for db in databases {
                if verbose {
                    println!("iterating through databases to add: {:?}", db)
                }
                deploy::fetch_and_create(
                    &client,
                    &configs,
                    db.to_slug().to_string(),
                    &linked_project,
                    &HashMap::new(),
                    verbose,
                )
                .await?;
                if verbose {
                    println!("succesfully created {:?}", db)
                }
            }
        }
        CreateKind::DockerImage {
            image,
            variables,
            name,
        } => {
            create_service(
                name,
                &linked_project,
                &client,
                &mut configs,
                None,
                Some(image),
                variables,
                verbose,
            )
            .await?;
        }
        CreateKind::GithubRepo {
            repo,
            variables,
            name,
        } => {
            create_service(
                name,
                &linked_project,
                &client,
                &mut configs,
                Some(repo),
                None,
                variables,
                verbose,
            )
            .await?;
        }
        CreateKind::EmptyService { name, variables } => {
            create_service(
                name,
                &linked_project,
                &client,
                &mut configs,
                None,
                None,
                variables,
                verbose,
            )
            .await?;
        }
    }
    Ok(())
}

fn prompt_database() -> Result<Vec<DatabaseType>, anyhow::Error> {
    if !std::io::stdout().is_terminal() {
        bail!("No database specified");
    }

    let databases =
        prompt_multi_options("Select databases to add", DatabaseType::iter().collect())?;
    if databases.is_empty() {
        bail!("Please select at least one database to add");
    }

    Ok(databases)
}

fn prompt_repo(repo: Option<String>) -> Result<String> {
    if let Some(repo) = repo {
        fake_select("Enter a repo", &repo);
        return Ok(repo);
    }

    prompt_text_with_placeholder_disappear("Enter a repo", "<user/org>/<repo name>")
}

fn prompt_image(image: Option<String>) -> Result<String> {
    if let Some(image) = image {
        fake_select("Enter an image", &image);
        return Ok(image);
    }
    prompt_text("Enter an image")
}

fn prompt_name(service: Option<Option<String>>) -> Result<Option<String>> {
    if let Some(name) = service {
        if let Some(name) = name {
            fake_select("Enter a service name", &name);
            Ok(Some(name))
        } else {
            fake_select("Enter a service name", "<randomly generated>");
            Ok(None)
        }
    } else if std::io::stdout().is_terminal() {
        Ok(Some(prompt_text_with_placeholder_if_blank(
            "Enter a service name",
            "<leave blank for randomly generated>",
            "<randomly generated>",
        )?)
        .filter(|s| !s.trim().is_empty()))
    } else {
        fake_select("Enter a service name", "<randomly generated>");
        Ok(None)
    }
}

fn prompt_variables(variables: Vec<Variable>) -> Result<Option<BTreeMap<String, String>>> {
    if !std::io::stdout().is_terminal() && variables.is_empty() {
        fake_select("Enter a variable", "");
        return Ok(None);
    }
    if variables.is_empty() {
        return Ok(Some(
            prompt::prompt_variables()?
                .into_iter()
                .map(|v| (v.key, v.value))
                .collect(),
        ));
    }
    Ok(Some(
        variables
            .into_iter()
            .map(|v| {
                fake_select("Enter a variable", &format!("{}={}", v.key, v.value));
                (v.key, v.value)
            })
            .collect(),
    ))
}

type Variables = Option<BTreeMap<String, String>>;

#[allow(clippy::too_many_arguments)]
async fn create_service(
    service: Option<String>,
    linked_project: &LinkedProject,
    client: &reqwest::Client,
    configs: &mut Configs,
    repo: Option<String>,
    image: Option<String>,
    variables: Variables,
    verbose: bool,
) -> Result<(), anyhow::Error> {
    let spinner = indicatif::ProgressBar::new_spinner()
        .with_style(
            indicatif::ProgressStyle::default_spinner()
                .tick_chars(TICK_STRING)
                .template("{spinner:.green} {msg}")?,
        )
        .with_message("Creating service...");
    spinner.enable_steady_tick(Duration::from_millis(100));
    let source = mutations::service_create::ServiceSourceInput { repo, image };
    let branch = if let Some(repo) = &source.repo {
        if verbose {
            println!("fetching branch for github repo {repo}")
        }
        let repos = post_graphql::<queries::GitHubRepos, _>(
            client,
            &configs.get_backboard(),
            queries::git_hub_repos::Variables {},
        )
        .await?
        .github_repos;
        let repo = repos
            .iter()
            .find(|r| r.full_name == *repo)
            .ok_or(anyhow::anyhow!("repo not found"))?;
        Some(repo.default_branch.clone())
    } else {
        None
    };
    let vars = mutations::service_create::Variables {
        name: service,
        project_id: linked_project.project.clone(),
        environment_id: linked_project.environment.clone(),
        source: Some(source),
        variables,
        branch,
    };
    if verbose {
        println!("creating service");
    }
    let s =
        post_graphql::<mutations::ServiceCreate, _>(client, &configs.get_backboard(), vars).await?;
    configs.link_service(s.service_create.id)?;
    configs.write()?;
    spinner.finish_with_message(format!(
        "Successfully created the service \"{}\" and linked to it",
        s.service_create.name.blue()
    ));
    Ok(())
}

#[derive(Debug, Clone, EnumIter, Display, EnumIs)]
enum CreateKind {
    #[strum(to_string = "GitHub Repo")]
    GithubRepo {
        repo: String,
        variables: Variables,
        name: Option<String>,
    },
    #[strum(to_string = "Database")]
    Database(Vec<DatabaseType>),
    #[strum(to_string = "Docker Image")]
    DockerImage {
        image: String,
        variables: Variables,
        name: Option<String>,
    },
    #[strum(to_string = "Empty Service")]
    EmptyService {
        name: Option<String>,
        variables: Variables,
    },
}