use std::{
collections::BTreeMap,
fs::{self, File, create_dir_all},
io::Read,
path::PathBuf,
};
use anyhow::{Context, Result, anyhow};
use colored::Colorize;
use inquire::ui::{Attributes, RenderConfig, StyleSheet, Styled};
use serde::{Deserialize, Serialize};
use crate::{
client::{GQLClient, post_graphql},
commands::queries,
consts,
errors::RailwayError,
};
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde_with::skip_serializing_none]
#[serde(rename_all = "camelCase")]
pub struct LinkedProject {
pub project_path: String,
pub name: Option<String>,
pub project: String,
pub environment: String,
pub environment_name: Option<String>,
pub service: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde_with::skip_serializing_none]
#[serde(rename_all = "camelCase")]
pub struct RailwayUser {
pub token: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde_with::skip_serializing_none]
#[serde(rename_all = "camelCase")]
pub struct RailwayConfig {
pub projects: BTreeMap<String, LinkedProject>,
pub user: RailwayUser,
pub linked_functions: Option<Vec<(String, String)>>,
}
#[derive(Debug)]
#[serde_with::skip_serializing_none]
pub struct Configs {
pub root_config: RailwayConfig,
root_config_path: PathBuf,
}
pub enum Environment {
Production,
Staging,
Dev,
}
impl Configs {
pub fn new() -> Result<Self> {
let environment = Self::get_environment_id();
let root_config_partial_path = match environment {
Environment::Production => ".railway/config.json",
Environment::Staging => ".railway/config-staging.json",
Environment::Dev => ".railway/config-dev.json",
};
let home_dir = dirs::home_dir().context("Unable to get home directory")?;
let root_config_path = std::path::Path::new(&home_dir).join(root_config_partial_path);
if let Ok(mut file) = File::open(&root_config_path) {
let mut serialized_config = vec![];
file.read_to_end(&mut serialized_config)?;
let root_config: RailwayConfig = serde_json::from_slice(&serialized_config)
.unwrap_or_else(|_| {
eprintln!("{}", "Unable to parse config file, regenerating".yellow());
RailwayConfig {
projects: BTreeMap::new(),
user: RailwayUser { token: None },
linked_functions: None,
}
});
let config = Self {
root_config,
root_config_path,
};
return Ok(config);
}
Ok(Self {
root_config_path,
root_config: RailwayConfig {
projects: BTreeMap::new(),
user: RailwayUser { token: None },
linked_functions: None,
},
})
}
pub fn reset(&mut self) -> Result<()> {
self.root_config = RailwayConfig {
projects: BTreeMap::new(),
user: RailwayUser { token: None },
linked_functions: None,
};
Ok(())
}
pub fn get_railway_token() -> Option<String> {
std::env::var(consts::RAILWAY_TOKEN_ENV).ok()
}
pub fn get_railway_api_token() -> Option<String> {
std::env::var(consts::RAILWAY_API_TOKEN_ENV).ok()
}
pub fn env_is_ci() -> bool {
std::env::var("CI")
.map(|val| val.trim().to_lowercase() == "true")
.unwrap_or(false)
}
pub fn get_railway_auth_token(&self) -> Option<String> {
Self::get_railway_api_token().or(self
.root_config
.user
.token
.clone()
.filter(|t| !t.is_empty()))
}
pub fn get_environment_id() -> Environment {
match std::env::var("RAILWAY_ENV")
.map(|env| env.to_lowercase())
.as_deref()
{
Ok("production") => Environment::Production,
Ok("staging") => Environment::Staging,
Ok("dev") => Environment::Dev,
Ok("develop") => Environment::Dev,
_ => Environment::Production,
}
}
pub fn get_host(&self) -> &'static str {
match Self::get_environment_id() {
Environment::Production => "railway.com",
Environment::Staging => "railway-staging.com",
Environment::Dev => "railway-develop.com",
}
}
pub fn get_relay_host_path(&self) -> String {
format!("backboard.{}/relay", self.get_host())
}
pub fn get_backboard(&self) -> String {
format!("https://backboard.{}/graphql/v2", self.get_host())
}
pub fn get_current_directory(&self) -> Result<String> {
let current_dir = std::env::current_dir()?;
let path = current_dir
.to_str()
.context("Unable to get current working directory")?;
Ok(path.to_owned())
}
pub fn get_closest_linked_project_directory(&self) -> Result<String> {
if Self::get_railway_token().is_some() {
return self.get_current_directory();
}
let mut current_path = std::env::current_dir()?;
loop {
let path = current_path
.to_str()
.context("Unable to get current working directory")?
.to_owned();
let config = self.root_config.projects.get(&path);
if config.is_some() {
return Ok(path);
}
if !current_path.pop() {
break;
}
}
Err(RailwayError::NoLinkedProject.into())
}
pub async fn get_linked_project(&self) -> Result<LinkedProject> {
let path = self.get_closest_linked_project_directory()?;
let project = self.root_config.projects.get(&path);
if Self::get_railway_token().is_some() {
let vars = queries::project_token::Variables {};
let client = GQLClient::new_authorized(self)?;
let data =
post_graphql::<queries::ProjectToken, _>(&client, self.get_backboard(), vars)
.await?;
let project = LinkedProject {
project_path: self.get_current_directory()?,
name: Some(data.project_token.project.name),
project: data.project_token.project.id,
environment: data.project_token.environment.id,
environment_name: Some(data.project_token.environment.name),
service: project.cloned().and_then(|p| p.service),
};
return Ok(project);
}
project
.cloned()
.ok_or_else(|| RailwayError::NoLinkedProject.into())
}
pub fn get_linked_project_mut(&mut self) -> Result<&mut LinkedProject> {
let path = self.get_closest_linked_project_directory()?;
let project = self.root_config.projects.get_mut(&path);
project.ok_or_else(|| RailwayError::ProjectNotFound.into())
}
pub fn link_project(
&mut self,
project_id: String,
name: Option<String>,
environment_id: String,
environment_name: Option<String>,
) -> Result<()> {
let path = self.get_current_directory()?;
let project = LinkedProject {
project_path: path.clone(),
name,
project: project_id,
environment: environment_id,
environment_name,
service: None,
};
self.root_config.projects.insert(path, project);
Ok(())
}
pub fn link_service(&mut self, service_id: String) -> Result<()> {
let linked_project = self.get_linked_project_mut()?;
linked_project.service = Some(service_id);
Ok(())
}
pub fn unlink_project(&mut self) {
if let Ok(path) = self.get_closest_linked_project_directory() {
self.root_config.projects.remove(&path);
}
}
pub fn unlink_service(&mut self) -> Result<()> {
let linked_project = self.get_linked_project_mut()?;
linked_project.service = None;
Ok(())
}
pub fn link_function(&mut self, path: PathBuf, id: String) -> Result<()> {
let path = path
.canonicalize()?
.to_str()
.ok_or(anyhow!("couldn't convert string"))?
.to_owned();
let functions = self
.root_config
.linked_functions
.get_or_insert_with(Vec::new);
functions.retain(|(p, i)| (path != *p) && (id != *i));
functions.push((path, id));
Ok(())
}
pub fn get_function(&self, path: PathBuf) -> Result<Option<String>> {
let canonical_path = path.canonicalize()?;
let path_str = canonical_path
.to_str()
.ok_or(anyhow!("couldn't convert string"))?;
if let Some(functions) = &self.root_config.linked_functions {
Ok(functions.iter().find_map(|(p, id)| {
if p == path_str {
Some(id.clone())
} else {
None
}
}))
} else {
Ok(None)
}
}
pub fn get_functions_in_directory(&self, path: PathBuf) -> Result<Vec<(PathBuf, String)>> {
let canonical_path = path.canonicalize()?;
let path_str = canonical_path
.to_str()
.ok_or(anyhow!("couldn't convert string"))?;
if let Some(functions) = &self.root_config.linked_functions {
Ok(functions
.iter()
.filter_map(|(p, id)| {
if p.starts_with(path_str) {
let p = PathBuf::from(p);
if p.exists() {
Some((p, id.clone()))
} else {
None
}
} else {
None
}
})
.collect())
} else {
Ok(Vec::new())
}
}
pub fn unlink_function(&mut self, id: String) -> Result<()> {
if let Some(functions) = &mut self.root_config.linked_functions {
if let Some(pos) = functions.iter().position(|(_, i)| *i == id) {
functions.swap_remove(pos);
functions.retain(|(_, i)| *i != id);
}
}
Ok(())
}
pub fn get_render_config() -> RenderConfig<'static> {
RenderConfig::default_colored()
.with_help_message(
StyleSheet::new()
.with_fg(inquire::ui::Color::LightMagenta)
.with_attr(Attributes::BOLD),
)
.with_answer(
StyleSheet::new()
.with_fg(inquire::ui::Color::LightCyan)
.with_attr(Attributes::BOLD),
)
.with_prompt_prefix(
Styled::new("?").with_style_sheet(
StyleSheet::new()
.with_fg(inquire::ui::Color::LightCyan)
.with_attr(Attributes::BOLD),
),
)
.with_canceled_prompt_indicator(
Styled::new("<cancelled>").with_fg(inquire::ui::Color::DarkRed),
)
}
pub fn write(&self) -> Result<()> {
let config_dir = self
.root_config_path
.parent()
.context("Failed to get parent directory")?;
create_dir_all(config_dir)?;
let tmp_file_path = self.root_config_path.with_extension("tmp");
let tmp_file = File::options()
.create(true)
.write(true)
.truncate(true)
.open(&tmp_file_path)?;
serde_json::to_writer_pretty(&tmp_file, &self.root_config)?;
tmp_file.sync_all()?;
fs::rename(tmp_file_path.as_path(), &self.root_config_path)?;
Ok(())
}
}