Skip to main content

terraform_wrapper/commands/
output.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use crate::Terraform;
5use crate::command::TerraformCommand;
6use crate::error::Result;
7use crate::exec;
8
9/// Result from an output command.
10///
11/// The variant returned depends on which flags were set on the command:
12/// - `.json()` returns [`OutputResult::Json`] or [`OutputResult::Single`]
13/// - `.raw()` returns [`OutputResult::Raw`]
14/// - Neither returns [`OutputResult::Plain`]
15#[derive(Debug, Clone)]
16pub enum OutputResult {
17    /// Raw string value from `-raw` flag.
18    Raw(String),
19    /// All output values as JSON (when `.json()` and no `.name()`).
20    #[cfg(feature = "json")]
21    Json(HashMap<String, crate::types::output::OutputValue>),
22    /// Single output value as JSON (when `.json()` and `.name()`).
23    #[cfg(feature = "json")]
24    Single(crate::types::output::OutputValue),
25    /// Plain command output (no `-json` or `-raw`).
26    Plain(exec::CommandOutput),
27}
28
29impl fmt::Display for OutputResult {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            OutputResult::Raw(s) => write!(f, "{s}"),
33            #[cfg(feature = "json")]
34            OutputResult::Single(v) => {
35                let pretty = serde_json::to_string_pretty(v).map_err(|_| fmt::Error)?;
36                write!(f, "{pretty}")
37            }
38            #[cfg(feature = "json")]
39            OutputResult::Json(map) => {
40                let pretty = serde_json::to_string_pretty(map).map_err(|_| fmt::Error)?;
41                write!(f, "{pretty}")
42            }
43            OutputResult::Plain(output) => write!(f, "{output}"),
44        }
45    }
46}
47
48/// Command for reading Terraform output values.
49///
50/// ```no_run
51/// # async fn example() -> terraform_wrapper::error::Result<()> {
52/// use terraform_wrapper::{Terraform, TerraformCommand};
53/// use terraform_wrapper::commands::output::{OutputCommand, OutputResult};
54///
55/// let tf = Terraform::builder().working_dir("/tmp/infra").build()?;
56///
57/// // Get all outputs as JSON
58/// let result = OutputCommand::new().json().execute(&tf).await?;
59///
60/// // Get a single raw output value
61/// let result = OutputCommand::new()
62///     .name("public_ip")
63///     .raw()
64///     .execute(&tf)
65///     .await?;
66/// # Ok(())
67/// # }
68/// ```
69#[derive(Debug, Clone, Default)]
70pub struct OutputCommand {
71    name: Option<String>,
72    json: bool,
73    raw: bool,
74    raw_args: Vec<String>,
75}
76
77impl OutputCommand {
78    /// Create a new output command.
79    #[must_use]
80    pub fn new() -> Self {
81        Self::default()
82    }
83
84    /// Request a specific named output (positional argument).
85    #[must_use]
86    pub fn name(mut self, name: &str) -> Self {
87        self.name = Some(name.to_string());
88        self
89    }
90
91    /// Request JSON-formatted output (`-json`).
92    #[must_use]
93    pub fn json(mut self) -> Self {
94        self.json = true;
95        self
96    }
97
98    /// Request raw output value (`-raw`). Requires `.name()` to be set.
99    #[must_use]
100    pub fn raw(mut self) -> Self {
101        self.raw = true;
102        self
103    }
104
105    /// Add a raw argument (escape hatch for unsupported options).
106    #[must_use]
107    pub fn arg(mut self, arg: impl Into<String>) -> Self {
108        self.raw_args.push(arg.into());
109        self
110    }
111}
112
113impl TerraformCommand for OutputCommand {
114    type Output = OutputResult;
115
116    fn args(&self) -> Vec<String> {
117        let mut args = vec!["output".to_string()];
118        if self.json {
119            args.push("-json".to_string());
120        }
121        if self.raw {
122            args.push("-raw".to_string());
123        }
124        args.extend(self.raw_args.clone());
125        if let Some(ref name) = self.name {
126            args.push(name.clone());
127        }
128        args
129    }
130
131    async fn execute(&self, tf: &Terraform) -> Result<OutputResult> {
132        let output = exec::run_terraform(tf, self.args()).await?;
133
134        if self.raw {
135            return Ok(OutputResult::Raw(output.stdout.trim_end().to_string()));
136        }
137
138        #[cfg(feature = "json")]
139        if self.json {
140            if self.name.is_some() {
141                let value: crate::types::output::OutputValue = serde_json::from_str(&output.stdout)
142                    .map_err(|e| crate::error::Error::Json {
143                        message: "failed to parse output json".to_string(),
144                        source: e,
145                    })?;
146                return Ok(OutputResult::Single(value));
147            }
148            let values: HashMap<String, crate::types::output::OutputValue> =
149                serde_json::from_str(&output.stdout).map_err(|e| crate::error::Error::Json {
150                    message: "failed to parse output json".to_string(),
151                    source: e,
152                })?;
153            return Ok(OutputResult::Json(values));
154        }
155
156        Ok(OutputResult::Plain(output))
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn default_args() {
166        let cmd = OutputCommand::new();
167        assert_eq!(cmd.args(), vec!["output"]);
168    }
169
170    #[test]
171    fn json_all_outputs() {
172        let cmd = OutputCommand::new().json();
173        assert_eq!(cmd.args(), vec!["output", "-json"]);
174    }
175
176    #[test]
177    fn raw_named_output() {
178        let cmd = OutputCommand::new().name("public_ip").raw();
179        let args = cmd.args();
180        assert_eq!(args, vec!["output", "-raw", "public_ip"]);
181    }
182
183    #[test]
184    fn json_named_output() {
185        let cmd = OutputCommand::new().name("vpc_id").json();
186        let args = cmd.args();
187        assert_eq!(args, vec!["output", "-json", "vpc_id"]);
188    }
189
190    #[test]
191    fn name_at_end() {
192        let cmd = OutputCommand::new().name("endpoint").arg("-no-color");
193        let args = cmd.args();
194        // Name should be the last positional argument
195        assert_eq!(args.last().unwrap(), "endpoint");
196    }
197
198    #[test]
199    fn display_raw() {
200        let result = OutputResult::Raw("10.0.1.5".to_string());
201        assert_eq!(result.to_string(), "10.0.1.5");
202    }
203
204    #[test]
205    fn display_plain() {
206        let output = exec::CommandOutput {
207            stdout: "some output\n".to_string(),
208            stderr: String::new(),
209            exit_code: 0,
210            success: true,
211        };
212        let result = OutputResult::Plain(output);
213        assert_eq!(result.to_string(), "some output");
214    }
215
216    #[cfg(feature = "json")]
217    #[test]
218    fn display_single() {
219        let value = crate::types::output::OutputValue {
220            sensitive: false,
221            output_type: serde_json::json!("string"),
222            value: serde_json::json!("10.0.1.5"),
223        };
224        let result = OutputResult::Single(value);
225        let displayed = result.to_string();
226        assert!(displayed.contains("\"value\": \"10.0.1.5\""));
227        assert!(displayed.contains("\"sensitive\": false"));
228    }
229
230    #[cfg(feature = "json")]
231    #[test]
232    fn display_json_map() {
233        let mut map = HashMap::new();
234        map.insert(
235            "ip".to_string(),
236            crate::types::output::OutputValue {
237                sensitive: false,
238                output_type: serde_json::json!("string"),
239                value: serde_json::json!("1.2.3.4"),
240            },
241        );
242        let result = OutputResult::Json(map);
243        let displayed = result.to_string();
244        assert!(displayed.contains("\"ip\""));
245        assert!(displayed.contains("\"value\": \"1.2.3.4\""));
246    }
247}