Skip to main content

terraform_wrapper/commands/
state.rs

1use crate::Terraform;
2use crate::command::TerraformCommand;
3use crate::error::Result;
4use crate::exec::{self, CommandOutput};
5
6/// The state subcommand to execute.
7#[derive(Debug, Clone)]
8pub enum StateSubcommand {
9    /// List resources in the state.
10    List,
11    /// Show a single resource in the state.
12    Show(String),
13    /// Move a resource to a different address.
14    Mv {
15        /// Source address.
16        source: String,
17        /// Destination address.
18        destination: String,
19    },
20    /// Remove a resource from the state (without destroying it).
21    Rm(Vec<String>),
22    /// Pull remote state and output to stdout.
23    Pull,
24    /// Push local state to remote backend.
25    Push,
26    /// Replace a provider in the state.
27    ReplaceProvider {
28        /// Source provider fully-qualified name.
29        from: String,
30        /// Destination provider fully-qualified name.
31        to: String,
32    },
33}
34
35/// Command for managing Terraform state.
36///
37/// ```no_run
38/// # async fn example() -> terraform_wrapper::error::Result<()> {
39/// use terraform_wrapper::{Terraform, TerraformCommand};
40/// use terraform_wrapper::commands::state::StateCommand;
41///
42/// let tf = Terraform::builder().working_dir("/tmp/infra").build()?;
43///
44/// // List resources in state
45/// let output = StateCommand::list().execute(&tf).await?;
46///
47/// // Show a specific resource
48/// let output = StateCommand::show("null_resource.example")
49///     .execute(&tf)
50///     .await?;
51/// # Ok(())
52/// # }
53/// ```
54#[derive(Debug, Clone)]
55pub struct StateCommand {
56    subcommand: StateSubcommand,
57    auto_approve: bool,
58    dry_run: bool,
59    lock: Option<bool>,
60    lock_timeout: Option<String>,
61    raw_args: Vec<String>,
62}
63
64impl StateCommand {
65    /// List resources in the state.
66    #[must_use]
67    pub fn list() -> Self {
68        Self {
69            subcommand: StateSubcommand::List,
70            auto_approve: false,
71            dry_run: false,
72            lock: None,
73            lock_timeout: None,
74            raw_args: Vec::new(),
75        }
76    }
77
78    /// Show a single resource in the state.
79    #[must_use]
80    pub fn show(address: &str) -> Self {
81        Self {
82            subcommand: StateSubcommand::Show(address.to_string()),
83            auto_approve: false,
84            dry_run: false,
85            lock: None,
86            lock_timeout: None,
87            raw_args: Vec::new(),
88        }
89    }
90
91    /// Move a resource to a different address.
92    #[must_use]
93    pub fn mv(source: &str, destination: &str) -> Self {
94        Self {
95            subcommand: StateSubcommand::Mv {
96                source: source.to_string(),
97                destination: destination.to_string(),
98            },
99            auto_approve: false,
100            dry_run: false,
101            lock: None,
102            lock_timeout: None,
103            raw_args: Vec::new(),
104        }
105    }
106
107    /// Remove resources from the state (without destroying them).
108    #[must_use]
109    pub fn rm(addresses: Vec<String>) -> Self {
110        Self {
111            subcommand: StateSubcommand::Rm(addresses),
112            auto_approve: false,
113            dry_run: false,
114            lock: None,
115            lock_timeout: None,
116            raw_args: Vec::new(),
117        }
118    }
119
120    /// Pull remote state and output to stdout.
121    #[must_use]
122    pub fn pull() -> Self {
123        Self {
124            subcommand: StateSubcommand::Pull,
125            auto_approve: false,
126            dry_run: false,
127            lock: None,
128            lock_timeout: None,
129            raw_args: Vec::new(),
130        }
131    }
132
133    /// Push local state to remote backend.
134    #[must_use]
135    pub fn push() -> Self {
136        Self {
137            subcommand: StateSubcommand::Push,
138            auto_approve: false,
139            dry_run: false,
140            lock: None,
141            lock_timeout: None,
142            raw_args: Vec::new(),
143        }
144    }
145
146    /// Replace a provider in the state.
147    ///
148    /// Migrates resources from one provider to another without modifying
149    /// the actual infrastructure.
150    ///
151    /// ```no_run
152    /// # async fn example() -> terraform_wrapper::error::Result<()> {
153    /// # use terraform_wrapper::{Terraform, TerraformCommand};
154    /// # use terraform_wrapper::commands::state::StateCommand;
155    /// # let tf = Terraform::builder().working_dir("/tmp/infra").build()?;
156    /// StateCommand::replace_provider(
157    ///     "registry.terraform.io/-/aws",
158    ///     "registry.terraform.io/hashicorp/aws",
159    /// )
160    /// .auto_approve()
161    /// .execute(&tf)
162    /// .await?;
163    /// # Ok(())
164    /// # }
165    /// ```
166    #[must_use]
167    pub fn replace_provider(from: &str, to: &str) -> Self {
168        Self {
169            subcommand: StateSubcommand::ReplaceProvider {
170                from: from.to_string(),
171                to: to.to_string(),
172            },
173            auto_approve: false,
174            dry_run: false,
175            lock: None,
176            lock_timeout: None,
177            raw_args: Vec::new(),
178        }
179    }
180
181    /// Skip interactive approval (`-auto-approve`).
182    ///
183    /// Applies to `replace-provider` subcommand only.
184    #[must_use]
185    pub fn auto_approve(mut self) -> Self {
186        self.auto_approve = true;
187        self
188    }
189
190    /// Preview the operation without making changes (`-dry-run`).
191    ///
192    /// Applies to `mv` and `rm` subcommands only; ignored for other subcommands.
193    #[must_use]
194    pub fn dry_run(mut self) -> Self {
195        self.dry_run = true;
196        self
197    }
198
199    /// Enable or disable state locking (`-lock`).
200    ///
201    /// Applies to `mv`, `rm`, and `replace-provider` subcommands.
202    #[must_use]
203    pub fn lock(mut self, enabled: bool) -> Self {
204        self.lock = Some(enabled);
205        self
206    }
207
208    /// Duration to wait for state lock (`-lock-timeout`).
209    ///
210    /// Applies to `mv`, `rm`, and `replace-provider` subcommands.
211    #[must_use]
212    pub fn lock_timeout(mut self, timeout: &str) -> Self {
213        self.lock_timeout = Some(timeout.to_string());
214        self
215    }
216
217    /// Add a raw argument (escape hatch for unsupported options).
218    #[must_use]
219    pub fn arg(mut self, arg: impl Into<String>) -> Self {
220        self.raw_args.push(arg.into());
221        self
222    }
223
224    /// Push lock-related flags into the args list.
225    fn push_lock_flags(&self, args: &mut Vec<String>) {
226        if self.dry_run {
227            args.push("-dry-run".to_string());
228        }
229        if let Some(lock) = self.lock {
230            args.push(format!("-lock={lock}"));
231        }
232        if let Some(ref timeout) = self.lock_timeout {
233            args.push(format!("-lock-timeout={timeout}"));
234        }
235    }
236}
237
238impl TerraformCommand for StateCommand {
239    type Output = CommandOutput;
240
241    fn args(&self) -> Vec<String> {
242        let mut args = vec!["state".to_string()];
243        match &self.subcommand {
244            StateSubcommand::List => args.push("list".to_string()),
245            StateSubcommand::Show(address) => {
246                args.push("show".to_string());
247                args.push(address.clone());
248            }
249            StateSubcommand::Mv {
250                source,
251                destination,
252            } => {
253                args.push("mv".to_string());
254                self.push_lock_flags(&mut args);
255                args.push(source.clone());
256                args.push(destination.clone());
257            }
258            StateSubcommand::Rm(addresses) => {
259                args.push("rm".to_string());
260                self.push_lock_flags(&mut args);
261                args.extend(addresses.clone());
262            }
263            StateSubcommand::Pull => args.push("pull".to_string()),
264            StateSubcommand::Push => args.push("push".to_string()),
265            StateSubcommand::ReplaceProvider { from, to } => {
266                args.push("replace-provider".to_string());
267                if self.auto_approve {
268                    args.push("-auto-approve".to_string());
269                }
270                self.push_lock_flags(&mut args);
271                args.push(from.clone());
272                args.push(to.clone());
273            }
274        }
275        args.extend(self.raw_args.clone());
276        args
277    }
278
279    async fn execute(&self, tf: &Terraform) -> Result<CommandOutput> {
280        exec::run_terraform(tf, self.args()).await
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn list_args() {
290        let cmd = StateCommand::list();
291        assert_eq!(cmd.args(), vec!["state", "list"]);
292    }
293
294    #[test]
295    fn show_args() {
296        let cmd = StateCommand::show("null_resource.example");
297        assert_eq!(cmd.args(), vec!["state", "show", "null_resource.example"]);
298    }
299
300    #[test]
301    fn mv_args() {
302        let cmd = StateCommand::mv("null_resource.old", "null_resource.new");
303        assert_eq!(
304            cmd.args(),
305            vec!["state", "mv", "null_resource.old", "null_resource.new"]
306        );
307    }
308
309    #[test]
310    fn mv_dry_run_args() {
311        let cmd = StateCommand::mv("null_resource.old", "null_resource.new").dry_run();
312        assert_eq!(
313            cmd.args(),
314            vec![
315                "state",
316                "mv",
317                "-dry-run",
318                "null_resource.old",
319                "null_resource.new"
320            ]
321        );
322    }
323
324    #[test]
325    fn mv_lock_args() {
326        let cmd = StateCommand::mv("null_resource.old", "null_resource.new")
327            .lock(false)
328            .lock_timeout("10s");
329        assert_eq!(
330            cmd.args(),
331            vec![
332                "state",
333                "mv",
334                "-lock=false",
335                "-lock-timeout=10s",
336                "null_resource.old",
337                "null_resource.new"
338            ]
339        );
340    }
341
342    #[test]
343    fn rm_args() {
344        let cmd = StateCommand::rm(vec![
345            "null_resource.a".to_string(),
346            "null_resource.b".to_string(),
347        ]);
348        assert_eq!(
349            cmd.args(),
350            vec!["state", "rm", "null_resource.a", "null_resource.b"]
351        );
352    }
353
354    #[test]
355    fn rm_dry_run_args() {
356        let cmd = StateCommand::rm(vec!["null_resource.a".to_string()]).dry_run();
357        assert_eq!(
358            cmd.args(),
359            vec!["state", "rm", "-dry-run", "null_resource.a"]
360        );
361    }
362
363    #[test]
364    fn pull_args() {
365        let cmd = StateCommand::pull();
366        assert_eq!(cmd.args(), vec!["state", "pull"]);
367    }
368
369    #[test]
370    fn push_args() {
371        let cmd = StateCommand::push();
372        assert_eq!(cmd.args(), vec!["state", "push"]);
373    }
374
375    #[test]
376    fn replace_provider_args() {
377        let cmd = StateCommand::replace_provider(
378            "registry.terraform.io/-/aws",
379            "registry.terraform.io/hashicorp/aws",
380        );
381        assert_eq!(
382            cmd.args(),
383            vec![
384                "state",
385                "replace-provider",
386                "registry.terraform.io/-/aws",
387                "registry.terraform.io/hashicorp/aws"
388            ]
389        );
390    }
391
392    #[test]
393    fn replace_provider_auto_approve_args() {
394        let cmd = StateCommand::replace_provider(
395            "registry.terraform.io/-/aws",
396            "registry.terraform.io/hashicorp/aws",
397        )
398        .auto_approve()
399        .lock(false);
400        assert_eq!(
401            cmd.args(),
402            vec![
403                "state",
404                "replace-provider",
405                "-auto-approve",
406                "-lock=false",
407                "registry.terraform.io/-/aws",
408                "registry.terraform.io/hashicorp/aws"
409            ]
410        );
411    }
412
413    #[test]
414    fn replace_provider_lock_timeout_args() {
415        let cmd = StateCommand::replace_provider(
416            "registry.terraform.io/-/aws",
417            "registry.terraform.io/hashicorp/aws",
418        )
419        .lock_timeout("30s");
420        assert_eq!(
421            cmd.args(),
422            vec![
423                "state",
424                "replace-provider",
425                "-lock-timeout=30s",
426                "registry.terraform.io/-/aws",
427                "registry.terraform.io/hashicorp/aws"
428            ]
429        );
430    }
431
432    #[test]
433    fn list_ignores_mv_rm_flags() {
434        let cmd = StateCommand::list()
435            .dry_run()
436            .lock(false)
437            .lock_timeout("10s");
438        assert_eq!(cmd.args(), vec!["state", "list"]);
439    }
440}