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}
52
53/// The `terraform` block (required_providers, backend, etc.).
54#[derive(Debug, Clone, Default, Serialize)]
55struct TerraformBlock {
56    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
57    required_providers: BTreeMap<String, ProviderRequirement>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    backend: Option<BTreeMap<String, Value>>,
60}
61
62/// A provider requirement in the `required_providers` block.
63#[derive(Debug, Clone, Serialize)]
64struct ProviderRequirement {
65    source: String,
66    version: String,
67}
68
69impl TerraformConfig {
70    /// Create a new empty configuration.
71    #[must_use]
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Add a required provider.
77    ///
78    /// ```rust
79    /// # use terraform_wrapper::config::TerraformConfig;
80    /// let config = TerraformConfig::new()
81    ///     .required_provider("aws", "hashicorp/aws", "~> 5.0")
82    ///     .required_provider("null", "hashicorp/null", "~> 3.0");
83    /// ```
84    #[must_use]
85    pub fn required_provider(mut self, name: &str, source: &str, version: &str) -> Self {
86        let block = self.terraform.get_or_insert_with(TerraformBlock::default);
87        block.required_providers.insert(
88            name.to_string(),
89            ProviderRequirement {
90                source: source.to_string(),
91                version: version.to_string(),
92            },
93        );
94        self
95    }
96
97    /// Configure a backend for remote state storage.
98    ///
99    /// ```rust
100    /// # use terraform_wrapper::config::TerraformConfig;
101    /// # use serde_json::json;
102    /// let config = TerraformConfig::new()
103    ///     .backend("s3", json!({
104    ///         "bucket": "my-tf-state",
105    ///         "key": "terraform.tfstate",
106    ///         "region": "us-west-2"
107    ///     }));
108    /// ```
109    #[must_use]
110    pub fn backend(mut self, backend_type: &str, config: Value) -> Self {
111        let block = self.terraform.get_or_insert_with(TerraformBlock::default);
112        let mut backend = BTreeMap::new();
113        backend.insert(backend_type.to_string(), config);
114        block.backend = Some(backend);
115        self
116    }
117
118    /// Configure a provider.
119    ///
120    /// ```rust
121    /// # use terraform_wrapper::config::TerraformConfig;
122    /// # use serde_json::json;
123    /// let config = TerraformConfig::new()
124    ///     .provider("aws", json!({ "region": "us-west-2" }));
125    /// ```
126    #[must_use]
127    pub fn provider(mut self, name: &str, config: Value) -> Self {
128        self.provider.insert(name.to_string(), config);
129        self
130    }
131
132    /// Add a managed resource.
133    ///
134    /// ```rust
135    /// # use terraform_wrapper::config::TerraformConfig;
136    /// # use serde_json::json;
137    /// let config = TerraformConfig::new()
138    ///     .resource("aws_instance", "web", json!({
139    ///         "ami": "ami-0c55b159",
140    ///         "instance_type": "t3.micro"
141    ///     }));
142    /// ```
143    #[must_use]
144    pub fn resource(mut self, resource_type: &str, name: &str, config: Value) -> Self {
145        self.resource
146            .entry(resource_type.to_string())
147            .or_default()
148            .insert(name.to_string(), config);
149        self
150    }
151
152    /// Add a data source.
153    ///
154    /// ```rust
155    /// # use terraform_wrapper::config::TerraformConfig;
156    /// # use serde_json::json;
157    /// let config = TerraformConfig::new()
158    ///     .data("aws_ami", "latest", json!({
159    ///         "most_recent": true,
160    ///         "owners": ["amazon"]
161    ///     }));
162    /// ```
163    #[must_use]
164    pub fn data(mut self, data_type: &str, name: &str, config: Value) -> Self {
165        self.data
166            .entry(data_type.to_string())
167            .or_default()
168            .insert(name.to_string(), config);
169        self
170    }
171
172    /// Add a variable.
173    ///
174    /// ```rust
175    /// # use terraform_wrapper::config::TerraformConfig;
176    /// # use serde_json::json;
177    /// let config = TerraformConfig::new()
178    ///     .variable("region", json!({
179    ///         "type": "string",
180    ///         "default": "us-west-2",
181    ///         "description": "AWS region"
182    ///     }));
183    /// ```
184    #[must_use]
185    pub fn variable(mut self, name: &str, config: Value) -> Self {
186        self.variable.insert(name.to_string(), config);
187        self
188    }
189
190    /// Add an output.
191    ///
192    /// ```rust
193    /// # use terraform_wrapper::config::TerraformConfig;
194    /// # use serde_json::json;
195    /// let config = TerraformConfig::new()
196    ///     .output("instance_id", json!({
197    ///         "value": "${aws_instance.web.id}",
198    ///         "description": "The instance ID"
199    ///     }));
200    /// ```
201    #[must_use]
202    pub fn output(mut self, name: &str, config: Value) -> Self {
203        self.output.insert(name.to_string(), config);
204        self
205    }
206
207    /// Add a local value.
208    ///
209    /// ```rust
210    /// # use terraform_wrapper::config::TerraformConfig;
211    /// # use serde_json::json;
212    /// let config = TerraformConfig::new()
213    ///     .local("common_tags", json!({
214    ///         "Environment": "production",
215    ///         "ManagedBy": "terraform-wrapper"
216    ///     }));
217    /// ```
218    #[must_use]
219    pub fn local(mut self, name: &str, value: Value) -> Self {
220        self.locals.insert(name.to_string(), value);
221        self
222    }
223
224    /// Serialize to a JSON string.
225    pub fn to_json(&self) -> serde_json::Result<String> {
226        serde_json::to_string(self)
227    }
228
229    /// Serialize to a pretty-printed JSON string.
230    pub fn to_json_pretty(&self) -> serde_json::Result<String> {
231        serde_json::to_string_pretty(self)
232    }
233
234    /// Write the configuration to a file as `main.tf.json`.
235    ///
236    /// Creates parent directories if they don't exist.
237    pub fn write_to(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
238        let path = path.as_ref();
239        if let Some(parent) = path.parent() {
240            std::fs::create_dir_all(parent)?;
241        }
242        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
243        std::fs::write(path, json)
244    }
245
246    /// Write the configuration to a temporary directory as `main.tf.json`.
247    ///
248    /// Returns the `TempDir` which will be cleaned up when dropped.
249    /// Use `.path()` to get the directory path for `Terraform::builder().working_dir()`.
250    pub fn write_to_tempdir(&self) -> std::io::Result<tempfile::TempDir> {
251        let dir = tempfile::tempdir()?;
252        self.write_to(dir.path().join("main.tf.json"))?;
253        Ok(dir)
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use serde_json::json;
261
262    #[test]
263    fn empty_config() {
264        let config = TerraformConfig::new();
265        let json = config.to_json().unwrap();
266        assert_eq!(json, "{}");
267    }
268
269    #[test]
270    fn required_provider() {
271        let config = TerraformConfig::new().required_provider("aws", "hashicorp/aws", "~> 5.0");
272        let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
273        assert_eq!(
274            val["terraform"]["required_providers"]["aws"]["source"],
275            "hashicorp/aws"
276        );
277        assert_eq!(
278            val["terraform"]["required_providers"]["aws"]["version"],
279            "~> 5.0"
280        );
281    }
282
283    #[test]
284    fn full_config() {
285        let config = TerraformConfig::new()
286            .required_provider("null", "hashicorp/null", "~> 3.0")
287            .provider("null", json!({}))
288            .resource(
289                "null_resource",
290                "example",
291                json!({
292                    "triggers": { "value": "hello" }
293                }),
294            )
295            .variable("name", json!({ "type": "string", "default": "world" }))
296            .output("id", json!({ "value": "${null_resource.example.id}" }))
297            .local("tag", json!("test"));
298
299        let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
300        assert!(val["resource"]["null_resource"]["example"].is_object());
301        assert_eq!(val["variable"]["name"]["default"], "world");
302        assert_eq!(val["output"]["id"]["value"], "${null_resource.example.id}");
303        assert_eq!(val["locals"]["tag"], "test");
304    }
305
306    #[test]
307    fn multiple_resources_same_type() {
308        let config = TerraformConfig::new()
309            .resource("null_resource", "a", json!({}))
310            .resource("null_resource", "b", json!({}));
311        let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
312        assert!(val["resource"]["null_resource"]["a"].is_object());
313        assert!(val["resource"]["null_resource"]["b"].is_object());
314    }
315
316    #[test]
317    fn data_source() {
318        let config =
319            TerraformConfig::new().data("aws_ami", "latest", json!({ "most_recent": true }));
320        let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
321        assert_eq!(val["data"]["aws_ami"]["latest"]["most_recent"], true);
322    }
323
324    #[test]
325    fn backend() {
326        let config = TerraformConfig::new().backend("s3", json!({ "bucket": "my-state" }));
327        let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
328        assert_eq!(val["terraform"]["backend"]["s3"]["bucket"], "my-state");
329    }
330
331    #[test]
332    fn write_to_tempdir() {
333        let config = TerraformConfig::new()
334            .required_provider("null", "hashicorp/null", "~> 3.0")
335            .resource("null_resource", "test", json!({}));
336
337        let dir = config.write_to_tempdir().unwrap();
338        let path = dir.path().join("main.tf.json");
339        assert!(path.exists());
340
341        let contents = std::fs::read_to_string(&path).unwrap();
342        let val: Value = serde_json::from_str(&contents).unwrap();
343        assert!(val["resource"]["null_resource"]["test"].is_object());
344    }
345}