use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use inquire::Select;
use super::ask;
use crate::brand;
pub(super) enum ReinitChoice {
Merge(HashMap<String, String>),
Overwrite,
MakeNew,
Cancel,
}
pub(super) fn parse_env_file(path: &Path) -> HashMap<String, String> {
let Ok(content) = std::fs::read_to_string(path) else {
return HashMap::new();
};
content
.lines()
.filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
.filter_map(|l| {
let (k, v) = l.split_once('=')?;
let trimmed = v.trim();
let unquoted = {
let bytes = trimmed.as_bytes();
if bytes.len() >= 2
&& (bytes[0] == b'"' || bytes[0] == b'\'')
&& bytes[0] == bytes[bytes.len() - 1]
{
&trimmed[1..trimmed.len() - 1]
} else {
trimmed
}
};
Some((k.trim().to_string(), unquoted.to_string()))
})
.collect()
}
pub(super) fn detect_existing_files<'a>(output_dir: &Path, compose_name: &'a str) -> Vec<&'a str> {
let mut found: Vec<&str> = Vec::new();
if output_dir.join(compose_name).exists() {
found.push(compose_name);
}
if output_dir.join(".env").exists() {
found.push(".env");
}
if output_dir.join("config/agent.yml").exists() {
found.push("config/agent.yml");
}
if output_dir.join("config/orchestrator.yml").exists() {
found.push("config/orchestrator.yml");
}
found
}
pub(super) fn process_reinit_choice(
choice: &str,
output_dir: &Path,
compose_name: &str,
) -> Result<ReinitChoice> {
if choice.starts_with("Cancel") {
return Ok(ReinitChoice::Cancel);
}
if choice.starts_with("Overwrite") {
backup_files(output_dir, compose_name)?;
return Ok(ReinitChoice::Overwrite);
}
if choice.starts_with("Make new") {
return Ok(ReinitChoice::MakeNew);
}
let env_path = output_dir.join(".env");
let existing = parse_env_file(&env_path);
Ok(ReinitChoice::Merge(existing))
}
pub(super) fn check_existing(output_dir: &Path, compose_name: &str) -> Result<ReinitChoice> {
let found = detect_existing_files(output_dir, compose_name);
if found.is_empty() {
return Ok(ReinitChoice::Overwrite); }
println!();
brand::warn(&format!("Found existing: {}", found.join(", ")));
println!();
let opts = vec![
"Merge \u{2014} keep existing secrets, update provider/agent config",
"Overwrite \u{2014} start fresh (existing files backed up to .nsed-backup/)",
"Make new \u{2014} ignore existing files, write fresh (no backup)",
"Cancel",
];
let rc = brand::render_config();
let choice = match ask(Select::new("How would you like to proceed?", opts)
.with_render_config(rc)
.prompt())?
{
Some(c) => c,
None => return Ok(ReinitChoice::Cancel),
};
process_reinit_choice(choice, output_dir, compose_name)
}
pub(super) fn backup_files(output_dir: &Path, compose_name: &str) -> Result<()> {
let backup = output_dir.join(".nsed-backup");
std::fs::create_dir_all(&backup).context("creating .nsed-backup/")?;
let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
let files_to_backup = [
compose_name,
".env",
"config/agent.yml",
"config/orchestrator.yml",
];
for name in &files_to_backup {
let src = output_dir.join(name);
if src.exists() {
let safe_name = name.replace('/', "_");
let dst = backup.join(format!("{safe_name}.{timestamp}"));
std::fs::copy(&src, &dst).with_context(|| format!("backing up {name}"))?;
}
}
brand::info(&format!(
"Existing files backed up to {}",
brand::teal(".nsed-backup/")
));
Ok(())
}
pub(super) fn ensure_gitignore(output_dir: &Path) -> Result<()> {
let gi = output_dir.join(".gitignore");
let entries = [".env", ".nsed-backup/"];
if gi.exists() {
let content = std::fs::read_to_string(&gi).unwrap_or_default();
let mut f = std::fs::OpenOptions::new().append(true).open(&gi)?;
use std::io::Write;
for entry in &entries {
if !content.lines().any(|l| l.trim() == *entry) {
write!(f, "\n{entry}\n")?;
}
}
} else {
let content = entries.join("\n") + "\n";
std::fs::write(&gi, content)?;
}
Ok(())
}
#[cfg(test)]
pub(super) fn env_keys(env_content: &str) -> std::collections::HashSet<String> {
env_content
.lines()
.filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
.filter_map(|l| l.split_once('=').map(|(k, _)| k.trim().to_string()))
.collect()
}
pub(super) struct ConfigFiles {
pub agent_config: Option<String>,
pub orchestrator_config: Option<String>,
}
const SECRET_KEY_PATTERNS: &[&str] = &["SECRET", "API_KEY", "PASSWORD", "SEED"];
pub(super) fn is_secret_key(key: &str) -> bool {
let upper = key.to_uppercase();
SECRET_KEY_PATTERNS.iter().any(|pat| upper.contains(pat))
}
fn merge_env(env_content: &str, existing_env: &HashMap<String, String>) -> String {
env_content
.lines()
.map(|line| {
if line.trim_start().starts_with('#') || line.trim().is_empty() {
return line.to_string();
}
if let Some((key, _value)) = line.split_once('=') {
let key = key.trim();
if is_secret_key(key) {
if let Some(old_val) = existing_env.get(key) {
return format!("{key}={old_val}");
}
}
}
line.to_string()
})
.collect::<Vec<_>>()
.join("\n")
+ "\n"
}
#[allow(clippy::too_many_arguments)]
pub(super) fn write_files(
output_dir: &Path,
compose_name: &str,
compose_content: &str,
env_content: &str,
existing_env: &HashMap<String, String>,
configs: &ConfigFiles,
) -> Result<bool> {
std::fs::create_dir_all(output_dir)
.with_context(|| format!("creating directory {}", output_dir.display()))?;
let compose_path = output_dir.join(compose_name);
let env_path = output_dir.join(".env");
std::fs::write(&compose_path, compose_content)
.with_context(|| format!("writing {}", compose_path.display()))?;
let env_skipped = if !existing_env.is_empty() {
let merged = merge_env(env_content, existing_env);
std::fs::write(&env_path, merged)
.with_context(|| format!("writing {}", env_path.display()))?;
true
} else {
std::fs::write(&env_path, env_content)
.with_context(|| format!("writing {}", env_path.display()))?;
false
};
ensure_gitignore(output_dir).context("updating .gitignore")?;
if configs.agent_config.is_some() || configs.orchestrator_config.is_some() {
let config_dir = output_dir.join("config");
std::fs::create_dir_all(&config_dir)
.with_context(|| format!("creating directory {}", config_dir.display()))?;
if let Some(agent_yml) = &configs.agent_config {
let agent_config_path = config_dir.join("agent.yml");
std::fs::write(&agent_config_path, agent_yml)
.with_context(|| format!("writing {}", agent_config_path.display()))?;
}
if let Some(orch_yml) = &configs.orchestrator_config {
let orch_config_path = config_dir.join("orchestrator.yml");
std::fs::write(&orch_config_path, orch_yml)
.with_context(|| format!("writing {}", orch_config_path.display()))?;
}
}
Ok(env_skipped)
}