terraform_plan_formatter/
lib.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3
4#[derive(Deserialize, Debug, Clone)]
5pub struct TerraformPlan {
6    pub resource_changes: Vec<ResourceChange>,
7}
8
9#[derive(Deserialize, Debug, Clone)]
10pub struct ResourceChange {
11    pub address: String,
12    pub change: Change,
13}
14
15#[derive(Deserialize, Debug, Clone)]
16pub struct Change {
17    pub actions: Vec<String>,
18    pub before: Option<serde_json::Value>,
19    pub after: Option<serde_json::Value>,
20}
21
22pub fn format_plan(plan: &TerraformPlan, collapsed: bool) -> String {
23    let mut output = String::new();
24    let mut counts = HashMap::new();
25
26    for change in &plan.resource_changes {
27        let action = get_action(&change.change.actions);
28        *counts.entry(action).or_insert(0) += 1;
29        output.push_str(&format_resource_change(change, collapsed));
30    }
31
32    output.push_str(&format_summary(&counts));
33    output
34}
35
36fn get_action(actions: &[String]) -> &'static str {
37    match actions {
38        [action] if action == "create" => "create",
39        [action] if action == "update" => "update",
40        [action] if action == "delete" => "delete",
41        [a1, a2] if a1 == "delete" && a2 == "create" => "replace",
42        _ => "unknown",
43    }
44}
45
46fn format_resource_change(change: &ResourceChange, collapsed: bool) -> String {
47    let action = get_action(&change.change.actions);
48    let (symbol, _color) = match action {
49        "create" => ("+", "green"),
50        "update" => ("~", "yellow"),
51        "delete" => ("-", "red"),
52        "replace" => ("-/+", "yellow"),
53        _ => ("?", "white"),
54    };
55
56    let indicator = if collapsed { "▶" } else { "▼" };
57    let mut output = format!(
58        "{} {} {} will be {}\n",
59        indicator, symbol, change.address, action
60    );
61
62    if !collapsed {
63        output.push_str(&format_changes(&change.change, action));
64    }
65    output.push('\n');
66    output
67}
68
69fn format_changes(change: &Change, action: &str) -> String {
70    let mut output = String::new();
71
72    if action == "update" || action == "replace" {
73        if let (Some(before), Some(after)) = (&change.before, &change.after) {
74            if let (Some(before_obj), Some(after_obj)) = (before.as_object(), after.as_object()) {
75                for (key, after_val) in after_obj {
76                    if let Some(before_val) = before_obj.get(key) {
77                        if before_val != after_val {
78                            output.push_str(&format!(
79                                "        {}: {} => {}\n",
80                                key,
81                                format_value(before_val),
82                                format_value(after_val)
83                            ));
84                        }
85                    } else {
86                        output.push_str(&format!("        {}: {}\n", key, format_value(after_val)));
87                    }
88                }
89            }
90        }
91    } else if action == "create" {
92        if let Some(after) = &change.after {
93            if let Some(after_obj) = after.as_object() {
94                for (key, val) in after_obj {
95                    output.push_str(&format!("        {}: {}\n", key, format_value(val)));
96                }
97            }
98        }
99    }
100    output
101}
102
103fn format_value(value: &serde_json::Value) -> String {
104    match value {
105        serde_json::Value::String(s) => format!("\"{}\"", s),
106        serde_json::Value::Number(n) => n.to_string(),
107        serde_json::Value::Bool(b) => b.to_string(),
108        serde_json::Value::Null => "null".to_string(),
109        _ => value.to_string(),
110    }
111}
112
113fn format_summary(counts: &HashMap<&str, i32>) -> String {
114    let create_count = counts.get("create").unwrap_or(&0);
115    let update_count = counts.get("update").unwrap_or(&0);
116    let replace_count = counts.get("replace").unwrap_or(&0);
117    let delete_count = counts.get("delete").unwrap_or(&0);
118
119    let total_changes = create_count + update_count + replace_count + delete_count;
120
121    if total_changes == 0 {
122        "No changes. Your infrastructure matches the configuration.\n".to_string()
123    } else {
124        format!(
125            "Plan: {} to add, {} to change, {} to destroy.\n",
126            create_count,
127            update_count + replace_count,
128            delete_count
129        )
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use serde_json::json;
137
138    fn create_test_plan() -> TerraformPlan {
139        TerraformPlan {
140            resource_changes: vec![
141                ResourceChange {
142                    address: "aws_instance.web".to_string(),
143                    change: Change {
144                        actions: vec!["create".to_string()],
145                        before: None,
146                        after: Some(json!({
147                            "ami": "ami-12345678",
148                            "instance_type": "t3.micro"
149                        })),
150                    },
151                },
152                ResourceChange {
153                    address: "aws_s3_bucket.data".to_string(),
154                    change: Change {
155                        actions: vec!["update".to_string()],
156                        before: Some(json!({
157                            "encryption": null,
158                            "versioning": false
159                        })),
160                        after: Some(json!({
161                            "encryption": "AES256",
162                            "versioning": true
163                        })),
164                    },
165                },
166            ],
167        }
168    }
169
170    #[test]
171    fn test_get_action() {
172        assert_eq!(get_action(&["create".to_string()]), "create");
173        assert_eq!(get_action(&["update".to_string()]), "update");
174        assert_eq!(get_action(&["delete".to_string()]), "delete");
175        assert_eq!(
176            get_action(&["delete".to_string(), "create".to_string()]),
177            "replace"
178        );
179        assert_eq!(get_action(&["unknown".to_string()]), "unknown");
180    }
181
182    #[test]
183    fn test_format_value() {
184        assert_eq!(format_value(&json!("test")), "\"test\"");
185        assert_eq!(format_value(&json!(42)), "42");
186        assert_eq!(format_value(&json!(true)), "true");
187        assert_eq!(format_value(&json!(null)), "null");
188    }
189
190    #[test]
191    fn test_format_plan_collapsed() {
192        let plan = create_test_plan();
193        let output = format_plan(&plan, true);
194
195        assert!(output.contains("▶ + aws_instance.web will be create"));
196        assert!(output.contains("▶ ~ aws_s3_bucket.data will be update"));
197        assert!(output.contains("Plan: 1 to add, 1 to change, 0 to destroy"));
198        assert!(!output.contains("ami:"));
199    }
200
201    #[test]
202    fn test_format_plan_expanded() {
203        let plan = create_test_plan();
204        let output = format_plan(&plan, false);
205
206        assert!(output.contains("▼ + aws_instance.web will be create"));
207        assert!(output.contains("ami: \"ami-12345678\""));
208        assert!(output.contains("encryption: null => \"AES256\""));
209        assert!(output.contains("versioning: false => true"));
210    }
211
212    #[test]
213    fn test_empty_plan() {
214        let plan = TerraformPlan {
215            resource_changes: vec![],
216        };
217        let output = format_plan(&plan, false);
218
219        assert!(output.contains("No changes. Your infrastructure matches the configuration"));
220    }
221
222    #[test]
223    fn test_format_summary() {
224        let mut counts = HashMap::new();
225        counts.insert("create", 2);
226        counts.insert("update", 1);
227        counts.insert("delete", 1);
228
229        let summary = format_summary(&counts);
230        assert!(summary.contains("Plan: 2 to add, 1 to change, 1 to destroy"));
231    }
232
233    #[test]
234    fn test_format_resource_change_create() {
235        let change = ResourceChange {
236            address: "aws_instance.test".to_string(),
237            change: Change {
238                actions: vec!["create".to_string()],
239                before: None,
240                after: Some(json!({"ami": "ami-123"})),
241            },
242        };
243
244        let output = format_resource_change(&change, false);
245        assert!(output.contains("▼ + aws_instance.test will be create"));
246        assert!(output.contains("ami: \"ami-123\""));
247    }
248
249    #[test]
250    fn test_format_resource_change_update() {
251        let change = ResourceChange {
252            address: "aws_instance.test".to_string(),
253            change: Change {
254                actions: vec!["update".to_string()],
255                before: Some(json!({"size": "small"})),
256                after: Some(json!({"size": "large"})),
257            },
258        };
259
260        let output = format_resource_change(&change, false);
261        assert!(output.contains("▼ ~ aws_instance.test will be update"));
262        assert!(output.contains("size: \"small\" => \"large\""));
263    }
264
265    #[test]
266    fn test_format_resource_change_delete() {
267        let change = ResourceChange {
268            address: "aws_instance.test".to_string(),
269            change: Change {
270                actions: vec!["delete".to_string()],
271                before: Some(json!({"ami": "ami-123"})),
272                after: None,
273            },
274        };
275
276        let output = format_resource_change(&change, false);
277        assert!(output.contains("▼ - aws_instance.test will be delete"));
278    }
279
280    #[test]
281    fn test_format_resource_change_replace() {
282        let change = ResourceChange {
283            address: "aws_instance.test".to_string(),
284            change: Change {
285                actions: vec!["delete".to_string(), "create".to_string()],
286                before: Some(json!({"ami": "ami-old"})),
287                after: Some(json!({"ami": "ami-new"})),
288            },
289        };
290
291        let output = format_resource_change(&change, false);
292        assert!(output.contains("▼ -/+ aws_instance.test will be replace"));
293        assert!(output.contains("ami: \"ami-old\" => \"ami-new\""));
294    }
295}