use std::collections::BTreeMap;
use std::path::Path;
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, Default, Serialize)]
pub struct TerraformConfig {
#[serde(skip_serializing_if = "Option::is_none")]
terraform: Option<TerraformBlock>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
provider: BTreeMap<String, Value>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
resource: BTreeMap<String, BTreeMap<String, Value>>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
data: BTreeMap<String, BTreeMap<String, Value>>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
variable: BTreeMap<String, Value>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
output: BTreeMap<String, Value>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
locals: BTreeMap<String, Value>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
module: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize)]
struct TerraformBlock {
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
required_providers: BTreeMap<String, ProviderRequirement>,
#[serde(skip_serializing_if = "Option::is_none")]
backend: Option<BTreeMap<String, Value>>,
}
#[derive(Debug, Clone, Serialize)]
struct ProviderRequirement {
source: String,
version: String,
}
impl TerraformConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn required_provider(mut self, name: &str, source: &str, version: &str) -> Self {
let block = self.terraform.get_or_insert_with(TerraformBlock::default);
block.required_providers.insert(
name.to_string(),
ProviderRequirement {
source: source.to_string(),
version: version.to_string(),
},
);
self
}
#[must_use]
pub fn backend(mut self, backend_type: &str, config: Value) -> Self {
let block = self.terraform.get_or_insert_with(TerraformBlock::default);
let mut backend = BTreeMap::new();
backend.insert(backend_type.to_string(), config);
block.backend = Some(backend);
self
}
#[must_use]
pub fn provider(mut self, name: &str, config: Value) -> Self {
self.provider.insert(name.to_string(), config);
self
}
#[must_use]
pub fn resource(mut self, resource_type: &str, name: &str, config: Value) -> Self {
self.resource
.entry(resource_type.to_string())
.or_default()
.insert(name.to_string(), config);
self
}
#[must_use]
pub fn data(mut self, data_type: &str, name: &str, config: Value) -> Self {
self.data
.entry(data_type.to_string())
.or_default()
.insert(name.to_string(), config);
self
}
#[must_use]
pub fn variable(mut self, name: &str, config: Value) -> Self {
self.variable.insert(name.to_string(), config);
self
}
#[must_use]
pub fn output(mut self, name: &str, config: Value) -> Self {
self.output.insert(name.to_string(), config);
self
}
#[must_use]
pub fn local(mut self, name: &str, value: Value) -> Self {
self.locals.insert(name.to_string(), value);
self
}
#[must_use]
pub fn module(mut self, name: &str, config: Value) -> Self {
self.module.insert(name.to_string(), config);
self
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
pub fn to_json_pretty(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
pub fn write_to(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
std::fs::write(path, json)
}
pub fn write_to_tempdir(&self) -> std::io::Result<tempfile::TempDir> {
let dir = tempfile::tempdir()?;
self.write_to(dir.path().join("main.tf.json"))?;
Ok(dir)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn empty_config() {
let config = TerraformConfig::new();
let json = config.to_json().unwrap();
assert_eq!(json, "{}");
}
#[test]
fn required_provider() {
let config = TerraformConfig::new().required_provider("aws", "hashicorp/aws", "~> 5.0");
let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
assert_eq!(
val["terraform"]["required_providers"]["aws"]["source"],
"hashicorp/aws"
);
assert_eq!(
val["terraform"]["required_providers"]["aws"]["version"],
"~> 5.0"
);
}
#[test]
fn full_config() {
let config = TerraformConfig::new()
.required_provider("null", "hashicorp/null", "~> 3.0")
.provider("null", json!({}))
.resource(
"null_resource",
"example",
json!({
"triggers": { "value": "hello" }
}),
)
.variable("name", json!({ "type": "string", "default": "world" }))
.output("id", json!({ "value": "${null_resource.example.id}" }))
.local("tag", json!("test"));
let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
assert!(val["resource"]["null_resource"]["example"].is_object());
assert_eq!(val["variable"]["name"]["default"], "world");
assert_eq!(val["output"]["id"]["value"], "${null_resource.example.id}");
assert_eq!(val["locals"]["tag"], "test");
}
#[test]
fn multiple_resources_same_type() {
let config = TerraformConfig::new()
.resource("null_resource", "a", json!({}))
.resource("null_resource", "b", json!({}));
let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
assert!(val["resource"]["null_resource"]["a"].is_object());
assert!(val["resource"]["null_resource"]["b"].is_object());
}
#[test]
fn data_source() {
let config =
TerraformConfig::new().data("aws_ami", "latest", json!({ "most_recent": true }));
let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
assert_eq!(val["data"]["aws_ami"]["latest"]["most_recent"], true);
}
#[test]
fn backend() {
let config = TerraformConfig::new().backend("s3", json!({ "bucket": "my-state" }));
let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
assert_eq!(val["terraform"]["backend"]["s3"]["bucket"], "my-state");
}
#[test]
fn module_block() {
let config = TerraformConfig::new().module(
"vpc",
json!({
"source": "terraform-aws-modules/vpc/aws",
"version": "~> 5.0",
"cidr": "10.0.0.0/16"
}),
);
let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
assert_eq!(
val["module"]["vpc"]["source"],
"terraform-aws-modules/vpc/aws"
);
assert_eq!(val["module"]["vpc"]["version"], "~> 5.0");
assert_eq!(val["module"]["vpc"]["cidr"], "10.0.0.0/16");
}
#[test]
fn multiple_modules() {
let config = TerraformConfig::new()
.module(
"vpc",
json!({
"source": "terraform-aws-modules/vpc/aws",
"version": "~> 5.0"
}),
)
.module(
"eks",
json!({
"source": "terraform-aws-modules/eks/aws",
"version": "~> 19.0"
}),
);
let val: Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
assert_eq!(
val["module"]["vpc"]["source"],
"terraform-aws-modules/vpc/aws"
);
assert_eq!(
val["module"]["eks"]["source"],
"terraform-aws-modules/eks/aws"
);
}
#[test]
fn write_to_tempdir() {
let config = TerraformConfig::new()
.required_provider("null", "hashicorp/null", "~> 3.0")
.resource("null_resource", "test", json!({}));
let dir = config.write_to_tempdir().unwrap();
let path = dir.path().join("main.tf.json");
assert!(path.exists());
let contents = std::fs::read_to_string(&path).unwrap();
let val: Value = serde_json::from_str(&contents).unwrap();
assert!(val["resource"]["null_resource"]["test"].is_object());
}
}