1use crate::{config::AppConfig, models::ActionResult};
3use std::path::Path;
4use std::time::Duration;
5use tokio::process::Command;
6use tokio::time::timeout;
7use tracing::{debug, error, info, warn};
8
9#[derive(Debug, Clone, Copy, PartialEq)]
11pub enum ScriptLanguage {
12 Deno,
13 Python,
14 Bash,
15}
16#[allow(clippy::should_implement_trait)]
17impl ScriptLanguage {
18 pub fn from_str(s: &str) -> Option<Self> {
20 match s {
21 "deno" => Some(ScriptLanguage::Deno),
22 "python" => Some(ScriptLanguage::Python),
23 "bash" => Some(ScriptLanguage::Bash),
24 _ => None,
25 }
26 }
27}
28
29pub async fn execute_script(
31 language: ScriptLanguage,
32 code: &str,
33 args: &[String],
34 project_root: &Path,
35 config: &AppConfig,
36) -> ActionResult {
37 if !config.script_execution_enabled {
39 return ActionResult {
40 success: false,
41 path: None,
42 content: None,
43 error: Some("Script execution is disabled by configuration".to_string()),
44 };
45 }
46
47 let mut cmd = match language {
49 ScriptLanguage::Deno => build_deno_command(code, args, project_root, config),
50 ScriptLanguage::Python => build_python_command(code, args, project_root, config),
51 ScriptLanguage::Bash => build_bash_command(code, args, project_root, config),
52 };
53
54 let timeout_secs = config.script_timeout_secs;
56 debug!("Executing script with timeout {}s", timeout_secs);
57
58 let result = timeout(Duration::from_secs(timeout_secs), cmd.output()).await;
60
61 match result {
62 Ok(Ok(output)) => {
63 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
64 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
65 let combined = format!("STDOUT:\n{}\nSTDERR:\n{}", stdout, stderr);
66 let success = output.status.success();
67 if success {
68 info!("Script executed successfully");
69 ActionResult {
70 success: true,
71 path: None,
72 content: Some(combined),
73 error: None,
74 }
75 } else {
76 warn!("Script failed with exit code: {:?}", output.status.code());
77 ActionResult {
78 success: false,
79 path: None,
80 content: Some(combined),
81 error: Some(format!("Script exited with status: {}", output.status)),
82 }
83 }
84 }
85 Ok(Err(e)) => {
86 error!("Failed to execute script: {}", e);
87 ActionResult {
88 success: false,
89 content: None,
90 path: None,
91 error: Some(format!("Failed to execute script: {}", e)),
92 }
93 }
94 Err(_) => {
95 warn!("Script execution timed out after {} seconds", timeout_secs);
96 ActionResult {
97 success: false,
98 content: None,
99 path: None,
100 error: Some(format!(
101 "Script execution timed out after {}s",
102 timeout_secs
103 )),
104 }
105 }
106 }
107}
108
109fn build_deno_command(
110 code: &str,
111 args: &[String],
112 project_root: &Path,
113 config: &AppConfig,
114) -> Command {
115 let mut cmd = Command::new("deno");
116 cmd.arg("eval").arg(code).current_dir(project_root);
117
118 if !config.script_enable_network {
120 cmd.arg("--allow-net=none");
121 }
122 cmd.arg(format!("--allow-read={}", project_root.display()))
123 .arg(format!("--allow-write={}", project_root.display()));
124
125 cmd.args(args);
126 cmd
127}
128
129fn build_python_command(
130 code: &str,
131 args: &[String],
132 project_root: &Path,
133 _config: &AppConfig,
134) -> Command {
135 let mut cmd = Command::new("python3");
136 cmd.arg("-c").arg(code).current_dir(project_root);
137 cmd.args(args);
138 cmd
139}
140
141fn build_bash_command(
142 code: &str,
143 args: &[String],
144 project_root: &Path,
145 _config: &AppConfig,
146) -> Command {
147 let mut cmd = Command::new("bash");
148 cmd.arg("-c")
149 .arg(code)
150 .current_dir(project_root)
151 .arg("bash"); cmd.args(args);
153 cmd
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use std::path::PathBuf;
160 use std::process::Stdio;
161 use tempfile::tempdir;
162
163 fn test_config() -> AppConfig {
165 AppConfig {
166 port: 3000,
167 workspace: PathBuf::from("/tmp"),
168 build_command: "".to_string(),
169 allowed_commands: vec![],
170 script_execution_enabled: true,
171 script_allowed_languages: vec![
172 "deno".to_string(),
173 "python".to_string(),
174 "bash".to_string(),
175 ],
176 script_timeout_secs: 5,
177 script_enable_network: false,
178 enable_worktrees: false,
179 worktrees_dir: PathBuf::from("/tmp"),
180 }
181 }
182
183 fn command_works(cmd: &str) -> bool {
185 std::process::Command::new(cmd)
186 .arg("--version")
187 .stdout(Stdio::null())
188 .stderr(Stdio::null())
189 .status()
190 .map(|status| status.success())
191 .unwrap_or(false)
192 }
193
194 #[test]
195 fn test_from_str() {
196 assert_eq!(ScriptLanguage::from_str("deno"), Some(ScriptLanguage::Deno));
197 assert_eq!(
198 ScriptLanguage::from_str("python"),
199 Some(ScriptLanguage::Python)
200 );
201 assert_eq!(ScriptLanguage::from_str("bash"), Some(ScriptLanguage::Bash));
202 assert_eq!(ScriptLanguage::from_str("ruby"), None);
203 assert_eq!(ScriptLanguage::from_str(""), None);
204 }
205
206 #[tokio::test]
207 async fn test_execute_script_disabled() {
208 let mut config = test_config();
209 config.script_execution_enabled = false;
210 let dir = tempdir().unwrap();
211 let result =
212 execute_script(ScriptLanguage::Bash, "echo hi", &[], dir.path(), &config).await;
213 assert!(!result.success);
214 assert!(result.error.unwrap().contains("disabled"));
215 }
216
217 #[tokio::test]
218 async fn test_bash_success() {
219 if !command_works("bash") {
220 return;
221 }
222 let config = test_config();
223 let dir = tempdir().unwrap();
224 let result =
225 execute_script(ScriptLanguage::Bash, "echo hello", &[], dir.path(), &config).await;
226 assert!(result.success, "Failed: {:?}", result.error);
227 let content = result.content.unwrap();
228 assert!(content.contains("hello"));
229 }
230
231 #[tokio::test]
232 async fn test_python_success() {
233 if !command_works("python3") && !command_works("python") {
234 return;
235 }
236 let config = test_config();
237 let dir = tempdir().unwrap();
238 let result = execute_script(
239 ScriptLanguage::Python,
240 "print('hello')",
241 &[],
242 dir.path(),
243 &config,
244 )
245 .await;
246 assert!(result.success, "Failed: {:?}", result.error);
247 let content = result.content.unwrap();
248 assert!(content.contains("hello"));
249 }
250
251 async fn deno_works() -> bool {
253 if !command_works("deno") {
254 return false;
255 }
256 let config = test_config();
258 let dir = tempdir().unwrap();
259 let result = execute_script(
260 ScriptLanguage::Deno,
261 "console.log('test')",
262 &[],
263 dir.path(),
264 &config,
265 )
266 .await;
267 result.success
268 }
269
270 #[tokio::test]
271 async fn test_deno_success() {
272 if !deno_works().await {
273 return;
274 }
275 let config = test_config();
276 let dir = tempdir().unwrap();
277 let result = execute_script(
278 ScriptLanguage::Deno,
279 "console.log('hello')",
280 &[],
281 dir.path(),
282 &config,
283 )
284 .await;
285 assert!(
286 result.success,
287 "Deno failed: {:?}\nOutput: {:?}",
288 result.error, result.content
289 );
290 let content = result.content.unwrap();
291 assert!(content.contains("hello"));
292 }
293
294 #[tokio::test]
295 async fn test_with_args() {
296 if !command_works("bash") {
297 return;
298 }
299 let config = test_config();
300 let dir = tempdir().unwrap();
301 let result = execute_script(
302 ScriptLanguage::Bash,
303 "echo $1",
304 &["arg1".to_string()],
305 dir.path(),
306 &config,
307 )
308 .await;
309 assert!(result.success);
310 assert!(result.content.unwrap().contains("arg1"));
311 }
312
313 #[tokio::test]
314 async fn test_timeout() {
315 if !command_works("bash") {
316 return;
317 }
318 let mut config = test_config();
319 config.script_timeout_secs = 1; let dir = tempdir().unwrap();
321 let result =
322 execute_script(ScriptLanguage::Bash, "sleep 3", &[], dir.path(), &config).await;
323 assert!(!result.success);
324 let err = result.error.unwrap();
325 assert!(err.contains("timed out") || err.contains("Timeout"));
326 }
327
328 #[tokio::test]
329 async fn test_failure_exit_code() {
330 if !command_works("bash") {
331 return;
332 }
333 let config = test_config();
334 let dir = tempdir().unwrap();
335 let result = execute_script(ScriptLanguage::Bash, "exit 1", &[], dir.path(), &config).await;
336 assert!(!result.success);
337 let err = result.error.unwrap();
338 assert!(err.contains("exited with status"));
339 }
340
341 #[tokio::test]
342 async fn test_deno_network_disabled() {
343 if !deno_works().await {
344 return;
345 }
346 let mut config = test_config();
347 config.script_enable_network = false;
348 let dir = tempdir().unwrap();
349 let result = execute_script(
351 ScriptLanguage::Deno,
352 "await fetch('https://example.com').then(r => r.status)",
353 &[],
354 dir.path(),
355 &config,
356 )
357 .await;
358 assert!(!result.success, "Expected failure but got success");
360 let err = result.error.unwrap();
361 assert!(!err.is_empty());
363 }
364
365 #[tokio::test]
366 async fn test_deno_network_enabled() {
367 if !deno_works().await {
368 return;
369 }
370 let mut config = test_config();
371 config.script_enable_network = true;
372 let dir = tempdir().unwrap();
373 let result = execute_script(
375 ScriptLanguage::Deno,
376 "console.log(await fetch('https://example.com').then(r => r.status))",
377 &[],
378 dir.path(),
379 &config,
380 )
381 .await;
382 if result.success {
383 let content = result.content.unwrap();
384 assert!(content.contains("200"));
385 } else {
386 println!(
388 "Deno network test skipped due to network issue: {:?}",
389 result.error
390 );
391 }
392 }
393}