use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
#[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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefreshChange {
pub resource_address: String,
pub change_type: RefreshChangeType,
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RefreshChangeType {
Updated,
Drifted,
Unchanged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshMode {
Apply,
Preview,
}
impl RefreshMode {
fn command(&self) -> &'static str {
match self {
Self::Apply => "apply",
Self::Preview => "plan",
}
}
fn error_prefix(&self) -> &'static str {
match self {
Self::Apply => "Refresh failed",
Self::Preview => "Refresh preview failed",
}
}
fn counted_change_type(&self) -> RefreshChangeType {
match self {
Self::Apply => RefreshChangeType::Updated,
Self::Preview => RefreshChangeType::Drifted,
}
}
fn message(&self, resources_updated: i32) -> String {
match self {
Self::Apply if resources_updated > 0 => {
format!("Refreshed {resources_updated} resources")
}
Self::Apply => "No resources needed refreshing".to_string(),
Self::Preview if resources_updated > 0 => {
format!("{resources_updated} resources have drifted and would be updated")
}
Self::Preview => "No drift detected - state is up to date".to_string(),
}
}
}
pub fn refresh(
terraform_path: &Path,
project_dir: &Path,
target: Option<&str>,
mode: RefreshMode,
) -> anyhow::Result<RefreshResult> {
let mut cmd = Command::new(terraform_path);
cmd.arg(mode.command()).arg("-refresh-only");
if matches!(mode, RefreshMode::Apply) {
cmd.arg("-auto-approve");
}
cmd.arg("-json");
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!("{}: {}", mode.error_prefix(), stderr));
}
let changes = parse_refresh_output(&stdout);
let counted_type = mode.counted_change_type();
let resources_updated = changes
.iter()
.filter(|c| c.change_type == counted_type)
.count() as i32;
let message = mode.message(resources_updated);
Ok(RefreshResult {
success: true,
resources_updated,
output: stdout.to_string(),
changes,
message,
})
}
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) {
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" => {
}
_ => {}
}
}
}
}
changes
}
#[allow(dead_code)]
pub fn get_stale_resources(
terraform_path: &Path,
project_dir: &Path,
) -> anyhow::Result<Vec<String>> {
let result = refresh(terraform_path, project_dir, None, RefreshMode::Preview)?;
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);
}
}