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>,
#[serde(default)]
pub changed_outputs: 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.detailed {
warnings.push(
"Attribute-level changes were not available; review a saved plan before applying"
.to_string(),
);
}
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()
|| analysis
.resource_changes
.iter()
.any(|change| change.action != "no-op" && change.action != "read")
|| analysis
.output_changes
.values()
.any(|change| change["actions"] != serde_json::json!(["no-op"]))
{
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 mut markdown = render_plan_review_markdown(
&decision,
&summary,
analysis,
&blockers,
&warnings,
&destructive_changes,
&replacement_changes,
);
let changed_outputs: Vec<String> = analysis
.output_changes
.iter()
.filter(|(_, change)| change["actions"] != serde_json::json!(["no-op"]))
.map(|(name, _)| name.clone())
.collect();
if !changed_outputs.is_empty() {
markdown.push_str("\n\n### Changed Outputs\n");
for name in &changed_outputs {
markdown.push_str(&format!("- `{name}`\n"));
}
}
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,
changed_outputs,
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(),
actions: vec![action.to_string()],
replace_paths: Vec::new(),
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()),
detailed: true,
output_changes: serde_json::Map::new(),
}
}
}