xbp 10.8.0

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