tfmcp 0.1.9

Terraform Model Context Protocol Tool - A CLI tool to manage Terraform through MCP
Documentation
//! Terraform workspace management operations.

use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;

/// Workspace action to perform
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum WorkspaceAction {
    List,
    Show,
    New,
    Select,
    Delete,
}

impl std::str::FromStr for WorkspaceAction {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "list" => Ok(WorkspaceAction::List),
            "show" => Ok(WorkspaceAction::Show),
            "new" | "create" => Ok(WorkspaceAction::New),
            "select" | "switch" => Ok(WorkspaceAction::Select),
            "delete" | "remove" => Ok(WorkspaceAction::Delete),
            _ => Err(anyhow::anyhow!(
                "Unknown workspace action: {}. Valid actions: list, show, new, select, delete",
                s
            )),
        }
    }
}

/// Workspace information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceInfo {
    pub name: String,
    pub current: bool,
}

/// Result of workspace operations
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceResult {
    pub success: bool,
    pub action: String,
    pub current_workspace: Option<String>,
    pub workspaces: Option<Vec<WorkspaceInfo>>,
    pub message: String,
}

/// Execute a workspace operation
pub fn execute_workspace(
    terraform_path: &Path,
    project_dir: &Path,
    action: WorkspaceAction,
    workspace_name: Option<&str>,
) -> anyhow::Result<WorkspaceResult> {
    match action {
        WorkspaceAction::List => list_workspaces(terraform_path, project_dir),
        WorkspaceAction::Show => show_workspace(terraform_path, project_dir),
        WorkspaceAction::New => {
            let name = workspace_name
                .ok_or_else(|| anyhow::anyhow!("Workspace name required for 'new' action"))?;
            new_workspace(terraform_path, project_dir, name)
        }
        WorkspaceAction::Select => {
            let name = workspace_name
                .ok_or_else(|| anyhow::anyhow!("Workspace name required for 'select' action"))?;
            select_workspace(terraform_path, project_dir, name)
        }
        WorkspaceAction::Delete => {
            let name = workspace_name
                .ok_or_else(|| anyhow::anyhow!("Workspace name required for 'delete' action"))?;
            delete_workspace(terraform_path, project_dir, name)
        }
    }
}

/// List all workspaces
fn list_workspaces(terraform_path: &Path, project_dir: &Path) -> anyhow::Result<WorkspaceResult> {
    let output = Command::new(terraform_path)
        .arg("workspace")
        .arg("list")
        .current_dir(project_dir)
        .output()?;

    if !output.status.success() {
        return Err(anyhow::anyhow!(
            "Failed to list workspaces: {}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let mut workspaces = Vec::new();
    let mut current_workspace = None;

    for line in stdout.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }

        if let Some(name) = line.strip_prefix("* ") {
            current_workspace = Some(name.to_string());
            workspaces.push(WorkspaceInfo {
                name: name.to_string(),
                current: true,
            });
        } else {
            workspaces.push(WorkspaceInfo {
                name: line.to_string(),
                current: false,
            });
        }
    }

    let message = format!("Found {} workspaces", workspaces.len());
    Ok(WorkspaceResult {
        success: true,
        action: "list".to_string(),
        current_workspace,
        workspaces: Some(workspaces),
        message,
    })
}

/// Show current workspace
fn show_workspace(terraform_path: &Path, project_dir: &Path) -> anyhow::Result<WorkspaceResult> {
    let output = Command::new(terraform_path)
        .arg("workspace")
        .arg("show")
        .current_dir(project_dir)
        .output()?;

    if !output.status.success() {
        return Err(anyhow::anyhow!(
            "Failed to show workspace: {}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }

    let current = String::from_utf8_lossy(&output.stdout).trim().to_string();

    Ok(WorkspaceResult {
        success: true,
        action: "show".to_string(),
        current_workspace: Some(current.clone()),
        workspaces: None,
        message: format!("Current workspace: {}", current),
    })
}

/// Create a new workspace
fn new_workspace(
    terraform_path: &Path,
    project_dir: &Path,
    name: &str,
) -> anyhow::Result<WorkspaceResult> {
    // Validate workspace name
    if !is_valid_workspace_name(name) {
        return Err(anyhow::anyhow!(
            "Invalid workspace name: '{}'. Names must be alphanumeric with hyphens or underscores",
            name
        ));
    }

    let output = Command::new(terraform_path)
        .arg("workspace")
        .arg("new")
        .arg(name)
        .current_dir(project_dir)
        .output()?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        if stderr.contains("already exists") {
            return Err(anyhow::anyhow!("Workspace '{}' already exists", name));
        }
        return Err(anyhow::anyhow!("Failed to create workspace: {}", stderr));
    }

    Ok(WorkspaceResult {
        success: true,
        action: "new".to_string(),
        current_workspace: Some(name.to_string()),
        workspaces: None,
        message: format!("Created and switched to workspace '{}'", name),
    })
}

/// Select (switch to) a workspace
fn select_workspace(
    terraform_path: &Path,
    project_dir: &Path,
    name: &str,
) -> anyhow::Result<WorkspaceResult> {
    let output = Command::new(terraform_path)
        .arg("workspace")
        .arg("select")
        .arg(name)
        .current_dir(project_dir)
        .output()?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        if stderr.contains("doesn't exist") || stderr.contains("does not exist") {
            return Err(anyhow::anyhow!("Workspace '{}' does not exist", name));
        }
        return Err(anyhow::anyhow!("Failed to select workspace: {}", stderr));
    }

    Ok(WorkspaceResult {
        success: true,
        action: "select".to_string(),
        current_workspace: Some(name.to_string()),
        workspaces: None,
        message: format!("Switched to workspace '{}'", name),
    })
}

/// Delete a workspace
fn delete_workspace(
    terraform_path: &Path,
    project_dir: &Path,
    name: &str,
) -> anyhow::Result<WorkspaceResult> {
    // Cannot delete the default workspace
    if name == "default" {
        return Err(anyhow::anyhow!("Cannot delete the 'default' workspace"));
    }

    // Check if trying to delete current workspace
    let current = show_workspace(terraform_path, project_dir)?;
    if current.current_workspace.as_deref() == Some(name) {
        return Err(anyhow::anyhow!(
            "Cannot delete workspace '{}' because it is currently selected. Switch to another workspace first.",
            name
        ));
    }

    let output = Command::new(terraform_path)
        .arg("workspace")
        .arg("delete")
        .arg(name)
        .current_dir(project_dir)
        .output()?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        if stderr.contains("doesn't exist") || stderr.contains("does not exist") {
            return Err(anyhow::anyhow!("Workspace '{}' does not exist", name));
        }
        if stderr.contains("is not empty") {
            return Err(anyhow::anyhow!(
                "Workspace '{}' is not empty. Use 'terraform workspace delete -force {}' to force deletion.",
                name,
                name
            ));
        }
        return Err(anyhow::anyhow!("Failed to delete workspace: {}", stderr));
    }

    Ok(WorkspaceResult {
        success: true,
        action: "delete".to_string(),
        current_workspace: current.current_workspace,
        workspaces: None,
        message: format!("Deleted workspace '{}'", name),
    })
}

/// Validate workspace name
fn is_valid_workspace_name(name: &str) -> bool {
    if name.is_empty() || name.len() > 100 {
        return false;
    }

    name.chars()
        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}

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

    #[test]
    fn test_parse_action() {
        assert_eq!(
            "list".parse::<WorkspaceAction>().unwrap(),
            WorkspaceAction::List
        );
        assert_eq!(
            "show".parse::<WorkspaceAction>().unwrap(),
            WorkspaceAction::Show
        );
        assert_eq!(
            "new".parse::<WorkspaceAction>().unwrap(),
            WorkspaceAction::New
        );
        assert_eq!(
            "create".parse::<WorkspaceAction>().unwrap(),
            WorkspaceAction::New
        );
        assert_eq!(
            "select".parse::<WorkspaceAction>().unwrap(),
            WorkspaceAction::Select
        );
        assert_eq!(
            "delete".parse::<WorkspaceAction>().unwrap(),
            WorkspaceAction::Delete
        );
    }

    #[test]
    fn test_valid_workspace_name() {
        assert!(is_valid_workspace_name("dev"));
        assert!(is_valid_workspace_name("prod-us-east-1"));
        assert!(is_valid_workspace_name("staging_v2"));
        assert!(!is_valid_workspace_name(""));
        assert!(!is_valid_workspace_name("name with spaces"));
        assert!(!is_valid_workspace_name("name/slash"));
    }
}