solarboat 0.8.9

A CLI tool for intelligent Terraform operations management with automatic dependency detection
Documentation
use std::process::{Command, Stdio};
use std::path::Path;
use regex::Regex;
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::Duration;
use std::thread;

/// Represents a single terraform operation to be processed
#[derive(Debug, Clone)]
pub struct TerraformOperation {
    pub module_path: String,
    pub workspace: Option<String>,
    pub var_files: Vec<String>,
    pub operation_type: OperationType,
    pub watch: bool,
    pub skip_init: bool, // Skip initialization if already done
}

#[derive(Debug, Clone)]
pub enum OperationType {
    Init,
    Plan { plan_dir: Option<String> },
    Apply,
}

/// Result of a terraform operation
#[derive(Debug, Clone)]
pub struct OperationResult {
    pub module_path: String,
    pub workspace: Option<String>,
    pub operation_type: OperationType,
    pub success: bool,
    pub error: Option<String>,
    pub output: Vec<String>,
}

/// Ensure terraform module is initialized before operations
pub fn ensure_module_initialized(module_path: &str) -> Result<(), String> {    
    // Check if .terraform directory exists to avoid unnecessary init
    let terraform_dir = std::path::Path::new(module_path).join(".terraform");
    if terraform_dir.exists() {
        // Check if it's properly initialized by trying to list workspaces
        let workspace_check = Command::new("terraform")
            .arg("workspace")
            .arg("list")
            .current_dir(module_path)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
            
        if workspace_check.is_ok() && workspace_check.unwrap().success() {
            return Ok(()); // Already initialized
        }
    }
    
    // Initialize if needed
    let output = Command::new("terraform")
        .arg("init")
        .current_dir(module_path)
        .output()
        .map_err(|e| format!("Failed to run terraform init: {}", e))?;

    if !output.status.success() {
        let error_msg = String::from_utf8_lossy(&output.stderr);
        return Err(format!("Terraform init failed: {}", error_msg));
    }

    Ok(())
}

/// Select a terraform workspace
pub fn select_workspace(module_path: &str, workspace: &str) -> Result<(), String> {
    // First check if we're already in the correct workspace
    let current_workspace = Command::new("terraform")
        .arg("workspace")
        .arg("show")
        .current_dir(module_path)
        .output()
        .map_err(|e| format!("Failed to get current workspace: {}", e))?;

    if current_workspace.status.success() {
        let current = String::from_utf8_lossy(&current_workspace.stdout).trim().to_string();
        if current == workspace {
            return Ok(()); // Already in the correct workspace
        }
    }

    // Only select if we're not already in the correct workspace
    let mut cmd = Command::new("terraform");
    cmd.arg("workspace")
       .arg("select")
       .arg(workspace)
       .current_dir(module_path)
       .stdout(Stdio::null())
       .stderr(Stdio::null());

    let status = cmd.status()
        .map_err(|e| format!("Failed to select workspace {}: {}", workspace, e))?;

    if status.success() {
        Ok(())
    } else {
        Err(format!("Failed to select workspace {}", workspace))
    }
}

/// Save plan output to a markdown file
/// Uses naming convention: {module_name}-{workspace}-{timestamp}.tfplan.md
pub fn save_plan_output(module_path: &str, plan_dir: &str, workspace: Option<&str>, output_lines: &[String]) -> Result<(), String> {
    // Create the plan directory if it doesn't exist
    std::fs::create_dir_all(plan_dir)
        .map_err(|e| format!("Failed to create plan directory: {}", e))?;
        
    if let Some(module_name) = Path::new(module_path).file_name().and_then(|n| n.to_str()) {
        // Get current timestamp
        let timestamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map_err(|e| format!("Failed to get timestamp: {}", e))?
            .as_secs();
        
        // Create filename with workspace and timestamp
        let workspace_name = workspace.unwrap_or("default");
        let filename = format!("{}-{}-{}.tfplan.md", module_name, workspace_name, timestamp);
        let plan_file = Path::new(plan_dir).join(filename);
        
        // Format the output
        let mut content = format!("# Terraform Plan Output for {} (workspace: {})\n\n", module_name, workspace_name);
        content.push_str("```\n");
        for line in output_lines {
            content.push_str(&clean_terraform_output(line));
            content.push('\n');
        }
        content.push_str("```\n");
        
        std::fs::write(&plan_file, content)
            .map_err(|e| format!("Failed to write plan file: {}", e))?;
    }

    Ok(())
}

/// Remove ANSI color codes from terraform output
pub fn clean_terraform_output(input: &str) -> String {
    // Remove ANSI color codes
    let re = Regex::new(r"\x1b\[[0-9;]*m").unwrap();
    re.replace_all(input, "").to_string()
}

/// Run a single terraform plan operation
pub fn run_single_plan(module_path: &str, plan_dir: Option<&str>, workspace: Option<&str>, var_files: Option<&[String]>) -> Result<bool, String> {
    // Ensure module is initialized before planning
    ensure_module_initialized(module_path)?;
    
    let mut cmd = Command::new("terraform");
    cmd.arg("plan").current_dir(module_path);
    
    if let Some(var_files) = var_files {
        for var_file in var_files {
            cmd.arg("-var-file").arg(var_file);
        }
    }

    let output = cmd.output()
        .map_err(|e| e.to_string())?;

    if !output.status.success() {
        eprintln!("{}", String::from_utf8_lossy(&output.stderr));
        return Ok(false);
    }

    // If plan_dir is specified, save the plan output
    if let Some(plan_dir) = plan_dir {
        let plan_output = String::from_utf8_lossy(&output.stdout).to_string();
        let output_lines: Vec<String> = plan_output.lines().map(|s| s.to_string()).collect();
        if let Err(e) = save_plan_output(module_path, plan_dir, workspace, &output_lines) {
            eprintln!("Warning: Failed to save plan output: {}", e);
        }
    }

    Ok(true)
}

/// Run a single terraform apply operation
pub fn run_single_apply(module_path: &str, var_files: Option<&[String]>) -> Result<bool, String> {
    // Ensure module is initialized before applying
    ensure_module_initialized(module_path)?;
    
    let mut cmd = Command::new("terraform");
    cmd.arg("apply")
       .arg("-auto-approve")
       .arg("-input=false")  // Prevent interactive prompts
       .current_dir(module_path);
    
    if let Some(var_files) = var_files {
        for var_file in var_files {
            cmd.arg("-var-file").arg(var_file);
        }
    }

    let status = cmd.status()
        .map_err(|e| e.to_string())?;

    Ok(status.success())
}


pub fn check_state_lock_available(module_path: &str, workspace: Option<&str>) -> bool {
    if let Some(ws) = workspace {
        if let Err(_) = select_workspace(module_path, ws) {
            return false;
        }
    }
    
    let result = Command::new("terraform")
        .arg("state")
        .arg("list")
        .current_dir(module_path)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();
        
    result.is_ok() && result.unwrap().success()
}

pub fn wait_for_state_lock_release(module_path: &str, workspace: Option<&str>, max_wait: Duration) -> bool {
    use std::time::Instant;
    
    let start = Instant::now();
    let mut attempt = 0;
    let max_attempts = 10;
    
    while start.elapsed() < max_wait && attempt < max_attempts {
        if check_state_lock_available(module_path, workspace) {
            return true; // State lock is available
        }
        
        attempt += 1;
        let delay = std::cmp::min(2u64.pow(attempt), 30); // Exponential backoff: 2, 4, 8, 16, 30, 30, ...
        thread::sleep(Duration::from_secs(delay));
    }
    
    false // Timeout reached
}