pub mod github;
pub mod scanner;
pub mod verify;
use async_trait::async_trait;
use dialoguer::Select;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use crate::cli::commands::{SecretsCmd, SecretsSubCommand};
use crate::utils::find_xbp_config_upwards;
#[derive(Debug, Clone)]
pub struct SecretVariable {
pub name: String,
pub value: String,
}
#[async_trait]
pub trait SecretsProvider {
async fn push(&self, secrets: &HashMap<String, String>, force: bool) -> Result<(), String>;
async fn pull(&self) -> Result<Vec<SecretVariable>, String>;
async fn list(&self) -> Result<Vec<SecretVariable>, String>;
}
pub async fn run_secrets(cmd: SecretsCmd, debug: bool) -> Result<(), String> {
let project_root = resolve_project_root()?;
let outcome = if let Some(command) = cmd.command {
match command {
SecretsSubCommand::Push { file, force } => {
push_secrets(&project_root, file, force).await
}
SecretsSubCommand::Pull { output } => pull_secrets(&project_root, output).await,
SecretsSubCommand::GenerateEnv { output } => {
scanner::generate_env_default(&project_root, output.as_deref())
}
SecretsSubCommand::Verify => verify::verify_envs(&project_root).await,
}
} else {
list_secrets(&project_root)
};
outcome.map(|_| {
if debug {
println!("[secrets] Completed secrets command");
}
})
}
async fn push_secrets(
project_root: &Path,
file_override: Option<String>,
force: bool,
) -> Result<(), String> {
let env_path = resolve_env_file(project_root, file_override)?;
let secrets = parse_env_file(&env_path)?;
if secrets.is_empty() {
println!("No secrets found in {}", env_path.display());
return Ok(());
}
let provider = github::GitHubProvider::new(project_root).await?;
provider.push(&secrets, force).await?;
println!(
"Pushed {} variable(s) from {} to GitHub repository variables.",
secrets.len(),
env_path.display()
);
Ok(())
}
async fn pull_secrets(project_root: &Path, output: Option<String>) -> Result<(), String> {
let provider = github::GitHubProvider::new(project_root).await?;
let variables = provider.pull().await?;
if variables.is_empty() {
println!("No variables found in GitHub repository.");
return Ok(());
}
let output_path = output
.map(PathBuf::from)
.map(|p| {
if p.is_relative() {
project_root.join(p)
} else {
p
}
})
.unwrap_or_else(|| project_root.join(".env.local"));
let mut content = String::new();
let mut variable_names: Vec<&SecretVariable> = variables.iter().collect();
variable_names.sort_by_key(|v| v.name.clone());
for variable in variable_names {
content.push_str(&format!("{}={}\n", variable.name, variable.value));
}
fs::write(&output_path, content)
.map_err(|e| format!("Failed to write {}: {}", output_path.display(), e))?;
println!(
"Pulled {} variable(s) into {}.",
variables.len(),
output_path.display()
);
Ok(())
}
fn list_secrets(project_root: &Path) -> Result<(), String> {
let env_file = choose_env_for_list(project_root)?;
let secrets = parse_env_file(&env_file)?;
println!(
"Found {} variable(s) in {}",
secrets.len(),
env_file.display()
);
for name in secrets.keys() {
println!(" {}", name);
}
Ok(())
}
fn resolve_env_file(project_root: &Path, override_file: Option<String>) -> Result<PathBuf, 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 Ok(resolved);
}
return Err(format!(
"Specified env file does not exist: {}",
resolved.display()
));
}
let env_local = project_root.join(".env.local");
let env = project_root.join(".env");
let local_exists = env_local.exists();
let env_exists = env.exists();
match (local_exists, env_exists) {
(true, false) => Ok(env_local),
(false, true) => Ok(env),
(true, true) => {
let options = vec![".env.local", ".env"];
let selection = Select::new()
.with_prompt("Multiple env files detected, choose one to push")
.items(&options)
.default(0)
.interact()
.map_err(|e| format!("Failed to run selection prompt: {}", e))?;
let chosen = if options[selection] == ".env" {
env
} else {
env_local
};
Ok(chosen)
}
_ => Err("No .env.local or .env file found in project root".to_string()),
}
}
fn choose_env_for_list(project_root: &Path) -> Result<PathBuf, String> {
let env_local = project_root.join(".env.local");
if env_local.exists() {
return Ok(env_local);
}
let env = project_root.join(".env");
if env.exists() {
return Ok(env);
}
Err("No .env.local or .env file available to list".to_string())
}
fn parse_env_file(path: &Path) -> Result<HashMap<String, String>, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let mut result = HashMap::new();
for line in content.lines() {
let mut trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with("export ") {
trimmed = trimmed.trim_start_matches("export ").trim();
}
if let Some((key, value)) = trimmed.split_once('=') {
let key = key.trim().to_string();
let value = value.trim().to_string();
if !key.is_empty() {
result.insert(key, value);
}
}
}
Ok(result)
}
fn resolve_project_root() -> Result<PathBuf, String> {
let current_dir =
env::current_dir().map_err(|e| format!("Failed to read current dir: {}", e))?;
find_xbp_config_upwards(¤t_dir)
.map(|found| found.project_root)
.ok_or_else(|| {
"Currently not in an XBP project. Run 'xbp' to select a project or 'xbp setup' to initialize a new project.".to_string()
})
}