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}
52
53#[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#[derive(Debug, Clone, Serialize)]
64struct ProviderRequirement {
65 source: String,
66 version: String,
67}
68
69impl TerraformConfig {
70 #[must_use]
72 pub fn new() -> Self {
73 Self::default()
74 }
75
76 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 pub fn to_json(&self) -> serde_json::Result<String> {
226 serde_json::to_string(self)
227 }
228
229 pub fn to_json_pretty(&self) -> serde_json::Result<String> {
231 serde_json::to_string_pretty(self)
232 }
233
234 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 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}