quickseal 0.1.4

Create Kubernetes SealedSecrets with a single secure command
Documentation
//! CLI argument parsing using clap

use clap::{Parser, Subcommand};
use std::collections::HashMap;

#[derive(Parser, Debug)]
#[command(name = "seal")]
#[command(version)]
#[command(about = "Create Kubernetes SealedSecrets with a single secure command")]
#[command(
    long_about = "Seal simplifies the creation of Kubernetes SealedSecrets by handling temporary file\n\
                         management, cleanup, and kubeseal integration automatically.\n\n\
                         Instead of manually creating plaintext secrets and sealing them, Seal does\n\
                         everything in one secure command with guaranteed cleanup.\n\n\
                         For more information: https://github.com/yourusername/seal"
)]
#[command(author = "Seal Contributors")]
#[command(after_help = "EXAMPLES:\n  \
    # Create sealed secret with flags\n  \
    seal create --name db-creds --namespace prod \\\n    \
        --from-literal username=admin \\\n    \
        --from-literal password=secret\n\n  \
    # Create sealed secret interactively\n  \
    seal create --interactive\n\n  \
    # Pipe output to file\n  \
    seal create --name app-key --namespace default \\\n    \
        --from-literal token=xyz > sealed-secret.yaml\n\n  \
    # Apply directly to cluster\n  \
    seal create -n db-creds -N prod -l user=admin -l pass=secret | kubectl apply -f -")]
pub struct Cli {
    #[command(subcommand)]
    pub command: Commands,

    /// Enable verbose logging (does NOT log secret values)
    #[arg(short, long, global = true)]
    pub verbose: bool,
}

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// Create a SealedSecret from key-value pairs
    Create(CreateCommand),
}

#[derive(Parser, Debug)]
#[command(about = "Create a SealedSecret from key-value pairs")]
#[command(long_about = "Create a SealedSecret from key-value pairs.\n\n\
                        This command creates a plaintext Kubernetes Secret, seals it using kubeseal,\n\
                        and outputs the encrypted SealedSecret YAML. All temporary files are automatically\n\
                        cleaned up with no traces left behind.")]
#[command(after_help = "EXAMPLES:\n  \
    # Basic secret creation\n  \
    seal create -n db-password -N production \\\n    \
        -l username=admin -l password=secret123\n\n  \
    # With labels and annotations\n  \
    seal create -n api-key -N default -l KEY=abc123 \\\n    \
        -L app=backend -L env=staging \\\n    \
        -A owner=platform-team\n\n  \
    # Interactive mode (guided prompts)\n  \
    seal create --interactive\n\n  \
    # Save to file\n  \
    seal create -n app-secret -N prod -l TOKEN=xyz > sealed-secret.yaml\n\n  \
    # Apply directly to cluster\n  \
    seal create -n db-creds -N prod -l user=admin | kubectl apply -f -\n\n\
NOTES:\n  \
    - The kubeseal binary must be installed and in PATH\n  \
    - A valid kubeconfig with active context is required\n  \
    - Temporary files are automatically cleaned up (no traces left)\n  \
    - Secret values are never logged or persisted\n  \
    - Cluster context is displayed before sealing (safety feature)")]
pub struct CreateCommand {
    /// Secret name (Kubernetes naming rules: lowercase, alphanumeric, hyphens, dots)
    #[arg(short, long, value_name = "NAME")]
    pub name: String,

    /// Target namespace for the secret
    #[arg(short = 'N', long, value_name = "NAMESPACE")]
    pub namespace: String,

    /// Secret key-value pair (can be specified multiple times, max 50)
    #[arg(short = 'l', long = "from-literal", value_name = "KEY=VALUE")]
    pub from_literal: Vec<String>,

    /// Add label to secret metadata (can be specified multiple times)
    #[arg(short = 'L', long = "label", value_name = "KEY=VALUE")]
    pub labels: Vec<String>,

    /// Add annotation to secret metadata (can be specified multiple times)
    #[arg(short = 'A', long = "annotation", value_name = "KEY=VALUE")]
    pub annotations: Vec<String>,

    /// Output format: yaml (default) or json
    #[arg(short, long, default_value = "yaml", value_name = "FORMAT")]
    pub output: String,

    /// Interactive mode - guided prompts for all parameters
    #[arg(short, long)]
    pub interactive: bool,
}

impl CreateCommand {
    /// Parse key=value strings into HashMap
    fn parse_key_values(items: &[String]) -> Result<HashMap<String, String>, anyhow::Error> {
        let mut map = HashMap::new();

        for item in items {
            let parts: Vec<&str> = item.splitn(2, '=').collect();
            if parts.len() != 2 {
                anyhow::bail!("Invalid format '{}': expected KEY=VALUE", item);
            }

            map.insert(parts[0].to_string(), parts[1].to_string());
        }

        Ok(map)
    }

    /// Parse and convert to SecretRequest
    pub fn to_secret_request(&self) -> Result<crate::models::SecretRequest, anyhow::Error> {
        // Parse from_literal into key-value pairs
        let data_map = Self::parse_key_values(&self.from_literal)?;

        if data_map.is_empty() {
            anyhow::bail!("At least one --from-literal KEY=VALUE pair is required");
        }

        // Convert to SecretKeyValue vector
        let mut data = Vec::new();
        for (key, value) in data_map {
            data.push(crate::models::SecretKeyValue::new(key, value)?);
        }

        // Parse labels and annotations
        let labels = Self::parse_key_values(&self.labels)?;
        let annotations = Self::parse_key_values(&self.annotations)?;

        // Create and validate SecretRequest
        crate::models::SecretRequest::new(
            self.name.clone(),
            self.namespace.clone(),
            labels,
            annotations,
            data,
        )
        .map_err(|e| anyhow::anyhow!(e))
    }

    /// Validate command arguments
    pub fn validate(&self) -> Result<(), anyhow::Error> {
        if !self.interactive {
            // In non-interactive mode, name and namespace are required
            if self.name.is_empty() {
                anyhow::bail!("--name is required (or use --interactive mode)");
            }

            if self.namespace.is_empty() {
                anyhow::bail!("--namespace is required (or use --interactive mode)");
            }

            if self.from_literal.is_empty() {
                anyhow::bail!(
                    "At least one --from-literal KEY=VALUE is required (or use --interactive mode)"
                );
            }
        }

        // Validate output format
        if self.output != "yaml" && self.output != "json" {
            anyhow::bail!("Output format must be 'yaml' or 'json'");
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_key_values() -> anyhow::Result<()> {
        let items = vec!["key1=value1".to_string(), "key2=value2".to_string()];
        let map = CreateCommand::parse_key_values(&items)?;

        assert_eq!(map.get("key1"), Some(&"value1".to_string()));
        assert_eq!(map.get("key2"), Some(&"value2".to_string()));
        assert_eq!(map.len(), 2);

        Ok(())
    }

    #[test]
    fn test_parse_key_values_with_equals_in_value() -> anyhow::Result<()> {
        let items = vec!["key=value=with=equals".to_string()];
        let map = CreateCommand::parse_key_values(&items)?;

        assert_eq!(map.get("key"), Some(&"value=with=equals".to_string()));

        Ok(())
    }

    #[test]
    fn test_parse_key_values_invalid_format() {
        let items = vec!["invalid_no_equals".to_string()];
        let result = CreateCommand::parse_key_values(&items);

        assert!(result.is_err());
    }
}