xbp 10.15.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::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::Usage) => {
            print_secrets_help()?;
            return Ok(());
        }
        Some(SecretsSubCommand::List(c)) => list_secrets(&project_root, c.file.as_deref(), c.format.as_deref()),
        Some(SecretsSubCommand::Push(c)) => {
            push_secrets(&project_root, c.file.clone(), c.force, c.dry_run, repo_override).await
        }
        Some(SecretsSubCommand::Pull(c)) => pull_secrets(&project_root, c.output.clone(), repo_override).await,
        Some(SecretsSubCommand::GenerateDefault(c)) => {
            scanner::generate_env_default(&project_root, c.output.as_deref())
        }
        Some(SecretsSubCommand::GenerateExample(c)) => scanner::generate_env_example(
            &project_root,
            c.output.as_deref(),
            c.clean,
            &c.include_prefix,
            &c.exclude_prefix,
        ),
        Some(SecretsSubCommand::Diff) => diff_secrets(&project_root, repo_override).await,
        Some(SecretsSubCommand::Verify) => verify::verify_envs(&project_root).await,
        None => list_secrets(&project_root, None, None),
    };

    outcome.map(|_| {
        if debug {
            println!("[secrets] Completed secrets command");
        }
    })
}

async fn push_secrets(
    project_root: &Path,
    file_override: Option<String>,
    force: bool,
    dry_run: 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(());
    }

    if dry_run {
        println!("[dry-run] Would push {} variable(s) from {}:", secrets.len(), env_path.display());
        for name in secrets.keys() {
            println!("  {}", name);
        }
        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 diff_secrets(project_root: &Path, repo_override: Option<&str>) -> Result<(), String> {
    let env_path = choose_env_for_list(project_root)?;
    let local = parse_env_file(&env_path)?;
    let provider = github::GitHubProvider::new(project_root, repo_override).await?;
    let remote_vars = provider.list().await?;
    let remote: HashMap<_, _> = remote_vars.into_iter().map(|v| (v.name, v.value)).collect();

    let local_keys: std::collections::HashSet<_> = local.keys().collect();
    let remote_keys: std::collections::HashSet<_> = remote.keys().collect();

    let mut only_local: Vec<_> = local_keys.difference(&remote_keys).collect();
    let mut only_remote: Vec<_> = remote_keys.difference(&local_keys).collect();
    let mut differing: Vec<_> = local_keys
        .intersection(&remote_keys)
        .filter(|k| local.get(k.as_str()) != remote.get(k.as_str()))
        .map(|k| k.as_str())
        .collect();

    only_local.sort();
    only_remote.sort();
    differing.sort();

    println!("Local: {} (from {})", local.len(), env_path.display());
    println!("Remote: {} variables\n", remote.len());

    if !only_local.is_empty() {
        println!("Only in local (not pushed):");
        for k in &only_local {
            println!("  + {}", k);
        }
        println!();
    }
    if !only_remote.is_empty() {
        println!("Only in remote (not in local):");
        for k in &only_remote {
            println!("  - {}", k);
        }
        println!();
    }
    if !differing.is_empty() {
        println!("Different values (local vs remote):");
        for k in &differing {
            println!("  ~ {} (local has value, remote differs)", k);
        }
    }

    if only_local.is_empty() && only_remote.is_empty() && differing.is_empty() {
        println!("Local and remote are in sync.");
    }
    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,
    file_override: Option<&str>,
    format_override: Option<&str>,
) -> Result<(), String> {
    let env_file = if let Some(f) = file_override {
        let p = PathBuf::from(f);
        let resolved = if p.is_relative() {
            project_root.join(p)
        } else {
            p
        };
        if resolved.exists() {
            resolved
        } else {
            return Err(format!("File not found: {}", resolved.display()));
        }
    } else {
        choose_env_for_list(project_root)?
    };

    let secrets = parse_env_file(&env_file)?;
    let format = format_override.unwrap_or("plain");

    if format.eq_ignore_ascii_case("json") {
        let mut sorted: Vec<_> = secrets.keys().collect();
        sorted.sort();
        let obj: std::collections::HashMap<_, _> =
            sorted.into_iter().map(|k| (k.clone(), secrets[k].clone())).collect();
        println!("{}", serde_json::to_string_pretty(&obj).unwrap_or_default());
    } else {
        println!(
            "Found {} variable(s) in {}",
            secrets.len(),
            env_file.display()
        );
        let mut names: Vec<_> = secrets.keys().collect();
        names.sort();
        for name in names {
            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);
    }
    let env_default = project_root.join(".env.default");
    if env_default.exists() {
        return Ok(env_default);
    }
    Err(
        "No .env.local, .env, or .env.default found. Run 'xbp secrets generate-default' to create \
         .env.default from source, or add .env manually."
            .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 (--file, --format json)");
    println!("  push               Push to GitHub (--file, --force, --dry-run)");
    println!("  pull               Pull from GitHub into .env.local");
    println!("  generate-default   Generate .env.default from source scan");
    println!("  generate-example   Generate .env.example (--clean, --include-prefix, --exclude-prefix)");
    println!("  diff               Compare local vs remote variables");
    println!("  verify             Verify required env vars are set");
    println!("  usage              Print this help");
    println!("\nOptions:");
    println!("  --repo <OWNER/REPO>  GitHub repository override");
    println!("\nExamples:");
    println!("  xbp secrets list --format json");
    println!("  xbp secrets push --dry-run");
    println!("  xbp secrets diff");
    println!("  xbp secrets generate-example --clean --include-prefix DATABASE_");
    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 init' to create a project config in this directory, or 'xbp' to select an existing project.".to_string()
        })
}