xbp 10.12.2

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
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::commands::ssh_helpers::prompt_for_input;
use crate::utils::find_xbp_config_upwards;

/// Simple data type to represent a secret variable with a name/value pair.
#[derive(Debug, Clone)]
pub struct SecretVariable {
    pub name: String,
    pub value: String,
}

/// Provider trait so additional secret backends can be added later.
#[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>;
}

/// Entry point for the `xbp secrets` command.
pub async fn run_secrets(cmd: SecretsCmd, debug: bool) -> Result<(), String> {
    let project_root = resolve_project_root()?;
    let repo_override = cmd.repo.as_deref();

    let outcome = match cmd.command {
        Some(SecretsSubCommand::Help) => {
            print_secrets_help()?;
            return Ok(());
        }
        Some(SecretsSubCommand::List) => list_secrets(&project_root),
        Some(SecretsSubCommand::Push { file, force }) => {
            push_secrets(&project_root, file, force, repo_override).await
        }
        Some(SecretsSubCommand::Pull { output }) => {
            pull_secrets(&project_root, output, repo_override).await
        }
        Some(SecretsSubCommand::GenerateDefault { output }) => {
            scanner::generate_env_default(&project_root, output.as_deref())
        }
        Some(SecretsSubCommand::Verify) => verify::verify_envs(&project_root).await,
        None => 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,
    repo_override: Option<&str>,
) -> 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, repo_override).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>,
    repo_override: Option<&str>,
) -> Result<(), String> {
    let provider = resolve_provider_for_pull(project_root, repo_override).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(())
}

async fn resolve_provider_for_pull(
    project_root: &Path,
    repo_override: Option<&str>,
) -> Result<github::GitHubProvider, String> {
    match github::GitHubProvider::new(project_root, repo_override).await {
        Ok(provider) => Ok(provider),
        Err(error) if repo_override.is_none() && github::needs_repo_setup(&error) => {
            println!("GitHub repository could not be detected for this project.");
            println!("Starting secrets setup wizard.\n");

            let repo = prompt_for_input("Enter GitHub repository (owner/repo): ")?;
            let repo = repo.trim();
            if repo.is_empty() {
                return Err(error);
            }

            github::GitHubProvider::new(project_root, Some(repo)).await
        }
        Err(error) => Err(error),
    }
}

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 print_secrets_help() -> Result<(), String> {
    println!("\nXBP Secrets Management");
    println!("{}\n", "".repeat(60));
    println!("Usage: xbp secrets [OPTIONS] <COMMAND>");
    println!("\nCommands:");
    println!("  list               List local env vars from the preferred env file");
    println!("  push               Push local env vars to GitHub repository variables");
    println!("  pull               Pull secrets from GitHub into .env.local");
    println!("  generate-default   Generate a default .env file from detected variables");
    println!("  verify             Verify environment variables are set");
    println!("  help               Print this help message");
    println!("\nOptions:");
    println!("  --repo <OWNER/REPO>  GitHub repository override (owner/repo)");
    println!("\nExamples:");
    println!("  xbp secrets list");
    println!("  xbp secrets push --file .env.local --force");
    println!("  xbp secrets pull --output .env.production");
    println!("  xbp secrets --repo myorg/myrepo push");
    println!();
    Ok(())
}

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(&current_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()
        })
}