terraform_plan_formatter/
lib.rs1use 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}