tfmcp 0.2.2

Terraform Model Context Protocol Tool - A CLI tool to manage Terraform through MCP
Documentation
use crate::terraform::plan_analyzer::{PlanAnalysis, ResourceChange, RiskLevel};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PlanReviewDecision {
    Approve,
    ReviewRequired,
    Block,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanReview {
    pub decision: PlanReviewDecision,
    pub summary: String,
    pub risk_level: RiskLevel,
    pub risk_score: i32,
    pub blockers: Vec<String>,
    pub warnings: Vec<String>,
    pub recommendations: Vec<String>,
    pub destructive_changes: Vec<String>,
    pub replacement_changes: Vec<String>,
    pub markdown: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanPrSummary {
    pub title: String,
    pub markdown: String,
    pub risk_level: RiskLevel,
    pub change_counts: serde_json::Value,
}

pub fn review_plan(analysis: &PlanAnalysis) -> PlanReview {
    let destructive_changes = changes_with_actions(&analysis.resource_changes, &["delete"]);
    let replacement_changes = changes_with_actions(
        &analysis.resource_changes,
        &["replace", "create_delete", "delete_create"],
    );

    let mut blockers = Vec::new();
    if analysis.risk_assessment.level == RiskLevel::Critical {
        blockers.push("Critical risk score requires explicit human approval".to_string());
    }
    if analysis.summary.destroy > 0 {
        blockers.push(format!(
            "{} resource(s) will be destroyed",
            analysis.summary.destroy
        ));
    }

    let mut warnings = analysis.risk_assessment.warnings.clone();
    if analysis.summary.replace > 0 {
        warnings.push(format!(
            "{} resource(s) will be replaced",
            analysis.summary.replace
        ));
    }

    let decision = if !blockers.is_empty() {
        PlanReviewDecision::Block
    } else if analysis.risk_assessment.level == RiskLevel::High || !warnings.is_empty() {
        PlanReviewDecision::ReviewRequired
    } else {
        PlanReviewDecision::Approve
    };

    let summary = format!(
        "{} add, {} change, {} destroy, {} replace",
        analysis.summary.add,
        analysis.summary.change,
        analysis.summary.destroy,
        analysis.summary.replace
    );

    let markdown = render_plan_review_markdown(
        &decision,
        &summary,
        analysis,
        &blockers,
        &warnings,
        &destructive_changes,
        &replacement_changes,
    );

    PlanReview {
        decision,
        summary,
        risk_level: analysis.risk_assessment.level.clone(),
        risk_score: analysis.risk_assessment.score,
        blockers,
        warnings,
        recommendations: analysis.risk_assessment.recommendations.clone(),
        destructive_changes,
        replacement_changes,
        markdown,
    }
}

pub fn summarize_plan_for_pr(analysis: &PlanAnalysis) -> PlanPrSummary {
    let review = review_plan(analysis);
    let title = format!(
        "Terraform plan: {} add, {} change, {} destroy, {} replace",
        analysis.summary.add,
        analysis.summary.change,
        analysis.summary.destroy,
        analysis.summary.replace
    );

    PlanPrSummary {
        title,
        markdown: review.markdown,
        risk_level: review.risk_level,
        change_counts: serde_json::json!({
            "add": analysis.summary.add,
            "change": analysis.summary.change,
            "destroy": analysis.summary.destroy,
            "replace": analysis.summary.replace,
            "no_op": analysis.summary.no_op
        }),
    }
}

fn changes_with_actions(changes: &[ResourceChange], actions: &[&str]) -> Vec<String> {
    changes
        .iter()
        .filter(|change| actions.iter().any(|action| change.action.contains(action)))
        .map(|change| change.address.clone())
        .collect()
}

fn render_plan_review_markdown(
    decision: &PlanReviewDecision,
    summary: &str,
    analysis: &PlanAnalysis,
    blockers: &[String],
    warnings: &[String],
    destructive_changes: &[String],
    replacement_changes: &[String],
) -> String {
    let mut lines = vec![
        "## Terraform Plan Review".to_string(),
        String::new(),
        format!("- Decision: `{:?}`", decision),
        format!(
            "- Risk: `{:?}` ({})",
            analysis.risk_assessment.level, analysis.risk_assessment.score
        ),
        format!("- Changes: {}", summary),
    ];

    push_section(&mut lines, "Blockers", blockers);
    push_section(&mut lines, "Warnings", warnings);
    push_section(
        &mut lines,
        "Recommendations",
        &analysis.risk_assessment.recommendations,
    );
    push_section(&mut lines, "Destructive Changes", destructive_changes);
    push_section(&mut lines, "Replacement Changes", replacement_changes);

    lines.join("\n")
}

fn push_section(lines: &mut Vec<String>, title: &str, items: &[String]) {
    if items.is_empty() {
        return;
    }

    lines.push(String::new());
    lines.push(format!("### {title}"));
    for item in items {
        lines.push(format!("- {item}"));
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::terraform::plan_analyzer::{
        ChangeSummary, DependencyImpact, PlanAnalysis, RiskAssessment,
    };

    #[test]
    fn review_blocks_destroy() {
        let analysis = plan_analysis("delete", RiskLevel::Medium);
        let review = review_plan(&analysis);

        assert_eq!(review.decision, PlanReviewDecision::Block);
        assert_eq!(review.destructive_changes, vec!["aws_s3_bucket.data"]);
        assert!(review.markdown.contains("Destructive Changes"));
    }

    #[test]
    fn pr_summary_contains_change_counts() {
        let analysis = plan_analysis("create", RiskLevel::Low);
        let summary = summarize_plan_for_pr(&analysis);

        assert!(summary.title.contains("1 add"));
        assert_eq!(summary.change_counts["add"], 1);
        assert!(summary.markdown.contains("Terraform Plan Review"));
    }

    fn plan_analysis(action: &str, risk_level: RiskLevel) -> PlanAnalysis {
        PlanAnalysis {
            summary: ChangeSummary {
                add: i32::from(action == "create"),
                change: i32::from(action == "update"),
                destroy: i32::from(action == "delete"),
                replace: i32::from(action == "replace"),
                no_op: 0,
            },
            resource_changes: vec![ResourceChange {
                address: "aws_s3_bucket.data".to_string(),
                resource_type: "aws_s3_bucket".to_string(),
                provider: "registry.terraform.io/hashicorp/aws".to_string(),
                action: action.to_string(),
                before: None,
                after: None,
                after_unknown: None,
            }],
            risk_assessment: RiskAssessment {
                level: risk_level,
                score: 20,
                warnings: Vec::new(),
                recommendations: vec!["Review state-sensitive resources".to_string()],
            },
            dependency_impacts: Vec::<DependencyImpact>::new(),
            terraform_version: Some("1.6.0".to_string()),
            format_version: Some("1.2".to_string()),
        }
    }
}