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)
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//! ```rust
251//! # #[cfg(feature = "config")]
252//! # fn example() -> std::io::Result<()> {
253//! use terraform_wrapper::config::TerraformConfig;
254//! use serde_json::json;
255//!
256//! let config = TerraformConfig::new()
257//! .required_provider("aws", "hashicorp/aws", "~> 5.0")
258//! .provider("aws", json!({ "region": "us-west-2" }))
259//! .resource("aws_instance", "web", json!({
260//! "ami": "ami-0c55b159",
261//! "instance_type": "${var.instance_type}"
262//! }))
263//! .variable("instance_type", json!({
264//! "type": "string", "default": "t3.micro"
265//! }))
266//! .output("id", json!({ "value": "${aws_instance.web.id}" }));
267//!
268//! // Write to a tempdir for ephemeral use
269//! let dir = config.write_to_tempdir()?;
270//! // Terraform::builder().working_dir(dir.path()).build()?;
271//! # Ok(())
272//! # }
273//! ```
274//!
275//! Enable with:
276//! ```toml
277//! terraform-wrapper = { version = "0.3", features = ["config"] }
278//! ```
279//!
280//! # Feature Flags
281//!
282//! | Feature | Default | Description |
283//! |---------|---------|-------------|
284//! | `json` | Yes | Typed JSON output parsing via `serde` / `serde_json` |
285//! | `config` | No | [`TerraformConfig`](config::TerraformConfig) builder for `.tf.json` generation |
286//!
287//! Disable defaults for raw command output only:
288//!
289//! ```toml
290//! terraform-wrapper = { version = "0.3", default-features = false }
291//! ```
292//!
293//! # OpenTofu Compatibility
294//!
295//! [OpenTofu](https://opentofu.org/) works out of the box by pointing the client
296//! at the `tofu` binary:
297//!
298//! ```rust,no_run
299//! # use terraform_wrapper::prelude::*;
300//! # fn example() -> terraform_wrapper::error::Result<()> {
301//! let tf = Terraform::builder()
302//! .binary("tofu")
303//! .working_dir("./infra")
304//! .build()?;
305//! # Ok(())
306//! # }
307//! ```
308//!
309//! # Imports
310//!
311//! The [`prelude`] module re-exports everything you need:
312//!
313//! ```rust
314//! use terraform_wrapper::prelude::*;
315//! ```
316//!
317//! Or import selectively from [`commands`]:
318//!
319//! ```rust
320//! use terraform_wrapper::{Terraform, TerraformCommand};
321//! use terraform_wrapper::commands::{InitCommand, ApplyCommand, OutputCommand, OutputResult};
322//! ```
323
324use std::collections::HashMap;
325use std::path::{Path, PathBuf};
326use std::time::Duration;
327
328pub mod command;
329pub mod commands;
330#[cfg(feature = "config")]
331pub mod config;
332pub mod error;
333pub mod exec;
334pub mod prelude;
335#[cfg(feature = "json")]
336pub mod streaming;
337#[cfg(feature = "json")]
338pub mod types;
339
340pub use command::TerraformCommand;
341pub use error::{Error, Result};
342pub use exec::CommandOutput;
343
344/// Terraform client configuration.
345///
346/// Holds the binary path, working directory, environment variables, and global
347/// arguments shared across all command executions. Construct via
348/// [`Terraform::builder()`].
349#[derive(Debug, Clone)]
350pub struct Terraform {
351 pub(crate) binary: PathBuf,
352 pub(crate) working_dir: Option<PathBuf>,
353 pub(crate) env: HashMap<String, String>,
354 /// Args applied to every subcommand (e.g., `-no-color`).
355 pub(crate) global_args: Vec<String>,
356 /// Whether to add `-input=false` to commands that support it.
357 pub(crate) no_input: bool,
358 /// Default timeout for command execution.
359 pub(crate) timeout: Option<Duration>,
360}
361
362impl Terraform {
363 /// Create a new [`TerraformBuilder`].
364 #[must_use]
365 pub fn builder() -> TerraformBuilder {
366 TerraformBuilder::new()
367 }
368
369 /// Verify terraform is installed and return version info.
370 #[cfg(feature = "json")]
371 pub async fn version(&self) -> Result<types::version::VersionInfo> {
372 commands::version::VersionCommand::new().execute(self).await
373 }
374
375 /// Create a clone of this client with a different working directory.
376 ///
377 /// Useful for running a single command against a different directory
378 /// without modifying the original client:
379 ///
380 /// ```rust,no_run
381 /// # use terraform_wrapper::prelude::*;
382 /// # async fn example() -> terraform_wrapper::error::Result<()> {
383 /// let tf = Terraform::builder()
384 /// .working_dir("./infra/network")
385 /// .build()?;
386 ///
387 /// // Run one command against a different directory
388 /// let compute = tf.with_working_dir("./infra/compute");
389 /// InitCommand::new().execute(&compute).await?;
390 /// # Ok(())
391 /// # }
392 /// ```
393 #[must_use]
394 pub fn with_working_dir(&self, path: impl AsRef<Path>) -> Self {
395 let mut clone = self.clone();
396 clone.working_dir = Some(path.as_ref().to_path_buf());
397 clone
398 }
399}
400
401/// Builder for constructing a [`Terraform`] client.
402///
403/// Defaults:
404/// - Binary: resolved via `TERRAFORM_PATH` env var, or `terraform` on `PATH`
405/// - `-no-color` enabled (disable with `.color(true)`)
406/// - `-input=false` enabled (disable with `.input(true)`)
407#[derive(Debug)]
408pub struct TerraformBuilder {
409 binary: Option<PathBuf>,
410 working_dir: Option<PathBuf>,
411 env: HashMap<String, String>,
412 no_color: bool,
413 input: bool,
414 timeout: Option<Duration>,
415}
416
417impl TerraformBuilder {
418 fn new() -> Self {
419 Self {
420 binary: None,
421 working_dir: None,
422 env: HashMap::new(),
423 no_color: true,
424 input: false,
425 timeout: None,
426 }
427 }
428
429 /// Set an explicit path to the terraform binary.
430 #[must_use]
431 pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
432 self.binary = Some(path.into());
433 self
434 }
435
436 /// Set the default working directory for all commands.
437 ///
438 /// This is passed as `-chdir=<path>` to terraform.
439 #[must_use]
440 pub fn working_dir(mut self, path: impl AsRef<Path>) -> Self {
441 self.working_dir = Some(path.as_ref().to_path_buf());
442 self
443 }
444
445 /// Set an environment variable for all terraform subprocesses.
446 #[must_use]
447 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
448 self.env.insert(key.into(), value.into());
449 self
450 }
451
452 /// Set a Terraform variable via environment (`TF_VAR_<name>`).
453 #[must_use]
454 pub fn env_var(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
455 self.env
456 .insert(format!("TF_VAR_{}", name.into()), value.into());
457 self
458 }
459
460 /// Enable or disable color output (default: disabled for programmatic use).
461 #[must_use]
462 pub fn color(mut self, enable: bool) -> Self {
463 self.no_color = !enable;
464 self
465 }
466
467 /// Enable or disable interactive input prompts (default: disabled).
468 #[must_use]
469 pub fn input(mut self, enable: bool) -> Self {
470 self.input = enable;
471 self
472 }
473
474 /// Set a default timeout for all command executions.
475 ///
476 /// Commands that exceed this duration will be terminated and return
477 /// [`Error::Timeout`]. No timeout is set by default.
478 #[must_use]
479 pub fn timeout(mut self, duration: Duration) -> Self {
480 self.timeout = Some(duration);
481 self
482 }
483
484 /// Set a default timeout in seconds for all command executions.
485 #[must_use]
486 pub fn timeout_secs(mut self, seconds: u64) -> Self {
487 self.timeout = Some(Duration::from_secs(seconds));
488 self
489 }
490
491 /// Build the [`Terraform`] client.
492 ///
493 /// Resolves the terraform binary in this order:
494 /// 1. Explicit path set via [`.binary()`](TerraformBuilder::binary)
495 /// 2. `TERRAFORM_PATH` environment variable
496 /// 3. `terraform` found on `PATH`
497 ///
498 /// Returns [`Error::NotFound`] if the binary cannot be located.
499 pub fn build(self) -> Result<Terraform> {
500 let binary = if let Some(path) = self.binary {
501 path
502 } else if let Ok(path) = std::env::var("TERRAFORM_PATH") {
503 PathBuf::from(path)
504 } else {
505 which::which("terraform").map_err(|_| Error::NotFound)?
506 };
507
508 let mut global_args = Vec::new();
509 if self.no_color {
510 global_args.push("-no-color".to_string());
511 }
512
513 Ok(Terraform {
514 binary,
515 working_dir: self.working_dir,
516 env: self.env,
517 global_args,
518 no_input: !self.input,
519 timeout: self.timeout,
520 })
521 }
522}