1use crate::{Error, Result};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::process::Stdio;
6use tokio::process::Command;
7use tokio::time::{timeout, Duration};
8
9#[derive(Debug, Clone)]
10pub struct CliHandler {
11 pub command: String,
12 pub args: Vec<String>,
13 pub cwd: Option<String>,
14 pub env: HashMap<String, String>,
15 pub timeout_ms: Option<u64>,
16 pub stream: bool,
17}
18
19#[derive(Debug, Deserialize, JsonSchema)]
20pub struct CliInput {
21 #[serde(default)]
22 pub args: Vec<String>,
23 #[serde(default)]
24 pub env: HashMap<String, String>,
25}
26
27#[derive(Debug, Serialize, JsonSchema)]
28pub struct CliOutput {
29 pub stdout: String,
30 pub stderr: String,
31 pub exit_code: i32,
32}
33
34impl CliHandler {
35 pub fn new(
36 command: String,
37 args: Vec<String>,
38 cwd: Option<String>,
39 env: HashMap<String, String>,
40 timeout_ms: Option<u64>,
41 stream: bool,
42 ) -> Self {
43 Self {
44 command,
45 args,
46 cwd,
47 env,
48 timeout_ms,
49 stream,
50 }
51 }
52
53 pub async fn execute(&self, input: CliInput) -> Result<CliOutput> {
54 let mut cmd = Command::new(&self.command);
55
56 cmd.args(&self.args);
58
59 cmd.args(&input.args);
61
62 if let Some(cwd) = &self.cwd {
64 cmd.current_dir(cwd);
65 }
66
67 for (k, v) in &self.env {
69 cmd.env(k, v);
70 }
71 for (k, v) in &input.env {
72 cmd.env(k, v);
73 }
74
75 cmd.stdout(Stdio::piped());
77 cmd.stderr(Stdio::piped());
78
79 let exec_future = async {
81 let output = cmd.output().await.map_err(|e| {
82 Error::Handler(format!("Failed to execute command '{}': {}", self.command, e))
83 })?;
84
85 Ok::<_, Error>(CliOutput {
86 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
87 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
88 exit_code: output.status.code().unwrap_or(-1),
89 })
90 };
91
92 if let Some(timeout_ms) = self.timeout_ms {
93 timeout(Duration::from_millis(timeout_ms), exec_future)
94 .await
95 .map_err(|_| Error::Timeout)?
96 } else {
97 exec_future.await
98 }
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[tokio::test]
107 async fn test_cli_handler_new() {
108 let handler = CliHandler::new(
109 "echo".to_string(),
110 vec!["hello".to_string()],
111 None,
112 HashMap::new(),
113 None,
114 false,
115 );
116
117 assert_eq!(handler.command, "echo");
118 assert_eq!(handler.args.len(), 1);
119 assert_eq!(handler.args[0], "hello");
120 assert!(handler.cwd.is_none());
121 assert!(handler.env.is_empty());
122 assert!(handler.timeout_ms.is_none());
123 assert!(!handler.stream);
124 }
125
126 #[tokio::test]
127 async fn test_cli_handler_execute_simple() {
128 let handler = CliHandler::new(
129 "echo".to_string(),
130 vec!["hello".to_string()],
131 None,
132 HashMap::new(),
133 None,
134 false,
135 );
136
137 let input = CliInput {
138 args: vec![],
139 env: HashMap::new(),
140 };
141
142 let result = handler.execute(input).await;
143 assert!(result.is_ok());
144
145 let output = result.unwrap();
146 assert!(output.stdout.contains("hello"));
147 assert_eq!(output.exit_code, 0);
148 }
149
150 #[tokio::test]
151 async fn test_cli_handler_execute_with_input_args() {
152 let handler = CliHandler::new(
153 "echo".to_string(),
154 vec![],
155 None,
156 HashMap::new(),
157 None,
158 false,
159 );
160
161 let input = CliInput {
162 args: vec!["test".to_string(), "message".to_string()],
163 env: HashMap::new(),
164 };
165
166 let result = handler.execute(input).await;
167 assert!(result.is_ok());
168
169 let output = result.unwrap();
170 assert!(output.stdout.contains("test"));
171 assert!(output.stdout.contains("message"));
172 }
173
174 #[tokio::test]
175 async fn test_cli_handler_execute_with_timeout() {
176 let handler = CliHandler::new(
177 "sleep".to_string(),
178 vec!["2".to_string()],
179 None,
180 HashMap::new(),
181 Some(100), false,
183 );
184
185 let input = CliInput {
186 args: vec![],
187 env: HashMap::new(),
188 };
189
190 let result = handler.execute(input).await;
191 assert!(result.is_err());
192 assert!(matches!(result.unwrap_err(), Error::Timeout));
193 }
194
195 #[tokio::test]
196 async fn test_cli_handler_execute_invalid_command() {
197 let handler = CliHandler::new(
198 "nonexistent_command_that_should_fail".to_string(),
199 vec![],
200 None,
201 HashMap::new(),
202 None,
203 false,
204 );
205
206 let input = CliInput {
207 args: vec![],
208 env: HashMap::new(),
209 };
210
211 let result = handler.execute(input).await;
212 assert!(result.is_err());
213 assert!(matches!(result.unwrap_err(), Error::Handler(_)));
214 }
215
216 #[tokio::test]
217 async fn test_cli_handler_with_env() {
218 let mut env = HashMap::new();
219 env.insert("TEST_VAR".to_string(), "test_value".to_string());
220
221 let handler = CliHandler::new(
222 "sh".to_string(),
223 vec!["-c".to_string(), "echo $TEST_VAR".to_string()],
224 None,
225 env,
226 None,
227 false,
228 );
229
230 let input = CliInput {
231 args: vec![],
232 env: HashMap::new(),
233 };
234
235 let result = handler.execute(input).await;
236 assert!(result.is_ok());
237
238 let output = result.unwrap();
239 assert!(output.stdout.contains("test_value"));
240 }
241}