1use std::collections::BTreeMap;
26use std::path::Path;
27
28use serde::Serialize;
29use serde_json::Value;
30
31#[derive(Debug, Clone, Default, Serialize)]
36pub struct TerraformConfig {
37 #[serde(skip_serializing_if = "Option::is_none")]
38 terraform: Option<TerraformBlock>,
39 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
40 provider: BTreeMap<String, Value>,
41 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
42 resource: BTreeMap<String, BTreeMap<String, Value>>,
43 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
44 data: BTreeMap<String, BTreeMap<String, Value>>,
45 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
46 variable: BTreeMap<String, Value>,
47 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
48 output: BTreeMap<String, Value>,
49 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
50 locals: BTreeMap<String, Value>,
51 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
52 module: BTreeMap<String, Value>,
53}
54
55#[derive(Debug, Clone, Default, Serialize)]
57struct TerraformBlock {
58 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
59 required_providers: BTreeMap<String, ProviderRequirement>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 backend: Option<BTreeMap<String, Value>>,
62}
63
64#[derive(Debug, Clone, Serialize)]
66struct ProviderRequirement {
67 source: String,
68 version: String,
69}
70
71impl TerraformConfig {
72 #[must_use]
74 pub fn new() -> Self {
75 Self::default()
76 }
77
78 #[must_use]
87 pub fn required_provider(mut self, name: &str, source: &str, version: &str) -> Self {
88 let block = self.terraform.get_or_insert_with(TerraformBlock::default);
89 block.required_providers.insert(
90 name.to_string(),
91 ProviderRequirement {
92 source: source.to_string(),
93 version: version.to_string(),
94 },
95 );
96 self
97 }
98
99 #[must_use]
112 pub fn backend(mut self, backend_type: &str, config: Value) -> Self {
113 let block = self.terraform.get_or_insert_with(TerraformBlock::default);
114 let mut backend = BTreeMap::new();
115 backend.insert(backend_type.to_string(), config);
116 block.backend = Some(backend);
117 self
118 }
119
120 #[must_use]
129 pub fn provider(mut self, name: &str, config: Value) -> Self {
130 self.provider.insert(name.to_string(), config);
131 self
132 }
133
134 #[must_use]
146 pub fn resource(mut self, resource_type: &str, name: &str, config: Value) -> Self {
147 self.resource
148 .entry(resource_type.to_string())
149 .or_default()
150 .insert(name.to_string(), config);
151 self
152 }
153
154 #[must_use]
166 pub fn data(mut self, data_type: &str, name: &str, config: Value) -> Self {
167 self.data
168 .entry(data_type.to_string())
169 .or_default()
170 .insert(name.to_string(), config);
171 self
172 }
173
174 #[must_use]
187 pub fn variable(mut self, name: &str, config: Value) -> Self {
188 self.variable.insert(name.to_string(), config);
189 self
190 }
191
192 #[must_use]
204 pub fn output(mut self, name: &str, config: Value) -> Self {
205 self.output.insert(name.to_string(), config);
206 self
207 }
208
209 #[must_use]
221 pub fn local(mut self, name: &str, value: Value) -> Self {
222 self.locals.insert(name.to_string(), value);
223 self
224 }
225
226 #[must_use]
239 pub fn module(mut self, name: &str, config: Value) -> Self {
240 self.module.insert(name.to_string(), config);
241 self
242 }
243
244 pub fn to_json(&self) -> serde_json::Result<String> {
246 serde_json::to_string(self)
247 }
248
249 pub fn to_json_pretty(&self) -> serde_json::Result<String> {
251 serde_json::to_string_pretty(self)
252 }
253
254 pub fn write_to(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
258 let path = path.as_ref();
259 if let Some(parent) = path.parent() {
260 std::fs::create_dir_all(parent)?;
261 }
262 let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
263 std::fs::write(path, json)
264 }
265
266 pub fn write_to_tempdir(&self) -> std::io::Result<tempfile::TempDir> {
271 let dir = tempfile::tempdir()?;
272 self.write_to(dir.path().join("main.tf.json"))?;
273 Ok(dir)
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use serde_json::json;
281
282 #[test]
283 fn empty_config() {
284 let config = TerraformConfig::new();
285 let json = config.to_json().unwrap();
286 assert_eq!(json, "{}");
287 }
288
289 #[test]
290 fn required_provider() {
291 let config = TerraformConfig::new().required_provider("aws", "hashicorp/aws", "~> 5.0");
292 let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
293 assert_eq!(
294 val["terraform"]["required_providers"]["aws"]["source"],
295 "hashicorp/aws"
296 );
297 assert_eq!(
298 val["terraform"]["required_providers"]["aws"]["version"],
299 "~> 5.0"
300 );
301 }
302
303 #[test]
304 fn full_config() {
305 let config = TerraformConfig::new()
306 .required_provider("null", "hashicorp/null", "~> 3.0")
307 .provider("null", json!({}))
308 .resource(
309 "null_resource",
310 "example",
311 json!({
312 "triggers": { "value": "hello" }
313 }),
314 )
315 .variable("name", json!({ "type": "string", "default": "world" }))
316 .output("id", json!({ "value": "${null_resource.example.id}" }))
317 .local("tag", json!("test"));
318
319 let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
320 assert!(val["resource"]["null_resource"]["example"].is_object());
321 assert_eq!(val["variable"]["name"]["default"], "world");
322 assert_eq!(val["output"]["id"]["value"], "${null_resource.example.id}");
323 assert_eq!(val["locals"]["tag"], "test");
324 }
325
326 #[test]
327 fn multiple_resources_same_type() {
328 let config = TerraformConfig::new()
329 .resource("null_resource", "a", json!({}))
330 .resource("null_resource", "b", json!({}));
331 let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
332 assert!(val["resource"]["null_resource"]["a"].is_object());
333 assert!(val["resource"]["null_resource"]["b"].is_object());
334 }
335
336 #[test]
337 fn data_source() {
338 let config =
339 TerraformConfig::new().data("aws_ami", "latest", json!({ "most_recent": true }));
340 let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
341 assert_eq!(val["data"]["aws_ami"]["latest"]["most_recent"], true);
342 }
343
344 #[test]
345 fn backend() {
346 let config = TerraformConfig::new().backend("s3", json!({ "bucket": "my-state" }));
347 let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
348 assert_eq!(val["terraform"]["backend"]["s3"]["bucket"], "my-state");
349 }
350
351 #[test]
352 fn module_block() {
353 let config = TerraformConfig::new().module(
354 "vpc",
355 json!({
356 "source": "terraform-aws-modules/vpc/aws",
357 "version": "~> 5.0",
358 "cidr": "10.0.0.0/16"
359 }),
360 );
361 let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
362 assert_eq!(
363 val["module"]["vpc"]["source"],
364 "terraform-aws-modules/vpc/aws"
365 );
366 assert_eq!(val["module"]["vpc"]["version"], "~> 5.0");
367 assert_eq!(val["module"]["vpc"]["cidr"], "10.0.0.0/16");
368 }
369
370 #[test]
371 fn multiple_modules() {
372 let config = TerraformConfig::new()
373 .module(
374 "vpc",
375 json!({
376 "source": "terraform-aws-modules/vpc/aws",
377 "version": "~> 5.0"
378 }),
379 )
380 .module(
381 "eks",
382 json!({
383 "source": "terraform-aws-modules/eks/aws",
384 "version": "~> 19.0"
385 }),
386 );
387 let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
388 assert_eq!(
389 val["module"]["vpc"]["source"],
390 "terraform-aws-modules/vpc/aws"
391 );
392 assert_eq!(
393 val["module"]["eks"]["source"],
394 "terraform-aws-modules/eks/aws"
395 );
396 }
397
398 #[test]
399 fn write_to_tempdir() {
400 let config = TerraformConfig::new()
401 .required_provider("null", "hashicorp/null", "~> 3.0")
402 .resource("null_resource", "test", json!({}));
403
404 let dir = config.write_to_tempdir().unwrap();
405 let path = dir.path().join("main.tf.json");
406 assert!(path.exists());
407
408 let contents = std::fs::read_to_string(&path).unwrap();
409 let val: Value = serde_json::from_str(&contents).unwrap();
410 assert!(val["resource"]["null_resource"]["test"].is_object());
411 }
412}