use std::io::Write;
use anyhow::{Context, Result};
use clap::{Arg, ArgMatches, Command};
use termcolor::{ColorChoice, StandardStream, WriteColor};
use super::CliCommand;
use crate::{
constants::{ERROR_FAILED_TO_SEND_REQUEST, get_platform_management_api_url},
core::{
command::command,
env::{parse_env_file_items, EnvFileItem},
http_client,
validate::{require_auth, require_integration, require_manifest},
},
};
pub(crate) fn reconstruct_env_content(items: Vec<EnvFileItem>) -> String {
items
.into_iter()
.map(|item| match item {
EnvFileItem::SectionHeader(line) => format!("{}\n", line),
EnvFileItem::KeyValue(key, value) => {
if value.contains('\n') {
format!("{}=\"{}\"\n", key, value)
} else {
format!("{}={}\n", key, value)
}
}
})
.collect::<String>()
}
#[derive(Debug)]
pub(crate) struct PushCommand;
impl PushCommand {
pub(crate) fn new() -> Self {
Self {}
}
}
impl CliCommand for PushCommand {
fn command(&self) -> Command {
command(
"push",
"Push environment configuration to the forklaunch platform",
)
.arg(
Arg::new("region")
.short('r')
.long("region")
.required(true)
.help("Region (e.g. us-east-1)"),
)
.arg(
Arg::new("environment")
.short('e')
.long("environment")
.required(true)
.help("Environment name (e.g. production, staging)"),
)
.arg(
Arg::new("input")
.short('i')
.long("input")
.required(false)
.help("Input file path (defaults to <environment>.env)"),
)
.arg(
Arg::new("base_path")
.long("path")
.short('p')
.help("Path to application root (optional)"),
)
}
fn handler(&self, matches: &ArgMatches) -> Result<()> {
let _token = require_auth()?;
let (_app_root, manifest) = require_manifest(matches)?;
let app = require_integration(&manifest)?;
let region = matches
.get_one::<String>("region")
.expect("region is required");
let environment = matches
.get_one::<String>("environment")
.expect("environment is required");
let input = format!("{}.env", environment);
let input = matches.get_one::<String>("input").unwrap_or(&input);
let url = format!(
"{}/config/push",
get_platform_management_api_url()
);
let items = parse_env_file_items(std::path::Path::new(input))
.with_context(|| format!("Failed to parse file {}. Please check file permissions.", input))?;
let content = reconstruct_env_content(items);
let body = serde_json::json!({
"applicationId": app,
"region": region,
"environment": environment,
"content": content
});
let response =
http_client::post(&url, body).with_context(|| ERROR_FAILED_TO_SEND_REQUEST)?;
let mut stdout = StandardStream::stdout(ColorChoice::Always);
match response.status() {
reqwest::StatusCode::OK => {
log_ok!(stdout, "Config pushed successfully for {} ({})", environment, region);
}
_ => {
let err_text = response.text()?;
log_error!(stdout, "Failed to push config: {}", err_text);
anyhow::bail!("Failed to push config: {}", err_text);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
fn parse_and_reconstruct(path: &std::path::Path) -> String {
let items = parse_env_file_items(path).unwrap();
reconstruct_env_content(items)
}
fn extract_keys(content: &str) -> Vec<String> {
content
.lines()
.filter(|line| !line.starts_with('#') && !line.is_empty())
.filter_map(|line| line.split_once('=').map(|(k, _)| k.to_string()))
.collect()
}
#[test]
fn test_push_content_only_includes_present_vars() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("production.env");
fs::write(
&path,
"# application\n\
DB_HOST=db.example.com\n\
DB_PORT=5432\n\
GOOGLE_CLOUD_LOCATION=us-central1\n\
GOOGLE_CLOUD_API_KEY=abc123\n\
REDIS_URL=redis://localhost:6379\n",
)
.unwrap();
let content = parse_and_reconstruct(&path);
let keys = extract_keys(&content);
assert_eq!(keys.len(), 5);
assert!(keys.contains(&"GOOGLE_CLOUD_LOCATION".to_string()));
assert!(keys.contains(&"GOOGLE_CLOUD_API_KEY".to_string()));
fs::write(
&path,
"# application\n\
DB_HOST=db.example.com\n\
DB_PORT=5432\n\
REDIS_URL=redis://localhost:6379\n",
)
.unwrap();
let content = parse_and_reconstruct(&path);
let keys = extract_keys(&content);
assert_eq!(keys.len(), 3);
assert!(!keys.contains(&"GOOGLE_CLOUD_LOCATION".to_string()));
assert!(!keys.contains(&"GOOGLE_CLOUD_API_KEY".to_string()));
assert!(keys.contains(&"DB_HOST".to_string()));
assert!(keys.contains(&"DB_PORT".to_string()));
assert!(keys.contains(&"REDIS_URL".to_string()));
}
#[test]
fn test_push_content_preserves_section_headers() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("staging.env");
fs::write(
&path,
"# application\n\
APP_NAME=myapp\n\
# my-service (svc-123)\n\
DB_HOST=db.example.com\n\
# video-processor-worker (wkr-456)\n\
QUEUE_URL=sqs://queue\n",
)
.unwrap();
let content = parse_and_reconstruct(&path);
assert!(content.contains("# application\n"));
assert!(content.contains("# my-service (svc-123)\n"));
assert!(content.contains("# video-processor-worker (wkr-456)\n"));
assert!(content.contains("APP_NAME=myapp\n"));
assert!(content.contains("DB_HOST=db.example.com\n"));
assert!(content.contains("QUEUE_URL=sqs://queue\n"));
}
#[test]
fn test_push_content_multiline_values_are_quoted() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("production.env");
fs::write(
&path,
"SIMPLE=hello\n\
CERT=\"-----BEGIN CERTIFICATE-----\n\
abc123\n\
-----END CERTIFICATE-----\"\n",
)
.unwrap();
let content = parse_and_reconstruct(&path);
assert!(content.contains("SIMPLE=hello\n"));
assert!(content.contains("CERT=\"-----BEGIN CERTIFICATE-----\nabc123\n-----END CERTIFICATE-----\"\n"));
}
#[test]
fn test_push_content_empty_file_produces_empty_string() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("empty.env");
fs::write(&path, "").unwrap();
let content = parse_and_reconstruct(&path);
assert!(content.is_empty());
}
#[test]
fn test_push_content_nonexistent_file_produces_empty_string() {
let content = parse_and_reconstruct(std::path::Path::new("/nonexistent/file.env"));
assert!(content.is_empty());
}
#[test]
fn test_push_re_adding_deleted_var_includes_it() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("production.env");
fs::write(
&path,
"# application\nA=1\nB=2\nC=3\n",
)
.unwrap();
let keys = extract_keys(&parse_and_reconstruct(&path));
assert_eq!(keys.len(), 3);
fs::write(
&path,
"# application\nA=1\nC=3\n",
)
.unwrap();
let keys = extract_keys(&parse_and_reconstruct(&path));
assert_eq!(keys.len(), 2);
assert!(!keys.contains(&"B".to_string()));
fs::write(
&path,
"# application\nA=1\nB=new_value\nC=3\n",
)
.unwrap();
let keys = extract_keys(&parse_and_reconstruct(&path));
assert_eq!(keys.len(), 3);
assert!(keys.contains(&"B".to_string()));
let content = parse_and_reconstruct(&path);
assert!(content.contains("B=new_value\n"));
}
#[test]
fn test_push_json_body_structure() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("production.env");
fs::write(
&path,
"# application\nDB_HOST=localhost\n",
)
.unwrap();
let items = parse_env_file_items(&path).unwrap();
let content = reconstruct_env_content(items);
let body = serde_json::json!({
"applicationId": "app-123",
"region": "us-east-1",
"environment": "production",
"content": content
});
assert_eq!(body["applicationId"], "app-123");
assert_eq!(body["region"], "us-east-1");
assert_eq!(body["environment"], "production");
let content_str = body["content"].as_str().unwrap();
assert!(content_str.contains("# application\n"));
assert!(content_str.contains("DB_HOST=localhost\n"));
}
}