railwayapp 4.27.6

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

use crate::{
    controllers::{
        database::DatabaseType, project::ensure_project_and_environment_exist, variables::Variable,
    },
    util::{
        progress::create_spinner_if,
        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>,

    /// Output in JSON format
    #[clap(long)]
    json: 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 {
        if !std::io::stdout().is_terminal() {
            bail!(
                "Service type required in non-interactive mode. Use one of:\n  \
                 --database <type>  Add a database (postgres, mysql, redis, mongo)\n  \
                 --service          Create an empty service\n  \
                 --repo <repo>      Create a service from a GitHub repo\n  \
                 --image <image>    Create a service from a Docker image"
            );
        }
        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) => {
            let is_single_db = databases.len() == 1;
            for db in databases {
                if verbose {
                    println!("iterating through databases to add: {:?}", db)
                }
                deploy::fetch_and_create(
                    &client,
                    &mut configs,
                    db.to_slug().to_string(),
                    &linked_project,
                    &HashMap::new(),
                    verbose,
                    args.json,
                    deploy::FetchAndCreateOptions {
                        should_link: is_single_db,
                    },
                )
                .await?;
                if verbose {
                    println!("successfully created {:?}", db)
                }
            }
        }
        CreateKind::DockerImage {
            image,
            variables,
            name,
        } => {
            create_service(
                name,
                &linked_project,
                &client,
                &mut configs,
                None,
                Some(image),
                variables,
                verbose,
                args.json,
            )
            .await?;
        }
        CreateKind::GithubRepo {
            repo,
            variables,
            name,
        } => {
            create_service(
                name,
                &linked_project,
                &client,
                &mut configs,
                Some(repo),
                None,
                variables,
                verbose,
                args.json,
            )
            .await?;
        }
        CreateKind::EmptyService { name, variables } => {
            create_service(
                name,
                &linked_project,
                &client,
                &mut configs,
                None,
                None,
                variables,
                verbose,
                args.json,
            )
            .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(None)?
                .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,
    json: bool,
) -> Result<(), anyhow::Error> {
    let spinner = create_spinner_if(!json, "Creating service...".into());
    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.clone())?;
    configs.write()?;
    if json {
        println!(
            "{}",
            serde_json::json!({"id": s.service_create.id, "name": s.service_create.name})
        );
    } else if let Some(spinner) = spinner {
        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,
    },
}