Skip to main content

terraform_wrapper/commands/
plan.rs

1use crate::Terraform;
2use crate::command::TerraformCommand;
3use crate::error::Result;
4use crate::exec::{self, CommandOutput};
5
6/// Command for creating a Terraform execution plan.
7///
8/// Terraform `plan` uses exit code 2 to indicate "changes present" which
9/// is treated as a success by this wrapper (not an error).
10///
11/// ```no_run
12/// # async fn example() -> terraform_wrapper::error::Result<()> {
13/// use terraform_wrapper::{Terraform, TerraformCommand};
14/// use terraform_wrapper::commands::plan::PlanCommand;
15///
16/// let tf = Terraform::builder().working_dir("/tmp/infra").build()?;
17/// let output = PlanCommand::new()
18///     .var("region", "us-west-2")
19///     .out("tfplan")
20///     .execute(&tf)
21///     .await?;
22/// # Ok(())
23/// # }
24/// ```
25#[derive(Debug, Clone, Default)]
26pub struct PlanCommand {
27    vars: Vec<(String, String)>,
28    var_files: Vec<String>,
29    out: Option<String>,
30    targets: Vec<String>,
31    replace: Vec<String>,
32    destroy: bool,
33    refresh_only: bool,
34    lock: Option<bool>,
35    lock_timeout: Option<String>,
36    parallelism: Option<u32>,
37    detailed_exitcode: bool,
38    json: bool,
39    raw_args: Vec<String>,
40}
41
42impl PlanCommand {
43    /// Create a new plan command with default options.
44    #[must_use]
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Set a variable value (`-var="name=value"`).
50    #[must_use]
51    pub fn var(mut self, name: &str, value: &str) -> Self {
52        self.vars.push((name.to_string(), value.to_string()));
53        self
54    }
55
56    /// Add a variable definitions file (`-var-file`).
57    #[must_use]
58    pub fn var_file(mut self, path: &str) -> Self {
59        self.var_files.push(path.to_string());
60        self
61    }
62
63    /// Save the plan to a file (`-out`).
64    #[must_use]
65    pub fn out(mut self, path: &str) -> Self {
66        self.out = Some(path.to_string());
67        self
68    }
69
70    /// Target a specific resource or module (`-target`).
71    #[must_use]
72    pub fn target(mut self, resource: &str) -> Self {
73        self.targets.push(resource.to_string());
74        self
75    }
76
77    /// Mark a resource for replacement (`-replace`).
78    #[must_use]
79    pub fn replace(mut self, resource: &str) -> Self {
80        self.replace.push(resource.to_string());
81        self
82    }
83
84    /// Create a destroy plan (`-destroy`).
85    #[must_use]
86    pub fn destroy(mut self) -> Self {
87        self.destroy = true;
88        self
89    }
90
91    /// Create a plan that only refreshes state (`-refresh-only`).
92    #[must_use]
93    pub fn refresh_only(mut self) -> Self {
94        self.refresh_only = true;
95        self
96    }
97
98    /// Enable or disable state locking (`-lock`).
99    #[must_use]
100    pub fn lock(mut self, enabled: bool) -> Self {
101        self.lock = Some(enabled);
102        self
103    }
104
105    /// Duration to wait for state lock (`-lock-timeout`).
106    #[must_use]
107    pub fn lock_timeout(mut self, timeout: &str) -> Self {
108        self.lock_timeout = Some(timeout.to_string());
109        self
110    }
111
112    /// Limit the number of concurrent operations (`-parallelism`).
113    #[must_use]
114    pub fn parallelism(mut self, n: u32) -> Self {
115        self.parallelism = Some(n);
116        self
117    }
118
119    /// Return a detailed exit code (`-detailed-exitcode`).
120    ///
121    /// When enabled, exit code 0 means no changes, exit code 2 means changes
122    /// are present. Without this flag, exit code 0 means success regardless
123    /// of whether changes are needed.
124    #[must_use]
125    pub fn detailed_exitcode(mut self) -> Self {
126        self.detailed_exitcode = true;
127        self
128    }
129
130    /// Enable machine-readable JSON output (`-json`).
131    ///
132    /// When enabled, stdout contains streaming JSON log lines (one per event),
133    /// not a single JSON blob.
134    #[must_use]
135    pub fn json(mut self) -> Self {
136        self.json = true;
137        self
138    }
139
140    /// Add a raw argument (escape hatch for unsupported options).
141    #[must_use]
142    pub fn arg(mut self, arg: impl Into<String>) -> Self {
143        self.raw_args.push(arg.into());
144        self
145    }
146}
147
148impl TerraformCommand for PlanCommand {
149    type Output = CommandOutput;
150
151    fn args(&self) -> Vec<String> {
152        let mut args = vec!["plan".to_string()];
153        for (name, value) in &self.vars {
154            args.push(format!("-var={name}={value}"));
155        }
156        for file in &self.var_files {
157            args.push(format!("-var-file={file}"));
158        }
159        if let Some(ref out) = self.out {
160            args.push(format!("-out={out}"));
161        }
162        for target in &self.targets {
163            args.push(format!("-target={target}"));
164        }
165        for resource in &self.replace {
166            args.push(format!("-replace={resource}"));
167        }
168        if self.destroy {
169            args.push("-destroy".to_string());
170        }
171        if self.refresh_only {
172            args.push("-refresh-only".to_string());
173        }
174        if let Some(lock) = self.lock {
175            args.push(format!("-lock={lock}"));
176        }
177        if let Some(ref timeout) = self.lock_timeout {
178            args.push(format!("-lock-timeout={timeout}"));
179        }
180        if let Some(n) = self.parallelism {
181            args.push(format!("-parallelism={n}"));
182        }
183        if self.detailed_exitcode {
184            args.push("-detailed-exitcode".to_string());
185        }
186        if self.json {
187            args.push("-json".to_string());
188        }
189        args.extend(self.raw_args.clone());
190        args
191    }
192
193    async fn execute(&self, tf: &Terraform) -> Result<CommandOutput> {
194        let mut args = self.args();
195        if tf.no_input {
196            args.insert(1, "-input=false".to_string());
197        }
198        exec::run_terraform_allow_exit_codes(tf, args, &[0, 2]).await
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn default_args() {
208        let cmd = PlanCommand::new();
209        assert_eq!(cmd.args(), vec!["plan"]);
210    }
211
212    #[test]
213    fn full_options() {
214        let cmd = PlanCommand::new()
215            .var("region", "us-west-2")
216            .var_file("prod.tfvars")
217            .out("tfplan")
218            .target("module.vpc")
219            .replace("aws_instance.web")
220            .destroy()
221            .parallelism(10)
222            .json();
223        let args = cmd.args();
224        assert_eq!(args[0], "plan");
225        assert!(args.contains(&"-var=region=us-west-2".to_string()));
226        assert!(args.contains(&"-var-file=prod.tfvars".to_string()));
227        assert!(args.contains(&"-out=tfplan".to_string()));
228        assert!(args.contains(&"-target=module.vpc".to_string()));
229        assert!(args.contains(&"-replace=aws_instance.web".to_string()));
230        assert!(args.contains(&"-destroy".to_string()));
231        assert!(args.contains(&"-parallelism=10".to_string()));
232        assert!(args.contains(&"-json".to_string()));
233    }
234
235    #[test]
236    fn multiple_targets() {
237        let cmd = PlanCommand::new().target("module.vpc").target("module.rds");
238        let args = cmd.args();
239        assert!(args.contains(&"-target=module.vpc".to_string()));
240        assert!(args.contains(&"-target=module.rds".to_string()));
241    }
242}