1use crate::{Error, Result};
2use rustc_hash::FxHashMap;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
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: FxHashMap<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: FxHashMap<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: FxHashMap<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!(
83 "Failed to execute command '{}': {}",
84 self.command, e
85 ))
86 })?;
87
88 Ok::<_, Error>(CliOutput {
89 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
90 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
91 exit_code: output.status.code().unwrap_or(-1),
92 })
93 };
94
95 if let Some(timeout_ms) = self.timeout_ms {
96 timeout(Duration::from_millis(timeout_ms), exec_future)
97 .await
98 .map_err(|_| Error::Timeout)?
99 } else {
100 exec_future.await
101 }
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[tokio::test]
110 async fn test_cli_handler_new() {
111 let handler = CliHandler::new(
112 "echo".to_string(),
113 vec!["hello".to_string()],
114 None,
115 FxHashMap::default(),
116 None,
117 false,
118 );
119
120 assert_eq!(handler.command, "echo");
121 assert_eq!(handler.args.len(), 1);
122 assert_eq!(handler.args[0], "hello");
123 assert!(handler.cwd.is_none());
124 assert!(handler.env.is_empty());
125 assert!(handler.timeout_ms.is_none());
126 assert!(!handler.stream);
127 }
128
129 #[tokio::test]
130 async fn test_cli_handler_execute_simple() {
131 let handler = CliHandler::new(
132 "echo".to_string(),
133 vec!["hello".to_string()],
134 None,
135 FxHashMap::default(),
136 None,
137 false,
138 );
139
140 let input = CliInput {
141 args: vec![],
142 env: FxHashMap::default(),
143 };
144
145 let result = handler.execute(input).await;
146 assert!(result.is_ok());
147
148 let output = result.unwrap();
149 assert!(output.stdout.contains("hello"));
150 assert_eq!(output.exit_code, 0);
151 }
152
153 #[tokio::test]
154 async fn test_cli_handler_execute_with_input_args() {
155 let handler = CliHandler::new(
156 "echo".to_string(),
157 vec![],
158 None,
159 FxHashMap::default(),
160 None,
161 false,
162 );
163
164 let input = CliInput {
165 args: vec!["test".to_string(), "message".to_string()],
166 env: FxHashMap::default(),
167 };
168
169 let result = handler.execute(input).await;
170 assert!(result.is_ok());
171
172 let output = result.unwrap();
173 assert!(output.stdout.contains("test"));
174 assert!(output.stdout.contains("message"));
175 }
176
177 #[tokio::test]
178 async fn test_cli_handler_execute_with_timeout() {
179 let handler = CliHandler::new(
180 "sleep".to_string(),
181 vec!["2".to_string()],
182 None,
183 FxHashMap::default(),
184 Some(100), false,
186 );
187
188 let input = CliInput {
189 args: vec![],
190 env: FxHashMap::default(),
191 };
192
193 let result = handler.execute(input).await;
194 assert!(result.is_err());
195 assert!(matches!(result.unwrap_err(), Error::Timeout));
196 }
197
198 #[tokio::test]
199 async fn test_cli_handler_execute_invalid_command() {
200 let handler = CliHandler::new(
201 "nonexistent_command_that_should_fail".to_string(),
202 vec![],
203 None,
204 FxHashMap::default(),
205 None,
206 false,
207 );
208
209 let input = CliInput {
210 args: vec![],
211 env: FxHashMap::default(),
212 };
213
214 let result = handler.execute(input).await;
215 assert!(result.is_err());
216 assert!(matches!(result.unwrap_err(), Error::Handler(_)));
217 }
218
219 #[tokio::test]
220 async fn test_cli_handler_with_env() {
221 let mut env = FxHashMap::default();
222 env.insert("TEST_VAR".to_string(), "test_value".to_string());
223
224 let handler = CliHandler::new(
225 "sh".to_string(),
226 vec!["-c".to_string(), "echo $TEST_VAR".to_string()],
227 None,
228 env,
229 None,
230 false,
231 );
232
233 let input = CliInput {
234 args: vec![],
235 env: FxHashMap::default(),
236 };
237
238 let result = handler.execute(input).await;
239 assert!(result.is_ok());
240
241 let output = result.unwrap();
242 assert!(output.stdout.contains("test_value"));
243 }
244}