diff --git a/Cargo.lock b/Cargo.lock index 09d0e2b..21d8bdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -527,6 +527,17 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" +[[package]] +name = "derive-new" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d150dea618e920167e5973d70ae6ece4385b7164e0d799fe7c122dd0a5d912ad" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.15", +] + [[package]] name = "digest" version = "0.10.6" @@ -1552,6 +1563,7 @@ dependencies = [ "colored", "console", "ctrlc", + "derive-new", "dirs", "futures 0.3.28", "graphql-ws-client", @@ -1575,6 +1587,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "structstruck", "strum", "synchronized-writer", "tar", @@ -2056,6 +2069,18 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "structstruck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a052ec87a2d9bdd3a35f85ec6a07a5ac0816e4190b1cbede9d67cccb47ea66d" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "venial", +] + [[package]] name = "strum" version = "0.26.1" @@ -2447,6 +2472,16 @@ dependencies = [ "serde", ] +[[package]] +name = "venial" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61584a325b16f97b5b25fcc852eb9550843a251057a5e3e5992d2376f3df4bb2" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "version_check" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index 7307034..5dbd7e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,3 +70,5 @@ which = "4.4.0" thiserror = "1.0.40" winapi = {version="0.3.9", features = ["minwindef", "tlhelp32", "processthreadsapi", "handleapi", "winerror"]} strum = { version = "0.26.1", features = ["derive"] } +structstruck = "0.4.1" +derive-new = "0.6.0" diff --git a/src/commands/link.rs b/src/commands/link.rs index 466d270..1efb67a 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -1,25 +1,17 @@ +use colored::*; use std::fmt::Display; -use anyhow::bail; -use is_terminal::IsTerminal; - use crate::{ controllers::project::get_project, errors::RailwayError, - util::prompt::{prompt_options, prompt_select, PromptService}, + queries::project::ProjectProject, + util::prompt::{fake_select, prompt_options}, }; use super::{ queries::{ - project::ProjectProjectEnvironmentsEdgesNode, - projects::{ - ProjectsProjectsEdgesNode, ProjectsProjectsEdgesNodeEnvironmentsEdgesNode, - ProjectsProjectsEdgesNodeServicesEdgesNode, - }, - user_projects::{ - UserProjectsMeProjectsEdgesNode, UserProjectsMeProjectsEdgesNodeEnvironmentsEdgesNode, - UserProjectsMeProjectsEdgesNodeServicesEdgesNode, UserProjectsMeTeamsEdgesNode, - }, + projects::ProjectsProjectsEdgesNode, + user_projects::{UserProjectsMeProjectsEdgesNode, UserProjectsMeTeamsEdgesNode}, }, *, }; @@ -27,14 +19,16 @@ use super::{ /// Associate existing project with current directory, may specify projectId as an argument #[derive(Parser)] pub struct Args { - #[clap(long)] + #[clap(long, short)] /// Environment to link to environment: Option, /// Project ID to link to + #[clap(long, short)] project_id: Option, - /// The service to link + /// The service to link to + #[clap(long, short)] service: Option, } @@ -42,156 +36,101 @@ pub async fn command(args: Args, _json: bool) -> Result<()> { let mut configs = Configs::new()?; let client = GQLClient::new_authorized(&configs)?; - if let Some(project_id) = args.project_id { - let project = get_project(&client, &configs, project_id.clone()).await?; - - let environment = if let Some(environment_name_or_id) = args.environment { - let environment = project - .environments - .edges - .iter() - .find(|env| { - env.node.name == environment_name_or_id || env.node.id == environment_name_or_id - }) - .context("Environment not found")?; - ProjectEnvironment(&environment.node) - } else if !std::io::stdout().is_terminal() { - bail!("Environment must be provided when not running in a terminal"); - } else if project.environments.edges.len() == 1 { - ProjectEnvironment(&project.environments.edges[0].node) - } else { - prompt_options( - "Select an environment", - project - .environments - .edges - .iter() - .map(|env| ProjectEnvironment(&env.node)) - .collect(), - )? - }; - - configs.link_project( - project.id.clone(), - Some(project.name.clone()), - environment.0.id.clone(), - Some(environment.0.name.clone()), - )?; - - let services: Vec<_> = project - .services - .edges - .iter() - .map(|s| PromptService(&s.node)) - .collect(); - - if let Some(service) = args.service { - let service = services - .iter() - .find(|s| s.0.id == service || s.0.name == service) - .ok_or_else(|| RailwayError::ServiceNotFound(service))?; - - configs.link_service(service.0.id.clone())?; - configs.write()?; - return Ok(()); - } else if !services.is_empty() { - let service = prompt_select("Select a service", services)?; - configs.link_service(service.0.id.clone())?; + let project = NormalisedProject::from(if let Some(project_id) = args.project_id { + let fetched_project = get_project(&client, &configs, project_id).await?; + // fake_select is used to mimic the user providing input in the terminal + // just for detail + if let Some(team) = fetched_project.clone().team { + fake_select("Select a team", &team.name); } - - configs.write()?; - return Ok(()); - } else if !std::io::stdout().is_terminal() { - bail!("Project must be provided when not running in a terminal"); - } - - let vars = queries::user_projects::Variables {}; - let me = post_graphql::(&client, configs.get_backboard(), vars) + fake_select("Select a project", fetched_project.name.as_str()); + Project(ProjectType::Fetched(fetched_project)) + } else { + let me = post_graphql::( + &client, + configs.get_backboard(), + queries::user_projects::Variables {}, + ) .await? .me; - - let mut personal_projects: Vec<_> = me - .projects - .edges - .iter() - .map(|project| &project.node) - .collect(); - personal_projects.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); - - let personal_project_names = personal_projects - .iter() - .map(|project| PersonalProject(project)) - .collect::>(); - - let teams: Vec<_> = me.teams.edges.iter().map(|team| &team.node).collect(); - - if teams.is_empty() { - let (project, environment, service) = prompt_personal_projects(personal_project_names)?; - configs.link_project( - project.0.id.clone(), - Some(project.0.name.clone()), - environment.0.id.clone(), - Some(environment.0.name.clone()), - )?; - - if let Some(service) = service { - configs.link_service(service.0.id.clone())?; - } - - configs.write()?; - return Ok(()); - } - - let mut team_names = teams - .iter() - .map(|team| Team::Team(team)) - .collect::>(); - team_names.insert(0, Team::Personal); - - let team = prompt_options("Select a team", team_names)?; - match team { - Team::Personal => { - let (project, environment, service) = prompt_personal_projects(personal_project_names)?; - configs.link_project( - project.0.id.clone(), - Some(project.0.name.clone()), - environment.0.id.clone(), - Some(environment.0.name.clone()), - )?; - - if let Some(service) = service { - configs.link_service(service.0.id.clone())?; - } - } - Team::Team(team) => { - let vars = queries::projects::Variables { - team_id: Some(team.id.clone()), - }; - - let projects = - post_graphql::(&client, configs.get_backboard(), vars) + let teams: Vec<_> = me.teams.edges.iter().map(|team| &team.node).collect(); + if teams.is_empty() { + // prompt projects on personal account + prompt_personal_projects(me)? + } else { + // prompt teams + let mut team_names = vec![Team::Personal]; + team_names.extend(teams.into_iter().map(Team::Team)); + match prompt_options("Select a team", team_names)? { + Team::Personal => prompt_personal_projects(me)?, + Team::Team(team) => { + let vars = queries::projects::Variables { + team_id: Some(team.id.clone()), + }; + let projects = post_graphql::( + &client, + configs.get_backboard(), + vars, + ) .await? .projects; - - let mut projects: Vec<_> = projects.edges.iter().map(|project| &project.node).collect(); - projects.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); - - let project_names = projects - .iter() - .map(|project| Project(project)) - .collect::>(); - let (project, environment, service) = prompt_team_projects(project_names)?; - configs.link_project( - project.0.id.clone(), - Some(project.0.name.clone()), - environment.0.id.clone(), - Some(environment.0.name.clone()), - )?; - - if let Some(service) = service { - configs.link_service(service.0.id.clone())?; + prompt_team_projects(projects)? + } } } + }); + + let environment = if let Some(environment) = args.environment { + let env = project.environments.iter().find(|e| { + (e.name.to_lowercase() == environment.to_lowercase()) + || (e.id.to_lowercase() == environment.to_lowercase()) + }); + if let Some(env) = env { + fake_select("Select an environment", env.name.as_str()); + env.clone() + } else { + return Err(RailwayError::EnvironmentNotFound(environment).into()); + } + } else { + prompt_options("Select an environment", project.environments)? + }; + let useful_services = project + .services + .iter() + .filter(|&a| { + a.service_instances + .iter() + .any(|instance| instance == &environment.id) + }) + .cloned() + .collect::>(); + let service = if !useful_services.is_empty() { + Some(if let Some(service) = args.service { + let service_norm = useful_services.iter().find(|s| { + (s.name.to_lowercase() == service.to_lowercase()) + || (s.id.to_lowercase() == service.to_lowercase()) + }); + if let Some(service) = service_norm { + fake_select("Select a service", &service.name); + service.clone() + } else { + return Err(RailwayError::ServiceNotFound(service).into()); + } + } else { + prompt_options("Select a service", useful_services)? + }) + } else { + None + }; + + configs.link_project( + project.id, + Some(project.name), + environment.id, + Some(environment.name), + )?; + if let Some(service) = service { + configs.link_service(service.id)?; } configs.write()?; @@ -200,126 +139,160 @@ pub async fn command(args: Args, _json: bool) -> Result<()> { } fn prompt_team_projects( - project_names: Vec, -) -> Result<(Project, Environment, Option)> { - if project_names.is_empty() { - return Err(RailwayError::NoProjects.into()); - } - - let project = prompt_options("Select a project", project_names)?; - let environments = project - .0 - .environments + projects: queries::projects::ProjectsProjects, +) -> Result { + let mut team_projects: Vec = projects .edges .iter() - .map(|env| Environment(&env.node)) + .cloned() + .map(|edge| edge.node) .collect(); - - let environment = prompt_options("Select an environment", environments)?; - let services: Vec<_> = project - .0 - .services - .edges + team_projects.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + let prompt_projects = team_projects .iter() - .map(|s| Service(&s.node)) - .collect(); - let service = if services.is_empty() { - None - } else { - Some(prompt_select("Select a service", services)?) - }; - - Ok((project, environment, service)) + .cloned() + .map(|project| Project(ProjectType::Team(project))) + .collect::>(); + prompt_options("Select a project", prompt_projects) } fn prompt_personal_projects( - personal_project_names: Vec, -) -> Result<( - PersonalProject, - PersonalEnvironment, - Option, -)> { - if personal_project_names.is_empty() { + me: queries::user_projects::UserProjectsMe, +) -> Result { + let mut personal_projects = me + .projects + .edges + .iter() + .map(|project| &project.node) + .collect::>(); + if personal_projects.is_empty() { return Err(RailwayError::NoProjects.into()); } - - let project = prompt_options("Select a project", personal_project_names)?; - let environments = project - .0 - .environments - .edges + personal_projects.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + let prompt_projects = personal_projects .iter() - .map(|env| PersonalEnvironment(&env.node)) - .collect(); - let environment = prompt_options("Select an environment", environments)?; - let services: Vec<_> = project - .0 - .services - .edges - .iter() - .map(|s| PersonalService(&s.node)) - .collect(); - - let service = if services.is_empty() { - None - } else { - Some(prompt_select("Select a service", services)?) - }; - - Ok((project, environment, service)) + .cloned() + .map(|project| Project(ProjectType::Personal(project.clone()))) + .collect::>(); + prompt_options("Select a project", prompt_projects) } -#[derive(Debug, Clone)] -struct PersonalService<'a>(&'a UserProjectsMeProjectsEdgesNodeServicesEdgesNode); - -impl<'a> Display for PersonalService<'a> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0.name) +structstruck::strike! { + #[strikethrough[derive(Debug, Clone, derive_new::new)]] + struct NormalisedProject { + /// Project ID + id: String, + /// Project name + name: String, + /// Project environments + environments: Vec, + /// Project services + services: Vec, + }> } } -#[derive(Debug, Clone)] -struct PersonalProject<'a>(&'a UserProjectsMeProjectsEdgesNode); - -impl<'a> Display for PersonalProject<'a> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0.name) - } -} - -#[derive(Debug, Clone)] -struct PersonalEnvironment<'a>(&'a UserProjectsMeProjectsEdgesNodeEnvironmentsEdgesNode); - -impl<'a> Display for PersonalEnvironment<'a> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0.name) - } -} - -#[derive(Debug, Clone)] -struct Project<'a>(&'a ProjectsProjectsEdgesNode); - -impl<'a> Display for Project<'a> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0.name) - } -} - -#[derive(Debug, Clone)] -struct Service<'a>(&'a ProjectsProjectsEdgesNodeServicesEdgesNode); - -impl<'a> Display for Service<'a> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0.name) - } -} - -#[derive(Debug, Clone)] -struct Environment<'a>(&'a ProjectsProjectsEdgesNodeEnvironmentsEdgesNode); - -impl<'a> Display for Environment<'a> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0.name) +// unfortunately, due to the graphql client returning 3 different types for some reason (despite them all being identical) +// we need to write 3 match arms to convert it to our normaliesd project type +impl From for NormalisedProject { + fn from(value: Project) -> Self { + match value.0 { + ProjectType::Fetched(fetched) => NormalisedProject::new( + fetched.id, + fetched.name, + fetched + .environments + .edges + .into_iter() + .map(|env| NormalisedEnvironment::new(env.node.id, env.node.name)) + .collect(), + fetched + .services + .edges + .into_iter() + .map(|service| { + NormalisedService::new( + service.node.id, + service.node.name, + service + .node + .service_instances + .edges + .into_iter() + .map(|instance| instance.node.environment_id) + .collect(), + ) + }) + .collect(), + ), + ProjectType::Personal(personal) => NormalisedProject::new( + personal.id, + personal.name, + personal + .environments + .edges + .into_iter() + .map(|env| NormalisedEnvironment::new(env.node.id, env.node.name)) + .collect(), + personal + .services + .edges + .into_iter() + .map(|service| { + NormalisedService::new( + service.node.id, + service.node.name, + service + .node + .service_instances + .edges + .into_iter() + .map(|instance| instance.node.environment_id) + .collect(), + ) + }) + .collect(), + ), + ProjectType::Team(team) => NormalisedProject::new( + team.id, + team.name, + team.environments + .edges + .into_iter() + .map(|env| NormalisedEnvironment::new(env.node.id, env.node.name)) + .collect(), + team.services + .edges + .into_iter() + .map(|service| { + NormalisedService::new( + service.node.id, + service.node.name, + service + .node + .service_instances + .edges + .into_iter() + .map(|instance| instance.node.environment_id) + .collect(), + ) + }) + .collect(), + ), + } } } @@ -339,10 +312,33 @@ impl<'a> Display for Team<'a> { } #[derive(Debug, Clone)] -struct ProjectEnvironment<'a>(&'a ProjectProjectEnvironmentsEdgesNode); +enum ProjectType { + Personal(UserProjectsMeProjectsEdgesNode), + Team(ProjectsProjectsEdgesNode), + Fetched(ProjectProject), +} -impl<'a> Display for ProjectEnvironment<'a> { +#[derive(Debug, Clone)] +struct Project(ProjectType); + +impl Display for Project { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0.name) + match &self.0 { + ProjectType::Personal(personal) => write!(f, "{}", personal.name), + ProjectType::Team(team_project) => write!(f, "{}", team_project.name), + ProjectType::Fetched(fetched) => write!(f, "{}", fetched.name), + } + } +} + +impl Display for NormalisedEnvironment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +impl Display for NormalisedService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) } } diff --git a/src/gql/queries/strings/Project.graphql b/src/gql/queries/strings/Project.graphql index c495f4d..adccc77 100644 --- a/src/gql/queries/strings/Project.graphql +++ b/src/gql/queries/strings/Project.graphql @@ -2,6 +2,9 @@ query Project($id: String!) { project(id: $id) { id name + team { + name + } environments { edges { node { diff --git a/src/gql/queries/strings/Projects.graphql b/src/gql/queries/strings/Projects.graphql index 45be00e..a7272e7 100644 --- a/src/gql/queries/strings/Projects.graphql +++ b/src/gql/queries/strings/Projects.graphql @@ -1,31 +1,38 @@ query Projects($teamId: String) { - projects(teamId: $teamId) { - edges { - node { - id - name - updatedAt - team { - id - name - } - environments { - edges { - node { - id - name - } - } - } - services { - edges { - node { - id - name - } - } - } - } - } - } + projects(teamId: $teamId) { + edges { + node { + id + name + updatedAt + team { + id + name + } + environments { + edges { + node { + id + name + } + } + } + services { + edges { + node { + id + name + serviceInstances { + edges { + node { + environmentId + } + } + } + } + } + } + } + } + } } diff --git a/src/gql/queries/strings/UserProjects.graphql b/src/gql/queries/strings/UserProjects.graphql index 6aef17b..66d2722 100644 --- a/src/gql/queries/strings/UserProjects.graphql +++ b/src/gql/queries/strings/UserProjects.graphql @@ -1,42 +1,49 @@ query UserProjects { - me { - projects { - edges { - node { - id - name - createdAt - updatedAt - team { - id - name - } - environments { - edges { - node { - id - name - } - } - } - services { - edges { - node { - id - name - } - } + me { + projects { + edges { + node { + id + name + createdAt + updatedAt + team { + id + name + } + environments { + edges { + node { + id + name + } + } + } + services { + edges { + node { + id + name + serviceInstances { + edges { + node { + environmentId } - } - } - } - teams { - edges { - node { - id - name - } - } - } - } -} + } + } + } + } + } + } + } + } + teams { + edges { + node { + id + name + } + } + } + } +} \ No newline at end of file diff --git a/src/util/prompt.rs b/src/util/prompt.rs index f7e420d..6c3e3d5 100644 --- a/src/util/prompt.rs +++ b/src/util/prompt.rs @@ -1,3 +1,4 @@ +use colored::*; use std::fmt::Display; use crate::commands::{queries::project::ProjectProjectServicesEdgesNode, Configs}; @@ -47,6 +48,10 @@ pub fn prompt_select(message: &str, options: Vec) -> Result { .context("Failed to prompt for select") } +pub fn fake_select(message: &str, selected: &str) { + println!("{} {} {}", ">".green(), message, selected.cyan().bold()); +} + #[derive(Debug, Clone)] pub struct PromptService<'a>(pub &'a ProjectProjectServicesEdgesNode);