tfmcp 0.1.9

Terraform Model Context Protocol Tool - A CLI tool to manage Terraform through MCP
Documentation
//! Terraform refresh operations.
//!
//! Note: `terraform refresh` is deprecated. The recommended approach is to use
//! `terraform apply -refresh-only` which is what this module implements internally.

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

/// Result of refresh operation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefreshResult {
    pub success: bool,
    pub resources_updated: i32,
    pub output: String,
    pub changes: Vec<RefreshChange>,
    pub message: String,
}

/// A single refresh change
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefreshChange {
    pub resource_address: String,
    pub change_type: RefreshChangeType,
    pub detail: Option<String>,
}

/// Type of refresh change
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RefreshChangeType {
    Updated,
    Drifted,
    Unchanged,
}

/// Execute terraform refresh (via apply -refresh-only)
pub fn execute_refresh(
    terraform_path: &Path,
    project_dir: &Path,
    target: Option<&str>,
) -> anyhow::Result<RefreshResult> {
    let mut cmd = Command::new(terraform_path);
    cmd.arg("apply")
        .arg("-refresh-only")
        .arg("-auto-approve")
        .arg("-json");

    // If targeting a specific resource
    if let Some(target_addr) = target {
        cmd.arg(format!("-target={}", target_addr));
    }

    let output = cmd.current_dir(project_dir).output()?;

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    if !output.status.success() {
        return Err(anyhow::anyhow!("Refresh failed: {}", stderr));
    }

    // Parse JSON output to extract changes
    let changes = parse_refresh_output(&stdout);
    let resources_updated = changes
        .iter()
        .filter(|c| c.change_type == RefreshChangeType::Updated)
        .count() as i32;

    let message = if resources_updated > 0 {
        format!("Refreshed {} resources", resources_updated)
    } else {
        "No resources needed refreshing".to_string()
    };

    Ok(RefreshResult {
        success: true,
        resources_updated,
        output: stdout.to_string(),
        changes,
        message,
    })
}

/// Execute refresh with plan preview (no auto-approve)
#[allow(dead_code)]
pub fn preview_refresh(
    terraform_path: &Path,
    project_dir: &Path,
    target: Option<&str>,
) -> anyhow::Result<RefreshResult> {
    let mut cmd = Command::new(terraform_path);
    cmd.arg("plan").arg("-refresh-only").arg("-json");

    // If targeting a specific resource
    if let Some(target_addr) = target {
        cmd.arg(format!("-target={}", target_addr));
    }

    let output = cmd.current_dir(project_dir).output()?;

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    if !output.status.success() {
        return Err(anyhow::anyhow!("Refresh preview failed: {}", stderr));
    }

    // Parse JSON output to extract changes
    let changes = parse_refresh_output(&stdout);
    let resources_updated = changes
        .iter()
        .filter(|c| c.change_type == RefreshChangeType::Drifted)
        .count() as i32;

    let message = if resources_updated > 0 {
        format!(
            "{} resources have drifted and would be updated",
            resources_updated
        )
    } else {
        "No drift detected - state is up to date".to_string()
    };

    Ok(RefreshResult {
        success: true,
        resources_updated,
        output: stdout.to_string(),
        changes,
        message,
    })
}

/// Parse refresh output to extract changes
fn parse_refresh_output(json_output: &str) -> Vec<RefreshChange> {
    let mut changes = Vec::new();

    for line in json_output.lines() {
        if line.trim().is_empty() {
            continue;
        }

        if let Ok(obj) = serde_json::from_str::<serde_json::Value>(line) {
            // Check for resource_drift or planned_change messages
            if let Some(msg_type) = obj.get("type").and_then(|t| t.as_str()) {
                match msg_type {
                    "resource_drift" => {
                        if let Some(change) = obj.get("change") {
                            if let Some(resource) = change.get("resource") {
                                if let Some(addr) = resource.get("addr").and_then(|a| a.as_str()) {
                                    changes.push(RefreshChange {
                                        resource_address: addr.to_string(),
                                        change_type: RefreshChangeType::Drifted,
                                        detail: Some("Resource has drifted from state".to_string()),
                                    });
                                }
                            }
                        }
                    }
                    "planned_change" => {
                        if let Some(change) = obj.get("change") {
                            if let Some(resource) = change.get("resource") {
                                if let Some(addr) = resource.get("addr").and_then(|a| a.as_str()) {
                                    let action = change
                                        .get("action")
                                        .and_then(|a| a.as_str())
                                        .unwrap_or("update");

                                    if action == "update" {
                                        changes.push(RefreshChange {
                                            resource_address: addr.to_string(),
                                            change_type: RefreshChangeType::Updated,
                                            detail: Some("State will be updated".to_string()),
                                        });
                                    }
                                }
                            }
                        }
                    }
                    "apply_complete" | "change_summary" => {
                        // Skip summary messages
                    }
                    _ => {}
                }
            }
        }
    }

    changes
}

/// Get a list of resources that might need refreshing
#[allow(dead_code)]
pub fn get_stale_resources(
    terraform_path: &Path,
    project_dir: &Path,
) -> anyhow::Result<Vec<String>> {
    // Run a refresh-only plan to detect drift
    let result = preview_refresh(terraform_path, project_dir, None)?;

    let stale: Vec<String> = result
        .changes
        .into_iter()
        .filter(|c| c.change_type == RefreshChangeType::Drifted)
        .map(|c| c.resource_address)
        .collect();

    Ok(stale)
}

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

    #[test]
    fn test_parse_empty_output() {
        let changes = parse_refresh_output("");
        assert!(changes.is_empty());
    }

    #[test]
    fn test_parse_drift_message() {
        let json =
            r#"{"type":"resource_drift","change":{"resource":{"addr":"aws_instance.example"}}}"#;
        let changes = parse_refresh_output(json);
        assert_eq!(changes.len(), 1);
        assert_eq!(changes[0].resource_address, "aws_instance.example");
        assert_eq!(changes[0].change_type, RefreshChangeType::Drifted);
    }

    #[test]
    fn test_parse_planned_change() {
        let json = r#"{"type":"planned_change","change":{"resource":{"addr":"aws_s3_bucket.data"},"action":"update"}}"#;
        let changes = parse_refresh_output(json);
        assert_eq!(changes.len(), 1);
        assert_eq!(changes[0].change_type, RefreshChangeType::Updated);
    }
}