use strum::IntoEnumIterator;
use super::{New as Args, changes::Change, *};
use crate::{
controllers::config::{
self, EnvironmentConfig, PatchEntry,
environment::{fetch_environment_config, prepare_config_for_duplication},
},
util::progress::create_spinner_if,
};
pub async fn new_environment(args: Args) -> Result<()> {
let mut configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let linked_project = configs.get_linked_project().await?;
let project = get_project(&client, &configs, linked_project.project.clone()).await?;
let project_id = project.id.clone();
let is_terminal = std::io::stdout().is_terminal();
let json = args.json;
let name = select_name_new(&args, is_terminal)?;
let duplicate_id = select_duplicate_id_new(&args, &project, is_terminal)?;
let vars = mutations::environment_create::Variables {
project_id: project.id.clone(),
name,
source_id: None,
apply_changes_in_background: None,
};
let spinner = create_spinner_if(!json, "Creating environment...".into());
let response =
post_graphql::<mutations::EnvironmentCreate, _>(&client, &configs.get_backboard(), vars)
.await?;
let env_id = response.environment_create.id.clone();
let env_name = response.environment_create.name.clone();
if let Some(ref source_env_id) = duplicate_id {
if let Some(ref s) = spinner {
s.set_message("Fetching source environment config...");
}
let source_config = fetch_environment_config(&client, &configs, source_env_id, true)
.await?
.config;
let source_config = prepare_config_for_duplication(source_config);
let override_config = edit_services_select(
&args,
&client,
&configs,
&project,
source_env_id.clone(),
source_config.clone(),
)
.await?;
let merged_config = merge_configs(source_config, override_config);
if !config::is_empty(&merged_config) {
if let Some(ref s) = spinner {
s.set_message("Applying configuration...");
}
apply_environment_config(&client, &configs, &env_id, merged_config).await?;
}
}
if json {
println!("{}", serde_json::json!({"id": env_id, "name": env_name}));
} else if let Some(spinner) = spinner {
spinner.finish_with_message(format!(
"{} {} {}",
"Environment".green(),
env_name.magenta().bold(),
"created!".green()
));
}
configs.link_project(
project_id,
linked_project.name.clone(),
env_id,
Some(env_name),
)?;
Ok(())
}
pub async fn edit_services_select(
args: &Args,
client: &reqwest::Client,
configs: &Configs,
project: &queries::project::ProjectProject,
environment_id: String,
exisiting_config: EnvironmentConfig,
) -> Result<EnvironmentConfig> {
let is_terminal = std::io::stdout().is_terminal();
let all_configs = args.config.get_all_service_configs();
let has_non_interactive = !all_configs.is_empty();
if has_non_interactive {
return parse_non_interactive_configs(&all_configs, project, &environment_id);
}
if !is_terminal {
return Ok(EnvironmentConfig::default());
}
parse_interactive_configs(
client,
configs,
project,
&environment_id,
Some(exisiting_config),
)
.await
}
pub fn parse_non_interactive_configs(
service_configs: &[String],
project: &queries::project::ProjectProject,
environment_id: &str,
) -> Result<EnvironmentConfig> {
let services = get_environment_services(project, environment_id)?;
let mut entries: Vec<PatchEntry> = Vec::new();
let mut configured_fields: std::collections::HashSet<String> = std::collections::HashSet::new();
for config_entry in service_configs.chunks(3) {
if config_entry.len() != 3 {
bail!(
"Invalid --service-config format, expected 3 values, got {}",
config_entry.len()
);
}
let service_input = &config_entry[0];
let path = &config_entry[1];
let value = &config_entry[2];
let Some(service) = services.iter().find(|s| {
s.node.service_id.to_lowercase() == service_input.to_lowercase()
|| s.node.service_name.to_lowercase() == service_input.to_lowercase()
}) else {
bail!("Service '{}' not found", service_input);
};
let service_id = &service.node.service_id;
let (normalized_path, json_value) = config::parse_service_value(path, value)?;
let display_field = get_config_display_field(&normalized_path);
configured_fields.insert(display_field);
let full_path = format!("services.{}.{}", service_id, normalized_path);
entries.push((full_path, json_value));
}
if !entries.is_empty() {
fake_select(
"Configuring",
&configured_fields.into_iter().collect::<Vec<_>>().join(", "),
);
}
config::build_config(entries)
}
fn get_config_display_field(path: &str) -> String {
let parts: Vec<&str> = path.split('.').collect();
match parts.first() {
Some(&"variables") => "variables".to_string(),
_ => parts.last().unwrap_or(&"config").to_string(),
}
}
pub async fn parse_interactive_configs(
client: &reqwest::Client,
configs: &Configs,
project: &queries::project::ProjectProject,
environment_id: &str,
existing_config: Option<EnvironmentConfig>,
) -> Result<EnvironmentConfig> {
let services = get_environment_services(project, environment_id)?;
let existing_config = match existing_config {
Some(config) => Some(config),
None => fetch_environment_config(client, configs, environment_id, false)
.await
.map(|r| r.config)
.ok(),
};
let prompt_services = services
.iter()
.map(|s| PromptServiceInstance(&s.node))
.collect::<Vec<_>>();
let selected_services = prompt_multi_options(
"What services do you want to configure? <enter to skip>",
prompt_services,
)?;
if selected_services.is_empty() {
return Ok(EnvironmentConfig::default());
}
let mut all_entries: Vec<PatchEntry> = Vec::new();
for service in selected_services {
let service_id = &service.0.service_id;
let service_name = &service.0.service_name;
let existing_service = existing_config
.as_ref()
.and_then(|c| c.services.get(service_id));
let selected_changes = prompt_multi_options(
&format!("What do you want to configure for {}?", service_name),
Change::iter().collect(),
)?;
for change in selected_changes {
let entries = change.parse_interactive(service_id, service_name, existing_service)?;
all_entries.extend(entries);
}
}
config::build_config(all_entries)
}
pub fn get_environment_services<'a>(
project: &'a queries::project::ProjectProject,
environment_id: &str,
) -> Result<&'a Vec<queries::project::ProjectProjectEnvironmentsEdgesNodeServiceInstancesEdges>> {
let environment = project
.environments
.edges
.iter()
.find(|env| env.node.id == environment_id)
.ok_or_else(|| anyhow::anyhow!("Environment not found: {}", environment_id))?;
Ok(&environment.node.service_instances.edges)
}
fn select_duplicate_id_new(
args: &Args,
project: &queries::project::ProjectProject,
is_terminal: bool,
) -> Result<Option<String>, anyhow::Error> {
let duplicate_id = if let Some(ref duplicate) = args.duplicate {
let env = project.environments.edges.iter().find(|env| {
(env.node.name.to_lowercase() == duplicate.to_lowercase())
|| (env.node.id == *duplicate)
});
if let Some(env) = env {
fake_select("Duplicate from", &env.node.name);
Some(env.node.id.clone())
} else {
bail!(RailwayError::EnvironmentNotFound(duplicate.clone()))
}
} else if is_terminal {
let environments = project
.environments
.edges
.iter()
.filter(|env| env.node.can_access)
.map(|env| Environment(&env.node))
.collect::<Vec<_>>();
prompt_options_skippable(
"Duplicate from <esc to create an empty environment>",
environments,
)?
.map(|e| e.0.id.clone())
} else {
None
};
Ok(duplicate_id)
}
fn select_name_new(args: &Args, is_terminal: bool) -> Result<String, anyhow::Error> {
let name = if let Some(name) = args.name.clone() {
fake_select("Environment name", name.as_str());
name
} else if is_terminal {
loop {
let q = prompt_text("Environment name")?;
if q.is_empty() {
eprintln!(
"{}: Environment name cannot be empty",
"Warn".yellow().bold()
);
continue;
} else {
break q;
}
}
} else {
bail!("Environment name must be specified when not running in a terminal");
};
Ok(name)
}
async fn apply_environment_config(
client: &reqwest::Client,
configs: &Configs,
environment_id: &str,
env_config: EnvironmentConfig,
) -> Result<()> {
let vars = mutations::environment_patch_commit::Variables {
environment_id: environment_id.to_string(),
patch: env_config,
commit_message: None,
};
post_graphql::<mutations::EnvironmentPatchCommit, _>(client, configs.get_backboard(), vars)
.await?;
Ok(())
}
fn merge_configs(base: EnvironmentConfig, overrides: EnvironmentConfig) -> EnvironmentConfig {
let base_json = serde_json::to_value(&base).unwrap_or_default();
let override_json = serde_json::to_value(&overrides).unwrap_or_default();
let merged = deep_merge_json(base_json, override_json);
serde_json::from_value(merged).unwrap_or(base)
}
fn deep_merge_json(left: serde_json::Value, right: serde_json::Value) -> serde_json::Value {
use serde_json::Value;
match (left, right) {
(Value::Object(mut left_map), Value::Object(right_map)) => {
for (key, right_val) in right_map {
let merged_val = if let Some(left_val) = left_map.remove(&key) {
deep_merge_json(left_val, right_val)
} else {
right_val
};
left_map.insert(key, merged_val);
}
Value::Object(left_map)
}
(_, right) => right,
}
}