Skip to main content

terraform_wrapper/
config.rs

1//! Terraform configuration builder for generating `.tf.json` files.
2//!
3//! Terraform natively accepts [JSON configuration syntax](https://developer.hashicorp.com/terraform/language/syntax/json)
4//! alongside HCL. This module provides a builder API to construct configs
5//! entirely in Rust and serialize them to `.tf.json`.
6//!
7//! Supported block types: [`required_provider`](TerraformConfig::required_provider),
8//! [`backend`](TerraformConfig::backend), [`provider`](TerraformConfig::provider),
9//! [`variable`](TerraformConfig::variable), [`data`](TerraformConfig::data),
10//! [`resource`](TerraformConfig::resource), [`local`](TerraformConfig::local),
11//! [`module`](TerraformConfig::module), [`output`](TerraformConfig::output).
12//!
13//! # Example
14//!
15//! ```rust
16//! use terraform_wrapper::config::TerraformConfig;
17//! use serde_json::json;
18//!
19//! let config = TerraformConfig::new()
20//!     .required_provider("null", "hashicorp/null", "~> 3.0")
21//!     .provider("null", json!({}))
22//!     .variable("name", json!({ "type": "string", "default": "world" }))
23//!     .data("null_data_source", "values", json!({
24//!         "inputs": { "greeting": "hello" }
25//!     }))
26//!     .resource("null_resource", "example", json!({
27//!         "triggers": { "value": "hello" }
28//!     }))
29//!     .local("tag", json!("test"))
30//!     .module("child", json!({ "source": "./modules/child" }))
31//!     .output("id", json!({ "value": "${null_resource.example.id}" }));
32//!
33//! let json = config.to_json_pretty().unwrap();
34//! assert!(json.contains("null_resource"));
35//! ```
36
37use std::collections::BTreeMap;
38use std::path::Path;
39
40use serde::Serialize;
41use serde_json::Value;
42
43/// Builder for constructing Terraform JSON configuration.
44///
45/// Produces a `.tf.json` file that Terraform can process identically
46/// to an HCL `.tf` file.
47#[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/// The `terraform` block (required_providers, backend, etc.).
68#[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/// A provider requirement in the `required_providers` block.
77#[derive(Debug, Clone, Serialize)]
78struct ProviderRequirement {
79    source: String,
80    version: String,
81}
82
83impl TerraformConfig {
84    /// Create a new empty configuration.
85    #[must_use]
86    pub fn new() -> Self {
87        Self::default()
88    }
89
90    /// Add a required provider.
91    ///
92    /// ```rust
93    /// # use terraform_wrapper::config::TerraformConfig;
94    /// let config = TerraformConfig::new()
95    ///     .required_provider("aws", "hashicorp/aws", "~> 5.0")
96    ///     .required_provider("null", "hashicorp/null", "~> 3.0");
97    /// ```
98    #[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    /// Configure a backend for remote state storage.
112    ///
113    /// ```rust
114    /// # use terraform_wrapper::config::TerraformConfig;
115    /// # use serde_json::json;
116    /// let config = TerraformConfig::new()
117    ///     .backend("s3", json!({
118    ///         "bucket": "my-tf-state",
119    ///         "key": "terraform.tfstate",
120    ///         "region": "us-west-2"
121    ///     }));
122    /// ```
123    #[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    /// Configure a provider.
133    ///
134    /// ```rust
135    /// # use terraform_wrapper::config::TerraformConfig;
136    /// # use serde_json::json;
137    /// let config = TerraformConfig::new()
138    ///     .provider("aws", json!({ "region": "us-west-2" }));
139    /// ```
140    #[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    /// Add a managed resource.
147    ///
148    /// ```rust
149    /// # use terraform_wrapper::config::TerraformConfig;
150    /// # use serde_json::json;
151    /// let config = TerraformConfig::new()
152    ///     .resource("aws_instance", "web", json!({
153    ///         "ami": "ami-0c55b159",
154    ///         "instance_type": "t3.micro"
155    ///     }));
156    /// ```
157    #[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    /// Add a data source.
167    ///
168    /// ```rust
169    /// # use terraform_wrapper::config::TerraformConfig;
170    /// # use serde_json::json;
171    /// let config = TerraformConfig::new()
172    ///     .data("aws_ami", "latest", json!({
173    ///         "most_recent": true,
174    ///         "owners": ["amazon"]
175    ///     }));
176    /// ```
177    #[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    /// Add a variable.
187    ///
188    /// ```rust
189    /// # use terraform_wrapper::config::TerraformConfig;
190    /// # use serde_json::json;
191    /// let config = TerraformConfig::new()
192    ///     .variable("region", json!({
193    ///         "type": "string",
194    ///         "default": "us-west-2",
195    ///         "description": "AWS region"
196    ///     }));
197    /// ```
198    #[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    /// Add an output.
205    ///
206    /// ```rust
207    /// # use terraform_wrapper::config::TerraformConfig;
208    /// # use serde_json::json;
209    /// let config = TerraformConfig::new()
210    ///     .output("instance_id", json!({
211    ///         "value": "${aws_instance.web.id}",
212    ///         "description": "The instance ID"
213    ///     }));
214    /// ```
215    #[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    /// Add a local value.
222    ///
223    /// ```rust
224    /// # use terraform_wrapper::config::TerraformConfig;
225    /// # use serde_json::json;
226    /// let config = TerraformConfig::new()
227    ///     .local("common_tags", json!({
228    ///         "Environment": "production",
229    ///         "ManagedBy": "terraform-wrapper"
230    ///     }));
231    /// ```
232    #[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    /// Add a module reference.
239    ///
240    /// ```rust
241    /// # use terraform_wrapper::config::TerraformConfig;
242    /// # use serde_json::json;
243    /// let config = TerraformConfig::new()
244    ///     .module("vpc", json!({
245    ///         "source": "terraform-aws-modules/vpc/aws",
246    ///         "version": "~> 5.0",
247    ///         "cidr": "10.0.0.0/16"
248    ///     }));
249    /// ```
250    #[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    /// Serialize to a JSON string.
257    pub fn to_json(&self) -> serde_json::Result<String> {
258        serde_json::to_string(self)
259    }
260
261    /// Serialize to a pretty-printed JSON string.
262    pub fn to_json_pretty(&self) -> serde_json::Result<String> {
263        serde_json::to_string_pretty(self)
264    }
265
266    /// Write the configuration to a file as `main.tf.json`.
267    ///
268    /// Creates parent directories if they don't exist.
269    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    /// Write the configuration to a temporary directory as `main.tf.json`.
279    ///
280    /// Returns the `TempDir` which will be cleaned up when dropped.
281    /// Use `.path()` to get the directory path for `Terraform::builder().working_dir()`.
282    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}