Skip to main content

terraform_wrapper/
lib.rs

1//! # terraform-wrapper
2//!
3//! A type-safe Terraform CLI wrapper for Rust.
4//!
5//! This crate provides an idiomatic Rust interface to the Terraform command-line tool.
6//! All commands use a builder pattern and async execution via Tokio.
7//!
8//! # Quick Start
9//!
10//! ```no_run
11//! use terraform_wrapper::prelude::*;
12//!
13//! #[tokio::main]
14//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
15//!     let tf = Terraform::builder()
16//!         .working_dir("./infra")
17//!         .build()?;
18//!
19//!     // Initialize, apply, read outputs, destroy
20//!     InitCommand::new().execute(&tf).await?;
21//!
22//!     ApplyCommand::new()
23//!         .auto_approve()
24//!         .var("region", "us-west-2")
25//!         .execute(&tf)
26//!         .await?;
27//!
28//!     let result = OutputCommand::new()
29//!         .name("endpoint")
30//!         .raw()
31//!         .execute(&tf)
32//!         .await?;
33//!
34//!     if let OutputResult::Raw(value) = result {
35//!         println!("Endpoint: {value}");
36//!     }
37//!
38//!     DestroyCommand::new().auto_approve().execute(&tf).await?;
39//!
40//!     Ok(())
41//! }
42//! ```
43//!
44//! # Core Concepts
45//!
46//! ## The `TerraformCommand` Trait
47//!
48//! All commands implement [`TerraformCommand`], which provides the
49//! [`execute()`](TerraformCommand::execute) method. You must import this trait
50//! to call `.execute()`:
51//!
52//! ```rust
53//! use terraform_wrapper::TerraformCommand; // Required for .execute()
54//! ```
55//!
56//! ## Builder Pattern
57//!
58//! Commands are configured using method chaining:
59//!
60//! ```rust,no_run
61//! # use terraform_wrapper::prelude::*;
62//! # async fn example() -> terraform_wrapper::error::Result<()> {
63//! # let tf = Terraform::builder().build()?;
64//! ApplyCommand::new()
65//!     .auto_approve()
66//!     .var("region", "us-west-2")
67//!     .var_file("prod.tfvars")
68//!     .target("module.vpc")
69//!     .parallelism(10)
70//!     .execute(&tf)
71//!     .await?;
72//! # Ok(())
73//! # }
74//! ```
75//!
76//! ## The `Terraform` Client
77//!
78//! The [`Terraform`] struct holds shared configuration (binary path, working
79//! directory, environment variables) passed to every command:
80//!
81//! ```rust,no_run
82//! # use terraform_wrapper::prelude::*;
83//! # fn example() -> terraform_wrapper::error::Result<()> {
84//! let tf = Terraform::builder()
85//!     .working_dir("./infra")
86//!     .env("AWS_REGION", "us-west-2")
87//!     .env_var("instance_type", "t3.medium")  // Sets TF_VAR_instance_type
88//!     .timeout_secs(300)
89//!     .build()?;
90//! # Ok(())
91//! # }
92//! ```
93//!
94//! Programmatic defaults: `-no-color` and `-input=false` are enabled by default.
95//! Override with `.color(true)` and `.input(true)`.
96//!
97//! ## Error Handling
98//!
99//! All commands return `Result<T, terraform_wrapper::Error>`. The error type
100//! implements `std::error::Error`, so it works with `anyhow` and other error
101//! libraries via `?`:
102//!
103//! ```rust,no_run
104//! # use terraform_wrapper::prelude::*;
105//! # use terraform_wrapper::Error;
106//! # async fn example() -> terraform_wrapper::error::Result<()> {
107//! # let tf = Terraform::builder().build()?;
108//! match InitCommand::new().execute(&tf).await {
109//!     Ok(output) => println!("Initialized: {}", output.stdout),
110//!     Err(Error::NotFound) => eprintln!("Terraform binary not found"),
111//!     Err(Error::CommandFailed { stderr, .. }) => eprintln!("Failed: {stderr}"),
112//!     Err(Error::Timeout { timeout_seconds }) => eprintln!("Timed out after {timeout_seconds}s"),
113//!     Err(e) => eprintln!("Error: {e}"),
114//! }
115//! # Ok(())
116//! # }
117//! ```
118//!
119//! # Command Categories
120//!
121//! ## Lifecycle
122//!
123//! ```rust
124//! use terraform_wrapper::commands::{
125//!     InitCommand,     // terraform init
126//!     PlanCommand,     // terraform plan
127//!     ApplyCommand,    // terraform apply
128//!     DestroyCommand,  // terraform destroy
129//! };
130//! ```
131//!
132//! ## Inspection
133//!
134//! ```rust
135//! use terraform_wrapper::commands::{
136//!     ValidateCommand,  // terraform validate
137//!     ShowCommand,      // terraform show (state or plan)
138//!     OutputCommand,    // terraform output
139//!     FmtCommand,       // terraform fmt
140//!     GraphCommand,     // terraform graph (DOT format)
141//!     ModulesCommand,   // terraform modules
142//!     ProvidersCommand, // terraform providers (lock, mirror, schema)
143//!     TestCommand,      // terraform test
144//!     VersionCommand,   // terraform version
145//! };
146//! ```
147//!
148//! ## State and Workspace Management
149//!
150//! ```rust
151//! use terraform_wrapper::commands::{
152//!     StateCommand,       // terraform state (list, show, mv, rm, pull, push, replace-provider)
153//!     WorkspaceCommand,   // terraform workspace (list, show, new, select, delete)
154//!     ImportCommand,      // terraform import
155//!     ForceUnlockCommand, // terraform force-unlock
156//!     GetCommand,         // terraform get (download modules)
157//!     RefreshCommand,     // terraform refresh (deprecated)
158//!     RawCommand,         // any subcommand not covered above
159//! };
160//! ```
161//!
162//! # JSON Output Types
163//!
164//! With the `json` feature (enabled by default), commands return typed structs
165//! instead of raw strings:
166//!
167//! ```rust,no_run
168//! # use terraform_wrapper::prelude::*;
169//! # async fn example() -> terraform_wrapper::error::Result<()> {
170//! # let tf = Terraform::builder().build()?;
171//! // Version info
172//! let info = tf.version().await?;
173//! println!("Terraform {} on {}", info.terraform_version, info.platform);
174//!
175//! // Validate with diagnostics
176//! let result = ValidateCommand::new().execute(&tf).await?;
177//! if !result.valid {
178//!     for diag in &result.diagnostics {
179//!         eprintln!("[{}] {}: {}", diag.severity, diag.summary, diag.detail);
180//!     }
181//! }
182//!
183//! // Show state with typed resources
184//! let result = ShowCommand::new().execute(&tf).await?;
185//! if let ShowResult::State(state) = result {
186//!     for resource in &state.values.root_module.resources {
187//!         println!("{} ({})", resource.address, resource.resource_type);
188//!     }
189//! }
190//!
191//! // Show plan with resource changes
192//! let result = ShowCommand::new().plan_file("tfplan").execute(&tf).await?;
193//! if let ShowResult::Plan(plan) = result {
194//!     for change in &plan.resource_changes {
195//!         println!("{}: {:?}", change.address, change.change.actions);
196//!     }
197//! }
198//!
199//! // Output values
200//! let result = OutputCommand::new().json().execute(&tf).await?;
201//! if let OutputResult::Json(outputs) = result {
202//!     for (name, val) in &outputs {
203//!         println!("{name} = {}", val.value);
204//!     }
205//! }
206//! # Ok(())
207//! # }
208//! ```
209//!
210//! # Streaming Output
211//!
212//! Long-running commands like `apply` and `plan` with `-json` produce streaming
213//! NDJSON (one JSON object per line) instead of a single blob. Use
214//! [`streaming::stream_terraform`] to process events as they arrive -- useful
215//! for progress reporting, logging, or UI updates:
216//!
217//! ```rust,no_run
218//! # use terraform_wrapper::prelude::*;
219//! use terraform_wrapper::streaming::{stream_terraform, JsonLogLine};
220//!
221//! # async fn example() -> terraform_wrapper::error::Result<()> {
222//! # let tf = Terraform::builder().build()?;
223//! let result = stream_terraform(
224//!     &tf,
225//!     ApplyCommand::new().auto_approve().json(),
226//!     &[0],
227//!     |line: JsonLogLine| {
228//!         match line.log_type.as_str() {
229//!             "apply_start" => println!("Creating: {}", line.message),
230//!             "apply_progress" => println!("  {}", line.message),
231//!             "apply_complete" => println!("Done: {}", line.message),
232//!             "apply_errored" => eprintln!("Error: {}", line.message),
233//!             "change_summary" => println!("Summary: {}", line.message),
234//!             _ => {}
235//!         }
236//!     },
237//! ).await?;
238//! # Ok(())
239//! # }
240//! ```
241//!
242//! Common event types: `version`, `planned_change`, `change_summary`,
243//! `apply_start`, `apply_progress`, `apply_complete`, `apply_errored`, `outputs`.
244//!
245//! # Config Builder
246//!
247//! With the `config` feature, define Terraform configurations entirely in Rust.
248//! No `.tf` files needed -- generates `.tf.json` that Terraform processes natively.
249//!
250//! Available builder methods:
251//! [`required_provider`](config::TerraformConfig::required_provider),
252//! [`backend`](config::TerraformConfig::backend),
253//! [`provider`](config::TerraformConfig::provider),
254//! [`variable`](config::TerraformConfig::variable),
255//! [`data`](config::TerraformConfig::data),
256//! [`resource`](config::TerraformConfig::resource),
257//! [`local`](config::TerraformConfig::local),
258//! [`module`](config::TerraformConfig::module),
259//! [`output`](config::TerraformConfig::output).
260//!
261//! ```rust
262//! # #[cfg(feature = "config")]
263//! # fn example() -> std::io::Result<()> {
264//! use terraform_wrapper::config::TerraformConfig;
265//! use serde_json::json;
266//!
267//! let config = TerraformConfig::new()
268//!     .required_provider("aws", "hashicorp/aws", "~> 5.0")
269//!     .backend("s3", json!({
270//!         "bucket": "my-tf-state",
271//!         "key": "terraform.tfstate",
272//!         "region": "us-west-2"
273//!     }))
274//!     .provider("aws", json!({ "region": "us-west-2" }))
275//!     .variable("instance_type", json!({
276//!         "type": "string", "default": "t3.micro"
277//!     }))
278//!     .data("aws_ami", "latest", json!({
279//!         "most_recent": true,
280//!         "owners": ["amazon"]
281//!     }))
282//!     .resource("aws_instance", "web", json!({
283//!         "ami": "${data.aws_ami.latest.id}",
284//!         "instance_type": "${var.instance_type}"
285//!     }))
286//!     .local("common_tags", json!({
287//!         "Environment": "production",
288//!         "ManagedBy": "terraform-wrapper"
289//!     }))
290//!     .module("vpc", json!({
291//!         "source": "terraform-aws-modules/vpc/aws",
292//!         "version": "~> 5.0",
293//!         "cidr": "10.0.0.0/16"
294//!     }))
295//!     .output("instance_id", json!({
296//!         "value": "${aws_instance.web.id}"
297//!     }));
298//!
299//! let dir = config.write_to_tempdir()?;
300//! // Terraform::builder().working_dir(dir.path()).build()?;
301//! # Ok(())
302//! # }
303//! ```
304//!
305//! Enable with:
306//! ```toml
307//! terraform-wrapper = { version = "0.3", features = ["config"] }
308//! ```
309//!
310//! # Feature Flags
311//!
312//! | Feature | Default | Description |
313//! |---------|---------|-------------|
314//! | `json` | Yes | Typed JSON output parsing via `serde` / `serde_json` |
315//! | `config` | No | [`TerraformConfig`](config::TerraformConfig) builder for `.tf.json` generation |
316//!
317//! Disable defaults for raw command output only:
318//!
319//! ```toml
320//! terraform-wrapper = { version = "0.3", default-features = false }
321//! ```
322//!
323//! # OpenTofu Compatibility
324//!
325//! [OpenTofu](https://opentofu.org/) works out of the box by pointing the client
326//! at the `tofu` binary:
327//!
328//! ```rust,no_run
329//! # use terraform_wrapper::prelude::*;
330//! # fn example() -> terraform_wrapper::error::Result<()> {
331//! let tf = Terraform::builder()
332//!     .binary("tofu")
333//!     .working_dir("./infra")
334//!     .build()?;
335//! # Ok(())
336//! # }
337//! ```
338//!
339//! # Imports
340//!
341//! The [`prelude`] module re-exports everything you need:
342//!
343//! ```rust
344//! use terraform_wrapper::prelude::*;
345//! ```
346//!
347//! Or import selectively from [`commands`]:
348//!
349//! ```rust
350//! use terraform_wrapper::{Terraform, TerraformCommand};
351//! use terraform_wrapper::commands::{InitCommand, ApplyCommand, OutputCommand, OutputResult};
352//! ```
353
354use std::collections::HashMap;
355use std::path::{Path, PathBuf};
356use std::time::Duration;
357
358pub mod command;
359pub mod commands;
360#[cfg(feature = "config")]
361pub mod config;
362pub mod error;
363pub mod exec;
364pub mod prelude;
365#[cfg(feature = "json")]
366pub mod streaming;
367#[cfg(feature = "json")]
368pub mod types;
369
370pub use command::TerraformCommand;
371pub use error::{Error, Result};
372pub use exec::CommandOutput;
373
374/// Terraform client configuration.
375///
376/// Holds the binary path, working directory, environment variables, and global
377/// arguments shared across all command executions. Construct via
378/// [`Terraform::builder()`].
379#[derive(Debug, Clone)]
380pub struct Terraform {
381    pub(crate) binary: PathBuf,
382    pub(crate) working_dir: Option<PathBuf>,
383    pub(crate) env: HashMap<String, String>,
384    /// Args applied to every subcommand (e.g., `-no-color`).
385    pub(crate) global_args: Vec<String>,
386    /// Whether to add `-input=false` to commands that support it.
387    pub(crate) no_input: bool,
388    /// Default timeout for command execution.
389    pub(crate) timeout: Option<Duration>,
390}
391
392impl Terraform {
393    /// Create a new [`TerraformBuilder`].
394    #[must_use]
395    pub fn builder() -> TerraformBuilder {
396        TerraformBuilder::new()
397    }
398
399    /// Verify terraform is installed and return version info.
400    #[cfg(feature = "json")]
401    pub async fn version(&self) -> Result<types::version::VersionInfo> {
402        commands::version::VersionCommand::new().execute(self).await
403    }
404
405    /// Create a clone of this client with a different working directory.
406    ///
407    /// Useful for running a single command against a different directory
408    /// without modifying the original client:
409    ///
410    /// ```rust,no_run
411    /// # use terraform_wrapper::prelude::*;
412    /// # async fn example() -> terraform_wrapper::error::Result<()> {
413    /// let tf = Terraform::builder()
414    ///     .working_dir("./infra/network")
415    ///     .build()?;
416    ///
417    /// // Run one command against a different directory
418    /// let compute = tf.with_working_dir("./infra/compute");
419    /// InitCommand::new().execute(&compute).await?;
420    /// # Ok(())
421    /// # }
422    /// ```
423    #[must_use]
424    pub fn with_working_dir(&self, path: impl AsRef<Path>) -> Self {
425        let mut clone = self.clone();
426        clone.working_dir = Some(path.as_ref().to_path_buf());
427        clone
428    }
429}
430
431/// Builder for constructing a [`Terraform`] client.
432///
433/// Defaults:
434/// - Binary: resolved via `TERRAFORM_PATH` env var, or `terraform` on `PATH`
435/// - `-no-color` enabled (disable with `.color(true)`)
436/// - `-input=false` enabled (disable with `.input(true)`)
437#[derive(Debug)]
438pub struct TerraformBuilder {
439    binary: Option<PathBuf>,
440    working_dir: Option<PathBuf>,
441    env: HashMap<String, String>,
442    no_color: bool,
443    input: bool,
444    timeout: Option<Duration>,
445}
446
447impl TerraformBuilder {
448    fn new() -> Self {
449        Self {
450            binary: None,
451            working_dir: None,
452            env: HashMap::new(),
453            no_color: true,
454            input: false,
455            timeout: None,
456        }
457    }
458
459    /// Set an explicit path to the terraform binary.
460    #[must_use]
461    pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
462        self.binary = Some(path.into());
463        self
464    }
465
466    /// Set the default working directory for all commands.
467    ///
468    /// This is passed as `-chdir=<path>` to terraform.
469    #[must_use]
470    pub fn working_dir(mut self, path: impl AsRef<Path>) -> Self {
471        self.working_dir = Some(path.as_ref().to_path_buf());
472        self
473    }
474
475    /// Set an environment variable for all terraform subprocesses.
476    #[must_use]
477    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
478        self.env.insert(key.into(), value.into());
479        self
480    }
481
482    /// Set a Terraform variable via environment (`TF_VAR_<name>`).
483    #[must_use]
484    pub fn env_var(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
485        self.env
486            .insert(format!("TF_VAR_{}", name.into()), value.into());
487        self
488    }
489
490    /// Enable or disable color output (default: disabled for programmatic use).
491    #[must_use]
492    pub fn color(mut self, enable: bool) -> Self {
493        self.no_color = !enable;
494        self
495    }
496
497    /// Enable or disable interactive input prompts (default: disabled).
498    #[must_use]
499    pub fn input(mut self, enable: bool) -> Self {
500        self.input = enable;
501        self
502    }
503
504    /// Set a default timeout for all command executions.
505    ///
506    /// Commands that exceed this duration will be terminated and return
507    /// [`Error::Timeout`]. No timeout is set by default.
508    #[must_use]
509    pub fn timeout(mut self, duration: Duration) -> Self {
510        self.timeout = Some(duration);
511        self
512    }
513
514    /// Set a default timeout in seconds for all command executions.
515    #[must_use]
516    pub fn timeout_secs(mut self, seconds: u64) -> Self {
517        self.timeout = Some(Duration::from_secs(seconds));
518        self
519    }
520
521    /// Build the [`Terraform`] client.
522    ///
523    /// Resolves the terraform binary in this order:
524    /// 1. Explicit path set via [`.binary()`](TerraformBuilder::binary)
525    /// 2. `TERRAFORM_PATH` environment variable
526    /// 3. `terraform` found on `PATH`
527    ///
528    /// Returns [`Error::NotFound`] if the binary cannot be located.
529    pub fn build(self) -> Result<Terraform> {
530        let binary = if let Some(path) = self.binary {
531            path
532        } else if let Ok(path) = std::env::var("TERRAFORM_PATH") {
533            PathBuf::from(path)
534        } else {
535            which::which("terraform").map_err(|_| Error::NotFound)?
536        };
537
538        let mut global_args = Vec::new();
539        if self.no_color {
540            global_args.push("-no-color".to_string());
541        }
542
543        Ok(Terraform {
544            binary,
545            working_dir: self.working_dir,
546            env: self.env,
547            global_args,
548            no_input: !self.input,
549            timeout: self.timeout,
550        })
551    }
552}