use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Input, Select};
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use crate::utils::parse_env_file as parse_shared_env_file;
#[derive(Debug, Clone)]
pub struct PreparedPushEnvInput {
pub display_source: String,
pub pushable: HashMap<String, String>,
pub skipped_empty: Vec<String>,
}
#[derive(Debug, Clone)]
struct EnvSource {
label: String,
path: PathBuf,
variables: HashMap<String, String>,
}
#[derive(Debug, Clone)]
struct ResolvedEnvSource {
display_source: String,
variables: HashMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct EnvComparison {
shared_keys: usize,
only_left: Vec<String>,
only_right: Vec<String>,
hard_conflicts: Vec<String>,
soft_conflicts: Vec<String>,
}
impl EnvComparison {
fn identical(&self) -> bool {
self.only_left.is_empty()
&& self.only_right.is_empty()
&& self.hard_conflicts.is_empty()
&& self.soft_conflicts.is_empty()
}
fn differing_keys(&self) -> usize {
self.hard_conflicts.len() + self.soft_conflicts.len()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct EnvConflict {
key: String,
left_label: String,
left_value: String,
right_label: String,
right_value: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct EnvMergeSummary {
variables: HashMap<String, String>,
auto_preferred_non_empty: Vec<String>,
prompted_conflicts: Vec<String>,
}
pub fn resolve_push_env_input(
project_root: &Path,
override_file: Option<String>,
) -> Result<PreparedPushEnvInput, String> {
let resolved = resolve_push_env_source(project_root, override_file)?;
let (pushable, skipped_empty) = split_empty_variables(resolved.variables);
Ok(PreparedPushEnvInput {
display_source: resolved.display_source,
pushable,
skipped_empty,
})
}
pub fn print_skipped_empty_variables(display_source: &str, keys: &[String]) {
if keys.is_empty() {
return;
}
println!(
"\n{} Skipped {} empty variable(s) from {}:",
"Skipped:".bright_yellow().bold(),
keys.len().to_string().bright_white().bold(),
display_source.bright_white()
);
println!(
" {} GitHub Actions variables cannot be empty, so these keys were not pushed.",
"Reason:".bright_black()
);
for key in keys {
println!(" - {}", key.bright_yellow());
}
}
fn resolve_push_env_source(
project_root: &Path,
override_file: Option<String>,
) -> Result<ResolvedEnvSource, String> {
if let Some(value) = override_file {
let candidate = PathBuf::from(value);
let resolved = if candidate.is_relative() {
project_root.join(candidate)
} else {
candidate
};
if !resolved.exists() {
return Err(format!(
"Specified env file does not exist: {}",
resolved.display()
));
}
let env_source = load_env_source(resolved, None)?;
return Ok(ResolvedEnvSource {
display_source: env_source.path.display().to_string(),
variables: env_source.variables,
});
}
let env_local = project_root.join(".env.local");
let env = project_root.join(".env");
match (env_local.exists(), env.exists()) {
(true, false) => {
let env_source = load_env_source(env_local, Some(".env.local"))?;
Ok(ResolvedEnvSource {
display_source: env_source.path.display().to_string(),
variables: env_source.variables,
})
}
(false, true) => {
let env_source = load_env_source(env, Some(".env"))?;
Ok(ResolvedEnvSource {
display_source: env_source.path.display().to_string(),
variables: env_source.variables,
})
}
(true, true) => {
let env_local_source = load_env_source(env_local, Some(".env.local"))?;
let env_source = load_env_source(env, Some(".env"))?;
let comparison = compare_env_sources(&env_local_source, &env_source);
print_env_source_summary(project_root, &env_local_source, &env_source, &comparison);
let options = vec![
format!(
"Use {} ({} vars)",
env_local_source.label,
env_local_source.variables.len()
),
format!(
"Use {} ({} vars)",
env_source.label,
env_source.variables.len()
),
build_merge_option_label(&comparison),
];
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Multiple env files detected, choose what to push")
.items(&options)
.default(0)
.interact()
.map_err(|error| format!("Failed to run env selection prompt: {}", error))?;
match selection {
0 => Ok(ResolvedEnvSource {
display_source: env_local_source.path.display().to_string(),
variables: env_local_source.variables,
}),
1 => Ok(ResolvedEnvSource {
display_source: env_source.path.display().to_string(),
variables: env_source.variables,
}),
_ => merge_env_sources_interactively(&env_local_source, &env_source),
}
}
_ => Err("No .env.local or .env file found in project root".to_string()),
}
}
fn load_env_source(path: PathBuf, label_override: Option<&str>) -> Result<EnvSource, String> {
let label = label_override
.map(str::to_string)
.or_else(|| {
path.file_name()
.map(|name| name.to_string_lossy().to_string())
})
.unwrap_or_else(|| path.display().to_string());
let variables = parse_shared_env_file(&path)?;
Ok(EnvSource {
label,
path,
variables,
})
}
fn compare_env_sources(left: &EnvSource, right: &EnvSource) -> EnvComparison {
let left_keys = left.variables.keys().cloned().collect::<BTreeSet<_>>();
let right_keys = right.variables.keys().cloned().collect::<BTreeSet<_>>();
let shared_keys = left_keys.intersection(&right_keys).count();
let only_left = left_keys
.difference(&right_keys)
.cloned()
.collect::<Vec<_>>();
let only_right = right_keys
.difference(&left_keys)
.cloned()
.collect::<Vec<_>>();
let mut hard_conflicts = Vec::new();
let mut soft_conflicts = Vec::new();
for key in left_keys.intersection(&right_keys) {
let Some(left_value) = left.variables.get(key) else {
continue;
};
let Some(right_value) = right.variables.get(key) else {
continue;
};
if left_value == right_value {
continue;
}
if is_empty_value(left_value) || is_empty_value(right_value) {
soft_conflicts.push(key.clone());
} else {
hard_conflicts.push(key.clone());
}
}
EnvComparison {
shared_keys,
only_left,
only_right,
hard_conflicts,
soft_conflicts,
}
}
fn print_env_source_summary(
project_root: &Path,
env_local_source: &EnvSource,
env_source: &EnvSource,
comparison: &EnvComparison,
) {
println!(
"{} Found both {} and {} in {}.",
"Info:".bright_cyan().bold(),
env_local_source.label.bright_white().bold(),
env_source.label.bright_white().bold(),
project_root.display()
);
if comparison.identical() {
println!(
" {} Same keys and values. Merge will produce the same result.",
"Match:".bright_green().bold()
);
return;
}
println!(
" {} {} vars in {} | {} vars in {} | {} shared",
"Compare:".bright_blue().bold(),
env_local_source.variables.len(),
env_local_source.label,
env_source.variables.len(),
env_source.label,
comparison.shared_keys
);
println!(
" {} {} only in {} | {} only in {} | {} differing",
"Diff:".bright_black(),
comparison.only_left.len(),
env_local_source.label,
comparison.only_right.len(),
env_source.label,
comparison.differing_keys()
);
if !comparison.hard_conflicts.is_empty() {
println!(
" {} Merge will ask you to resolve {} key(s) with different non-empty values.",
"Conflict:".bright_yellow().bold(),
comparison.hard_conflicts.len()
);
}
if !comparison.soft_conflicts.is_empty() {
println!(
" {} Merge will prefer the non-empty value for {} key(s).",
"Merge:".bright_yellow().bold(),
comparison.soft_conflicts.len()
);
}
}
fn build_merge_option_label(comparison: &EnvComparison) -> String {
if comparison.identical() {
return "Merge both files (same result; files are identical)".to_string();
}
if comparison.hard_conflicts.is_empty() {
return "Merge both files (union and prefer non-empty values)".to_string();
}
format!(
"Merge both files ({} interactive conflict(s))",
comparison.hard_conflicts.len()
)
}
fn merge_env_sources_interactively(
left: &EnvSource,
right: &EnvSource,
) -> Result<ResolvedEnvSource, String> {
let merged = merge_env_sources(left, right, resolve_merge_conflict)?;
println!(
"{} Prepared merged env input with {} total key(s).",
"Merge:".bright_green().bold(),
merged.variables.len()
);
if !merged.auto_preferred_non_empty.is_empty() {
println!(
" {} Preferred the non-empty value for {} key(s).",
"Auto:".bright_black(),
merged.auto_preferred_non_empty.len()
);
}
if !merged.prompted_conflicts.is_empty() {
println!(
" {} Resolved {} conflicting key(s) interactively.",
"Chosen:".bright_black(),
merged.prompted_conflicts.len()
);
}
Ok(ResolvedEnvSource {
display_source: format!("merged {} + {}", left.label, right.label),
variables: merged.variables,
})
}
fn merge_env_sources<F>(
left: &EnvSource,
right: &EnvSource,
mut resolve_conflict: F,
) -> Result<EnvMergeSummary, String>
where
F: FnMut(&EnvConflict) -> Result<String, String>,
{
let keys = left
.variables
.keys()
.chain(right.variables.keys())
.cloned()
.collect::<BTreeSet<_>>();
let mut variables = HashMap::new();
let mut auto_preferred_non_empty = Vec::new();
let mut prompted_conflicts = Vec::new();
for key in keys {
match (left.variables.get(&key), right.variables.get(&key)) {
(Some(left_value), Some(right_value)) if left_value == right_value => {
variables.insert(key, left_value.clone());
}
(Some(left_value), Some(right_value))
if !is_empty_value(left_value) && is_empty_value(right_value) =>
{
auto_preferred_non_empty.push(key.clone());
variables.insert(key, left_value.clone());
}
(Some(left_value), Some(right_value))
if is_empty_value(left_value) && !is_empty_value(right_value) =>
{
auto_preferred_non_empty.push(key.clone());
variables.insert(key, right_value.clone());
}
(Some(left_value), Some(right_value)) => {
let resolved = resolve_conflict(&EnvConflict {
key: key.clone(),
left_label: left.label.clone(),
left_value: left_value.clone(),
right_label: right.label.clone(),
right_value: right_value.clone(),
})?;
prompted_conflicts.push(key.clone());
variables.insert(key, resolved);
}
(Some(left_value), None) => {
variables.insert(key, left_value.clone());
}
(None, Some(right_value)) => {
variables.insert(key, right_value.clone());
}
(None, None) => {}
}
}
auto_preferred_non_empty.sort();
prompted_conflicts.sort();
Ok(EnvMergeSummary {
variables,
auto_preferred_non_empty,
prompted_conflicts,
})
}
fn resolve_merge_conflict(conflict: &EnvConflict) -> Result<String, String> {
println!(
"\n{} {}",
"Conflict:".bright_yellow().bold(),
conflict.key.bright_white().bold()
);
println!(
" {} {}",
format!("{}:", conflict.left_label).bright_black(),
preview_env_value(&conflict.left_value)
);
println!(
" {} {}",
format!("{}:", conflict.right_label).bright_black(),
preview_env_value(&conflict.right_value)
);
let options = vec![
format!(
"Use {} {}",
conflict.left_label,
preview_env_value(&conflict.left_value)
),
format!(
"Use {} {}",
conflict.right_label,
preview_env_value(&conflict.right_value)
),
"Enter a custom value".to_string(),
];
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Resolve {}", conflict.key))
.items(&options)
.default(0)
.interact()
.map_err(|error| {
format!(
"Failed to resolve env conflict for {}: {}",
conflict.key, error
)
})?;
match selection {
0 => Ok(conflict.left_value.clone()),
1 => Ok(conflict.right_value.clone()),
_ => Input::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Custom value for {}", conflict.key))
.with_initial_text(conflict.left_value.clone())
.allow_empty(true)
.interact_text()
.map_err(|error| {
format!(
"Failed to read custom env value for {}: {}",
conflict.key, error
)
}),
}
}
fn preview_env_value(value: &str) -> String {
let sanitized = value.replace(['\r', '\n'], " ");
let trimmed = sanitized.trim();
if trimmed.is_empty() {
return "<empty>".bright_black().to_string();
}
let char_count = trimmed.chars().count();
if char_count <= 20 {
return format!(
"{} {}",
trimmed,
format!("({} chars)", char_count).bright_black()
);
}
let prefix = trimmed.chars().take(6).collect::<String>();
let suffix = trimmed
.chars()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<String>();
format!(
"{}...{} {}",
prefix,
suffix,
format!("({} chars)", char_count).bright_black()
)
}
fn split_empty_variables(
variables: HashMap<String, String>,
) -> (HashMap<String, String>, Vec<String>) {
let mut pushable = HashMap::new();
let mut skipped_empty = Vec::new();
for (key, value) in variables {
if is_empty_value(&value) {
skipped_empty.push(key);
} else {
pushable.insert(key, value);
}
}
skipped_empty.sort();
(pushable, skipped_empty)
}
fn is_empty_value(value: &str) -> bool {
value.trim().is_empty()
}
#[cfg(test)]
mod tests {
use super::{compare_env_sources, merge_env_sources, split_empty_variables, EnvSource};
use std::collections::HashMap;
use std::path::PathBuf;
fn env_source(label: &str, values: &[(&str, &str)]) -> EnvSource {
let mut variables = HashMap::new();
for (key, value) in values {
variables.insert((*key).to_string(), (*value).to_string());
}
EnvSource {
label: label.to_string(),
path: PathBuf::from(label),
variables,
}
}
#[test]
fn compare_env_sources_detects_identical_files() {
let left = env_source(".env.local", &[("API_URL", "https://example.com")]);
let right = env_source(".env", &[("API_URL", "https://example.com")]);
let comparison = compare_env_sources(&left, &right);
assert!(comparison.identical());
assert_eq!(comparison.shared_keys, 1);
assert!(comparison.hard_conflicts.is_empty());
assert!(comparison.soft_conflicts.is_empty());
}
#[test]
fn merge_prefers_non_empty_value_without_prompt() {
let left = env_source(
".env.local",
&[("API_KEY", ""), ("BASE_URL", "http://local")],
);
let right = env_source(".env", &[("API_KEY", "secret"), ("BASE_URL", "")]);
let merged = merge_env_sources(&left, &right, |_| {
Err("conflict resolver should not be called".to_string())
})
.expect("merge should succeed");
assert_eq!(merged.variables.get("API_KEY"), Some(&"secret".to_string()));
assert_eq!(
merged.variables.get("BASE_URL"),
Some(&"http://local".to_string())
);
assert_eq!(
merged.auto_preferred_non_empty,
vec!["API_KEY".to_string(), "BASE_URL".to_string()]
);
assert!(merged.prompted_conflicts.is_empty());
}
#[test]
fn merge_uses_resolver_for_non_empty_conflicts() {
let left = env_source(".env.local", &[("API_KEY", "local-secret")]);
let right = env_source(".env", &[("API_KEY", "shared-secret")]);
let mut resolve_calls = 0usize;
let merged = merge_env_sources(&left, &right, |conflict| {
resolve_calls += 1;
assert_eq!(conflict.key, "API_KEY");
Ok(conflict.right_value.clone())
})
.expect("merge should succeed");
assert_eq!(resolve_calls, 1);
assert_eq!(
merged.variables.get("API_KEY"),
Some(&"shared-secret".to_string())
);
assert_eq!(merged.prompted_conflicts, vec!["API_KEY".to_string()]);
assert!(merged.auto_preferred_non_empty.is_empty());
}
#[test]
fn split_empty_variables_skips_blank_values() {
let mut variables = HashMap::new();
variables.insert("FILLED".to_string(), "value".to_string());
variables.insert("EMPTY".to_string(), String::new());
variables.insert("WHITESPACE".to_string(), " ".to_string());
let (pushable, skipped_empty) = split_empty_variables(variables);
assert_eq!(pushable.len(), 1);
assert_eq!(pushable.get("FILLED"), Some(&"value".to_string()));
assert_eq!(
skipped_empty,
vec!["EMPTY".to_string(), "WHITESPACE".to_string()]
);
}
}