Skip to main content

propel_cloud/
executor.rs

1use crate::gcloud::GcloudError;
2
3/// Abstraction over gcloud CLI execution for testability.
4///
5/// Production code uses [`RealExecutor`], tests use mockall-generated mocks.
6#[allow(async_fn_in_trait)]
7pub trait GcloudExecutor: Send + Sync {
8    /// Execute a gcloud command and capture stdout.
9    async fn exec(&self, args: &[String]) -> Result<String, GcloudError>;
10
11    /// Execute a gcloud command, streaming output to the terminal.
12    async fn exec_streaming(&self, args: &[String]) -> Result<(), GcloudError>;
13
14    /// Execute a gcloud command with data piped to stdin.
15    async fn exec_with_stdin(
16        &self,
17        args: &[String],
18        stdin_data: &[u8],
19    ) -> Result<String, GcloudError>;
20}
21
22/// Real gcloud CLI executor.
23pub struct RealExecutor;
24
25impl GcloudExecutor for RealExecutor {
26    async fn exec(&self, args: &[String]) -> Result<String, GcloudError> {
27        use std::process::Stdio;
28
29        let output = tokio::process::Command::new("gcloud")
30            .args(args)
31            .stdout(Stdio::piped())
32            .stderr(Stdio::piped())
33            .output()
34            .await
35            .map_err(|e| GcloudError::NotFound { source: e })?;
36
37        if output.status.success() {
38            String::from_utf8(output.stdout).map_err(|e| GcloudError::InvalidUtf8 { source: e })
39        } else {
40            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
41            Err(GcloudError::CommandFailed {
42                args: args.to_vec(),
43                stderr,
44            })
45        }
46    }
47
48    async fn exec_streaming(&self, args: &[String]) -> Result<(), GcloudError> {
49        use std::process::Stdio;
50
51        let status = tokio::process::Command::new("gcloud")
52            .args(args)
53            .stdout(Stdio::inherit())
54            .stderr(Stdio::inherit())
55            .status()
56            .await
57            .map_err(|e| GcloudError::NotFound { source: e })?;
58
59        if status.success() {
60            Ok(())
61        } else {
62            Err(GcloudError::CommandFailed {
63                args: args.to_vec(),
64                stderr: format!("exit code: {status}"),
65            })
66        }
67    }
68
69    async fn exec_with_stdin(
70        &self,
71        args: &[String],
72        stdin_data: &[u8],
73    ) -> Result<String, GcloudError> {
74        use std::process::Stdio;
75        use tokio::io::AsyncWriteExt;
76
77        let mut child = tokio::process::Command::new("gcloud")
78            .args(args)
79            .stdin(Stdio::piped())
80            .stdout(Stdio::piped())
81            .stderr(Stdio::piped())
82            .spawn()
83            .map_err(|e| GcloudError::NotFound { source: e })?;
84
85        if let Some(mut stdin) = child.stdin.take() {
86            stdin
87                .write_all(stdin_data)
88                .await
89                .map_err(|e| GcloudError::StdinWrite { source: e })?;
90            stdin
91                .shutdown()
92                .await
93                .map_err(|e| GcloudError::StdinWrite { source: e })?;
94        }
95
96        let output = child
97            .wait_with_output()
98            .await
99            .map_err(|e| GcloudError::NotFound { source: e })?;
100
101        if output.status.success() {
102            String::from_utf8(output.stdout).map_err(|e| GcloudError::InvalidUtf8 { source: e })
103        } else {
104            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
105            Err(GcloudError::CommandFailed {
106                args: args.to_vec(),
107                stderr,
108            })
109        }
110    }
111}