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//! # Example
8//!
9//! ```rust
10//! use terraform_wrapper::config::TerraformConfig;
11//! use serde_json::json;
12//!
13//! let config = TerraformConfig::new()
14//!     .required_provider("null", "hashicorp/null", "~> 3.0")
15//!     .resource("null_resource", "example", json!({
16//!         "triggers": { "value": "hello" }
17//!     }))
18//!     .variable("name", json!({ "type": "string", "default": "world" }))
19//!     .output("id", json!({ "value": "${null_resource.example.id}" }));
20//!
21//! let json = config.to_json_pretty().unwrap();
22//! assert!(json.contains("null_resource"));
23//! ```
24
25use std::collections::BTreeMap;
26use std::path::Path;
27
28use serde::Serialize;
29use serde_json::Value;
30
31/// Builder for constructing Terraform JSON configuration.
32///
33/// Produces a `.tf.json` file that Terraform can process identically
34/// to an HCL `.tf` file.
35#[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/// The `terraform` block (required_providers, backend, etc.).
56#[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/// A provider requirement in the `required_providers` block.
65#[derive(Debug, Clone, Serialize)]
66struct ProviderRequirement {
67    source: String,
68    version: String,
69}
70
71impl TerraformConfig {
72    /// Create a new empty configuration.
73    #[must_use]
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Add a required provider.
79    ///
80    /// ```rust
81    /// # use terraform_wrapper::config::TerraformConfig;
82    /// let config = TerraformConfig::new()
83    ///     .required_provider("aws", "hashicorp/aws", "~> 5.0")
84    ///     .required_provider("null", "hashicorp/null", "~> 3.0");
85    /// ```
86    #[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    /// Configure a backend for remote state storage.
100    ///
101    /// ```rust
102    /// # use terraform_wrapper::config::TerraformConfig;
103    /// # use serde_json::json;
104    /// let config = TerraformConfig::new()
105    ///     .backend("s3", json!({
106    ///         "bucket": "my-tf-state",
107    ///         "key": "terraform.tfstate",
108    ///         "region": "us-west-2"
109    ///     }));
110    /// ```
111    #[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    /// Configure a provider.
121    ///
122    /// ```rust
123    /// # use terraform_wrapper::config::TerraformConfig;
124    /// # use serde_json::json;
125    /// let config = TerraformConfig::new()
126    ///     .provider("aws", json!({ "region": "us-west-2" }));
127    /// ```
128    #[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    /// Add a managed resource.
135    ///
136    /// ```rust
137    /// # use terraform_wrapper::config::TerraformConfig;
138    /// # use serde_json::json;
139    /// let config = TerraformConfig::new()
140    ///     .resource("aws_instance", "web", json!({
141    ///         "ami": "ami-0c55b159",
142    ///         "instance_type": "t3.micro"
143    ///     }));
144    /// ```
145    #[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    /// Add a data source.
155    ///
156    /// ```rust
157    /// # use terraform_wrapper::config::TerraformConfig;
158    /// # use serde_json::json;
159    /// let config = TerraformConfig::new()
160    ///     .data("aws_ami", "latest", json!({
161    ///         "most_recent": true,
162    ///         "owners": ["amazon"]
163    ///     }));
164    /// ```
165    #[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    /// Add a variable.
175    ///
176    /// ```rust
177    /// # use terraform_wrapper::config::TerraformConfig;
178    /// # use serde_json::json;
179    /// let config = TerraformConfig::new()
180    ///     .variable("region", json!({
181    ///         "type": "string",
182    ///         "default": "us-west-2",
183    ///         "description": "AWS region"
184    ///     }));
185    /// ```
186    #[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    /// Add an output.
193    ///
194    /// ```rust
195    /// # use terraform_wrapper::config::TerraformConfig;
196    /// # use serde_json::json;
197    /// let config = TerraformConfig::new()
198    ///     .output("instance_id", json!({
199    ///         "value": "${aws_instance.web.id}",
200    ///         "description": "The instance ID"
201    ///     }));
202    /// ```
203    #[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    /// Add a local value.
210    ///
211    /// ```rust
212    /// # use terraform_wrapper::config::TerraformConfig;
213    /// # use serde_json::json;
214    /// let config = TerraformConfig::new()
215    ///     .local("common_tags", json!({
216    ///         "Environment": "production",
217    ///         "ManagedBy": "terraform-wrapper"
218    ///     }));
219    /// ```
220    #[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    /// Add a module reference.
227    ///
228    /// ```rust
229    /// # use terraform_wrapper::config::TerraformConfig;
230    /// # use serde_json::json;
231    /// let config = TerraformConfig::new()
232    ///     .module("vpc", json!({
233    ///         "source": "terraform-aws-modules/vpc/aws",
234    ///         "version": "~> 5.0",
235    ///         "cidr": "10.0.0.0/16"
236    ///     }));
237    /// ```
238    #[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    /// Serialize to a JSON string.
245    pub fn to_json(&self) -> serde_json::Result<String> {
246        serde_json::to_string(self)
247    }
248
249    /// Serialize to a pretty-printed JSON string.
250    pub fn to_json_pretty(&self) -> serde_json::Result<String> {
251        serde_json::to_string_pretty(self)
252    }
253
254    /// Write the configuration to a file as `main.tf.json`.
255    ///
256    /// Creates parent directories if they don't exist.
257    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    /// Write the configuration to a temporary directory as `main.tf.json`.
267    ///
268    /// Returns the `TempDir` which will be cleaned up when dropped.
269    /// Use `.path()` to get the directory path for `Terraform::builder().working_dir()`.
270    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}