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    refresh: Option<bool>,
35    compact_warnings: bool,
36    lock: Option<bool>,
37    lock_timeout: Option<String>,
38    parallelism: Option<u32>,
39    detailed_exitcode: bool,
40    json: bool,
41    raw_args: Vec<String>,
42}
43
44impl PlanCommand {
45    /// Create a new plan command with default options.
46    #[must_use]
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Set a variable value (`-var="name=value"`).
52    #[must_use]
53    pub fn var(mut self, name: &str, value: &str) -> Self {
54        self.vars.push((name.to_string(), value.to_string()));
55        self
56    }
57
58    /// Add a variable definitions file (`-var-file`).
59    #[must_use]
60    pub fn var_file(mut self, path: &str) -> Self {
61        self.var_files.push(path.to_string());
62        self
63    }
64
65    /// Save the plan to a file (`-out`).
66    #[must_use]
67    pub fn out(mut self, path: &str) -> Self {
68        self.out = Some(path.to_string());
69        self
70    }
71
72    /// Target a specific resource or module (`-target`).
73    #[must_use]
74    pub fn target(mut self, resource: &str) -> Self {
75        self.targets.push(resource.to_string());
76        self
77    }
78
79    /// Mark a resource for replacement (`-replace`).
80    #[must_use]
81    pub fn replace(mut self, resource: &str) -> Self {
82        self.replace.push(resource.to_string());
83        self
84    }
85
86    /// Create a destroy plan (`-destroy`).
87    #[must_use]
88    pub fn destroy(mut self) -> Self {
89        self.destroy = true;
90        self
91    }
92
93    /// Create a plan that only refreshes state (`-refresh-only`).
94    #[must_use]
95    pub fn refresh_only(mut self) -> Self {
96        self.refresh_only = true;
97        self
98    }
99
100    /// Enable or disable refreshing state (`-refresh`).
101    ///
102    /// Pass `false` to skip checking for external changes during planning.
103    #[must_use]
104    pub fn refresh(mut self, enabled: bool) -> Self {
105        self.refresh = Some(enabled);
106        self
107    }
108
109    /// Show warnings in compact form (`-compact-warnings`).
110    #[must_use]
111    pub fn compact_warnings(mut self) -> Self {
112        self.compact_warnings = true;
113        self
114    }
115
116    /// Enable or disable state locking (`-lock`).
117    #[must_use]
118    pub fn lock(mut self, enabled: bool) -> Self {
119        self.lock = Some(enabled);
120        self
121    }
122
123    /// Duration to wait for state lock (`-lock-timeout`).
124    #[must_use]
125    pub fn lock_timeout(mut self, timeout: &str) -> Self {
126        self.lock_timeout = Some(timeout.to_string());
127        self
128    }
129
130    /// Limit the number of concurrent operations (`-parallelism`).
131    #[must_use]
132    pub fn parallelism(mut self, n: u32) -> Self {
133        self.parallelism = Some(n);
134        self
135    }
136
137    /// Return a detailed exit code (`-detailed-exitcode`).
138    ///
139    /// When enabled, exit code 0 means no changes, exit code 2 means changes
140    /// are present. Without this flag, exit code 0 means success regardless
141    /// of whether changes are needed.
142    #[must_use]
143    pub fn detailed_exitcode(mut self) -> Self {
144        self.detailed_exitcode = true;
145        self
146    }
147
148    /// Enable machine-readable JSON output (`-json`).
149    ///
150    /// When enabled, stdout contains streaming JSON log lines (one per event),
151    /// not a single JSON blob.
152    #[must_use]
153    pub fn json(mut self) -> Self {
154        self.json = true;
155        self
156    }
157
158    /// Add a raw argument (escape hatch for unsupported options).
159    #[must_use]
160    pub fn arg(mut self, arg: impl Into<String>) -> Self {
161        self.raw_args.push(arg.into());
162        self
163    }
164}
165
166impl TerraformCommand for PlanCommand {
167    type Output = CommandOutput;
168
169    fn args(&self) -> Vec<String> {
170        let mut args = vec!["plan".to_string()];
171        for (name, value) in &self.vars {
172            args.push(format!("-var={name}={value}"));
173        }
174        for file in &self.var_files {
175            args.push(format!("-var-file={file}"));
176        }
177        if let Some(ref out) = self.out {
178            args.push(format!("-out={out}"));
179        }
180        for target in &self.targets {
181            args.push(format!("-target={target}"));
182        }
183        for resource in &self.replace {
184            args.push(format!("-replace={resource}"));
185        }
186        if self.destroy {
187            args.push("-destroy".to_string());
188        }
189        if self.refresh_only {
190            args.push("-refresh-only".to_string());
191        }
192        if let Some(refresh) = self.refresh {
193            args.push(format!("-refresh={refresh}"));
194        }
195        if self.compact_warnings {
196            args.push("-compact-warnings".to_string());
197        }
198        if let Some(lock) = self.lock {
199            args.push(format!("-lock={lock}"));
200        }
201        if let Some(ref timeout) = self.lock_timeout {
202            args.push(format!("-lock-timeout={timeout}"));
203        }
204        if let Some(n) = self.parallelism {
205            args.push(format!("-parallelism={n}"));
206        }
207        if self.detailed_exitcode {
208            args.push("-detailed-exitcode".to_string());
209        }
210        if self.json {
211            args.push("-json".to_string());
212        }
213        args.extend(self.raw_args.clone());
214        args
215    }
216
217    fn supports_input(&self) -> bool {
218        true
219    }
220
221    async fn execute(&self, tf: &Terraform) -> Result<CommandOutput> {
222        exec::run_terraform_allow_exit_codes(tf, self.prepare_args(tf), &[0, 2]).await
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn default_args() {
232        let cmd = PlanCommand::new();
233        assert_eq!(cmd.args(), vec!["plan"]);
234    }
235
236    #[test]
237    fn full_options() {
238        let cmd = PlanCommand::new()
239            .var("region", "us-west-2")
240            .var_file("prod.tfvars")
241            .out("tfplan")
242            .target("module.vpc")
243            .replace("aws_instance.web")
244            .destroy()
245            .refresh(false)
246            .compact_warnings()
247            .parallelism(10)
248            .json();
249        let args = cmd.args();
250        assert_eq!(args[0], "plan");
251        assert!(args.contains(&"-var=region=us-west-2".to_string()));
252        assert!(args.contains(&"-var-file=prod.tfvars".to_string()));
253        assert!(args.contains(&"-out=tfplan".to_string()));
254        assert!(args.contains(&"-target=module.vpc".to_string()));
255        assert!(args.contains(&"-replace=aws_instance.web".to_string()));
256        assert!(args.contains(&"-destroy".to_string()));
257        assert!(args.contains(&"-refresh=false".to_string()));
258        assert!(args.contains(&"-compact-warnings".to_string()));
259        assert!(args.contains(&"-parallelism=10".to_string()));
260        assert!(args.contains(&"-json".to_string()));
261    }
262
263    #[test]
264    fn multiple_targets() {
265        let cmd = PlanCommand::new().target("module.vpc").target("module.rds");
266        let args = cmd.args();
267        assert!(args.contains(&"-target=module.vpc".to_string()));
268        assert!(args.contains(&"-target=module.rds".to_string()));
269    }
270}