1use std::collections::BTreeMap;
38use std::path::Path;
39
40use serde::Serialize;
41use serde_json::Value;
42
43#[derive(Debug, Clone, Default, Serialize)]
48pub struct TerraformConfig {
49 #[serde(skip_serializing_if = "Option::is_none")]
50 terraform: Option<TerraformBlock>,
51 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
52 provider: BTreeMap<String, Value>,
53 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
54 resource: BTreeMap<String, BTreeMap<String, Value>>,
55 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
56 data: BTreeMap<String, BTreeMap<String, Value>>,
57 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
58 variable: BTreeMap<String, Value>,
59 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
60 output: BTreeMap<String, Value>,
61 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
62 locals: BTreeMap<String, Value>,
63 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
64 module: BTreeMap<String, Value>,
65}
66
67#[derive(Debug, Clone, Default, Serialize)]
69struct TerraformBlock {
70 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
71 required_providers: BTreeMap<String, ProviderRequirement>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 backend: Option<BTreeMap<String, Value>>,
74}
75
76#[derive(Debug, Clone, Serialize)]
78struct ProviderRequirement {
79 source: String,
80 version: String,
81}
82
83impl TerraformConfig {
84 #[must_use]
86 pub fn new() -> Self {
87 Self::default()
88 }
89
90 #[must_use]
99 pub fn required_provider(mut self, name: &str, source: &str, version: &str) -> Self {
100 let block = self.terraform.get_or_insert_with(TerraformBlock::default);
101 block.required_providers.insert(
102 name.to_string(),
103 ProviderRequirement {
104 source: source.to_string(),
105 version: version.to_string(),
106 },
107 );
108 self
109 }
110
111 #[must_use]
124 pub fn backend(mut self, backend_type: &str, config: Value) -> Self {
125 let block = self.terraform.get_or_insert_with(TerraformBlock::default);
126 let mut backend = BTreeMap::new();
127 backend.insert(backend_type.to_string(), config);
128 block.backend = Some(backend);
129 self
130 }
131
132 #[must_use]
141 pub fn provider(mut self, name: &str, config: Value) -> Self {
142 self.provider.insert(name.to_string(), config);
143 self
144 }
145
146 #[must_use]
158 pub fn resource(mut self, resource_type: &str, name: &str, config: Value) -> Self {
159 self.resource
160 .entry(resource_type.to_string())
161 .or_default()
162 .insert(name.to_string(), config);
163 self
164 }
165
166 #[must_use]
178 pub fn data(mut self, data_type: &str, name: &str, config: Value) -> Self {
179 self.data
180 .entry(data_type.to_string())
181 .or_default()
182 .insert(name.to_string(), config);
183 self
184 }
185
186 #[must_use]
199 pub fn variable(mut self, name: &str, config: Value) -> Self {
200 self.variable.insert(name.to_string(), config);
201 self
202 }
203
204 #[must_use]
216 pub fn output(mut self, name: &str, config: Value) -> Self {
217 self.output.insert(name.to_string(), config);
218 self
219 }
220
221 #[must_use]
233 pub fn local(mut self, name: &str, value: Value) -> Self {
234 self.locals.insert(name.to_string(), value);
235 self
236 }
237
238 #[must_use]
251 pub fn module(mut self, name: &str, config: Value) -> Self {
252 self.module.insert(name.to_string(), config);
253 self
254 }
255
256 pub fn to_json(&self) -> serde_json::Result<String> {
258 serde_json::to_string(self)
259 }
260
261 pub fn to_json_pretty(&self) -> serde_json::Result<String> {
263 serde_json::to_string_pretty(self)
264 }
265
266 pub fn write_to(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
270 let path = path.as_ref();
271 if let Some(parent) = path.parent() {
272 std::fs::create_dir_all(parent)?;
273 }
274 let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
275 std::fs::write(path, json)
276 }
277
278 pub fn write_to_tempdir(&self) -> std::io::Result<tempfile::TempDir> {
283 let dir = tempfile::tempdir()?;
284 self.write_to(dir.path().join("main.tf.json"))?;
285 Ok(dir)
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use serde_json::json;
293
294 #[test]
295 fn empty_config() {
296 let config = TerraformConfig::new();
297 let json = config.to_json().unwrap();
298 assert_eq!(json, "{}");
299 }
300
301 #[test]
302 fn required_provider() {
303 let config = TerraformConfig::new().required_provider("aws", "hashicorp/aws", "~> 5.0");
304 let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
305 assert_eq!(
306 val["terraform"]["required_providers"]["aws"]["source"],
307 "hashicorp/aws"
308 );
309 assert_eq!(
310 val["terraform"]["required_providers"]["aws"]["version"],
311 "~> 5.0"
312 );
313 }
314
315 #[test]
316 fn full_config() {
317 let config = TerraformConfig::new()
318 .required_provider("null", "hashicorp/null", "~> 3.0")
319 .provider("null", json!({}))
320 .resource(
321 "null_resource",
322 "example",
323 json!({
324 "triggers": { "value": "hello" }
325 }),
326 )
327 .variable("name", json!({ "type": "string", "default": "world" }))
328 .output("id", json!({ "value": "${null_resource.example.id}" }))
329 .local("tag", json!("test"));
330
331 let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
332 assert!(val["resource"]["null_resource"]["example"].is_object());
333 assert_eq!(val["variable"]["name"]["default"], "world");
334 assert_eq!(val["output"]["id"]["value"], "${null_resource.example.id}");
335 assert_eq!(val["locals"]["tag"], "test");
336 }
337
338 #[test]
339 fn multiple_resources_same_type() {
340 let config = TerraformConfig::new()
341 .resource("null_resource", "a", json!({}))
342 .resource("null_resource", "b", json!({}));
343 let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
344 assert!(val["resource"]["null_resource"]["a"].is_object());
345 assert!(val["resource"]["null_resource"]["b"].is_object());
346 }
347
348 #[test]
349 fn data_source() {
350 let config =
351 TerraformConfig::new().data("aws_ami", "latest", json!({ "most_recent": true }));
352 let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
353 assert_eq!(val["data"]["aws_ami"]["latest"]["most_recent"], true);
354 }
355
356 #[test]
357 fn backend() {
358 let config = TerraformConfig::new().backend("s3", json!({ "bucket": "my-state" }));
359 let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
360 assert_eq!(val["terraform"]["backend"]["s3"]["bucket"], "my-state");
361 }
362
363 #[test]
364 fn module_block() {
365 let config = TerraformConfig::new().module(
366 "vpc",
367 json!({
368 "source": "terraform-aws-modules/vpc/aws",
369 "version": "~> 5.0",
370 "cidr": "10.0.0.0/16"
371 }),
372 );
373 let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
374 assert_eq!(
375 val["module"]["vpc"]["source"],
376 "terraform-aws-modules/vpc/aws"
377 );
378 assert_eq!(val["module"]["vpc"]["version"], "~> 5.0");
379 assert_eq!(val["module"]["vpc"]["cidr"], "10.0.0.0/16");
380 }
381
382 #[test]
383 fn multiple_modules() {
384 let config = TerraformConfig::new()
385 .module(
386 "vpc",
387 json!({
388 "source": "terraform-aws-modules/vpc/aws",
389 "version": "~> 5.0"
390 }),
391 )
392 .module(
393 "eks",
394 json!({
395 "source": "terraform-aws-modules/eks/aws",
396 "version": "~> 19.0"
397 }),
398 );
399 let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
400 assert_eq!(
401 val["module"]["vpc"]["source"],
402 "terraform-aws-modules/vpc/aws"
403 );
404 assert_eq!(
405 val["module"]["eks"]["source"],
406 "terraform-aws-modules/eks/aws"
407 );
408 }
409
410 #[test]
411 fn write_to_tempdir() {
412 let config = TerraformConfig::new()
413 .required_provider("null", "hashicorp/null", "~> 3.0")
414 .resource("null_resource", "test", json!({}));
415
416 let dir = config.write_to_tempdir().unwrap();
417 let path = dir.path().join("main.tf.json");
418 assert!(path.exists());
419
420 let contents = std::fs::read_to_string(&path).unwrap();
421 let val: Value = serde_json::from_str(&contents).unwrap();
422 assert!(val["resource"]["null_resource"]["test"].is_object());
423 }
424}