1use crate::error::{Error, Result};
4use crate::types::{ExecResult, IoOptions, ScriptOptions, SpawnResult};
5use std::io::Write;
6use std::path::PathBuf;
7use std::process::Stdio;
8use std::time::Instant;
9use tempfile::NamedTempFile;
10use tokio::process::{Child, Command};
11use tokio::time::timeout;
12
13pub async fn run(script: &str, options: ScriptOptions) -> Result<ExecResult> {
44 let start_time = Instant::now();
45
46 if script.trim().is_empty() {
48 return Err(Error::command_failed("Script content cannot be empty"));
49 }
50
51 let mut temp_file = NamedTempFile::new()
53 .map_err(Error::script_write_error)?;
54
55 temp_file.write_all(script.as_bytes())
56 .map_err(Error::script_write_error)?;
57
58 temp_file.flush()
59 .map_err(Error::script_write_error)?;
60
61 let script_path = temp_file.path().to_path_buf();
62
63 if let Some(ref wd) = options.working_directory {
65 if !wd.exists() {
66 return Err(Error::invalid_working_directory(
67 wd.to_string_lossy(),
68 std::io::Error::new(std::io::ErrorKind::NotFound, "Directory does not exist")
69 ));
70 }
71 if !wd.is_dir() {
72 return Err(Error::invalid_working_directory(
73 wd.to_string_lossy(),
74 std::io::Error::new(std::io::ErrorKind::InvalidInput, "Path is not a directory")
75 ));
76 }
77 }
78
79 let timeout_duration = options.timeout;
80 let child = spawn_command(&script_path, &options)
81 .await?;
82
83 let output = if let Some(timeout_duration) = timeout_duration {
85 match timeout(timeout_duration, child.wait_with_output()).await {
86 Ok(result) => result.map_err(Error::process_wait_error)?,
87 Err(_) => {
88 return Ok(ExecResult {
91 exit_code: -1,
92 stdout: String::new(),
93 stderr: format!("Process timed out after {:?}", timeout_duration),
94 duration: start_time.elapsed(),
95 timed_out: true,
96 });
97 }
98 }
99 } else {
100 child.wait_with_output().await
101 .map_err(Error::process_wait_error)?
102 };
103
104 let duration = start_time.elapsed();
105 let exit_code = output.status.code().unwrap_or(-1);
106
107 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
108 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
109
110 Ok(ExecResult {
111 exit_code,
112 stdout,
113 stderr,
114 duration,
115 timed_out: false,
116 })
117}
118
119pub async fn run_file(path: &PathBuf, options: ScriptOptions) -> Result<ExecResult> {
152 if !path.exists() {
154 return Err(Error::script_read_error(
155 path.to_string_lossy(),
156 std::io::Error::new(std::io::ErrorKind::NotFound, "Script file does not exist")
157 ));
158 }
159
160 if !path.is_file() {
161 return Err(Error::invalid_script_path(
162 path.to_string_lossy(),
163 "Path is not a file"
164 ));
165 }
166
167 match std::fs::metadata(path) {
169 Ok(metadata) => {
170 if metadata.len() == 0 {
171 return Err(Error::invalid_script_path(
172 path.to_string_lossy(),
173 "Script file is empty"
174 ));
175 }
176 }
177 Err(e) => {
178 return Err(Error::script_read_error(path.to_string_lossy(), e));
179 }
180 }
181
182 let start_time = Instant::now();
183 let timeout_duration = options.timeout;
184 let child = spawn_command(path, &options).await?;
185
186 let output = if let Some(timeout_duration) = timeout_duration {
188 match timeout(timeout_duration, child.wait_with_output()).await {
189 Ok(result) => result.map_err(Error::process_wait_error)?,
190 Err(_) => {
191 return Ok(ExecResult {
194 exit_code: -1,
195 stdout: String::new(),
196 stderr: format!("Process timed out after {:?}", timeout_duration),
197 duration: start_time.elapsed(),
198 timed_out: true,
199 });
200 }
201 }
202 } else {
203 child.wait_with_output().await
204 .map_err(Error::process_wait_error)?
205 };
206
207 let duration = start_time.elapsed();
208 let exit_code = output.status.code().unwrap_or(-1);
209
210 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
211 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
212
213 Ok(ExecResult {
214 exit_code,
215 stdout,
216 stderr,
217 duration,
218 timed_out: false,
219 })
220}
221
222pub async fn spawn(script: &str, options: ScriptOptions) -> Result<SpawnResult> {
254 if script.trim().is_empty() {
256 return Err(Error::command_failed("Script content cannot be empty"));
257 }
258
259 let mut temp_file = NamedTempFile::new()
261 .map_err(Error::script_write_error)?;
262
263 temp_file.write_all(script.as_bytes())
264 .map_err(Error::script_write_error)?;
265
266 temp_file.flush()
267 .map_err(Error::script_write_error)?;
268
269 let script_path = temp_file.path().to_path_buf();
270 let child = spawn_command(&script_path, &options).await?;
271
272 Ok(SpawnResult {
273 child,
274 _temp_file: Some(temp_file),
275 })
276}
277
278pub async fn spawn_file(path: &PathBuf, options: ScriptOptions) -> Result<Child> {
292 if !path.exists() {
294 return Err(Error::script_read_error(
295 path.to_string_lossy(),
296 std::io::Error::new(std::io::ErrorKind::NotFound, "Script file does not exist")
297 ));
298 }
299
300 if !path.is_file() {
301 return Err(Error::invalid_script_path(
302 path.to_string_lossy(),
303 "Path is not a file"
304 ));
305 }
306
307 spawn_command(path, &options).await
308}
309
310async fn spawn_command(path: &PathBuf, options: &ScriptOptions) -> Result<Child> {
311 let openscript_path = options
312 .openscript_path
313 .as_ref()
314 .cloned()
315 .unwrap_or_else(|| PathBuf::from("openscript"));
316
317 if openscript_path.is_absolute() && !openscript_path.exists() {
319 return Err(Error::OpenScriptNotFound);
320 }
321
322 let mut cmd = Command::new(&openscript_path);
323
324 cmd.arg(path);
325 cmd.args(&options.args);
326
327 if let Some(ref cwd) = options.working_directory {
329 if !cwd.exists() {
330 return Err(Error::invalid_working_directory(
331 cwd.to_string_lossy(),
332 std::io::Error::new(std::io::ErrorKind::NotFound, "Directory does not exist")
333 ));
334 }
335 cmd.current_dir(cwd);
336 }
337
338 if options.clear_env {
339 cmd.env_clear();
340 }
341
342 for (key, value) in &options.env_vars {
344 if key.contains('\0') || value.contains('\0') {
345 return Err(Error::invalid_environment_variable(
346 key,
347 "Environment variable contains null bytes"
348 ));
349 }
350 cmd.env(key, value);
351 }
352
353 cmd.stdin(convert_io(options.stdin));
354 cmd.stdout(convert_io(options.stdout));
355 cmd.stderr(convert_io(options.stderr));
356
357 let child = cmd.spawn().map_err(|e| {
358 match e.kind() {
359 std::io::ErrorKind::NotFound => Error::OpenScriptNotFound,
360 std::io::ErrorKind::PermissionDenied => Error::PermissionDenied,
361 _ => Error::process_spawn_error(e),
362 }
363 })?;
364
365 Ok(child)
366}
367
368fn convert_io(io_option: IoOptions) -> Stdio {
369 match io_option {
370 IoOptions::Inherit => Stdio::inherit(),
371 IoOptions::Pipe => Stdio::piped(),
372 IoOptions::Null => Stdio::null(),
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use std::time::Duration;
380
381 #[tokio::test]
382 async fn test_run_success() {
383 let options = ScriptOptions::new().openscript_path("/bin/sh");
384 let result = run("echo 'test'", options).await.unwrap();
385 assert_eq!(result.exit_code, 0);
386 assert!(result.stdout.contains("test"));
387 }
388
389 #[tokio::test]
390 async fn test_run_empty_script() {
391 let options = ScriptOptions::new().openscript_path("/bin/sh");
392 let result = run("", options).await;
393 assert!(result.is_err());
394 assert!(matches!(result.unwrap_err(), Error::CommandFailed { .. }));
395 }
396
397 #[tokio::test]
398 async fn test_run_with_timeout() {
399 let options = ScriptOptions::new()
400 .openscript_path("/bin/sh")
401 .timeout(Duration::from_millis(100));
402 let result = run("sleep 1", options).await.unwrap();
403 assert!(result.timed_out);
404 }
405
406 #[tokio::test]
407 async fn test_spawn_and_wait() -> crate::Result<()> {
408 let options = ScriptOptions::new().openscript_path("/bin/sh");
409 let spawn_result = spawn("echo 'spawned'", options).await?;
410 let output = spawn_result.child.wait_with_output().await?;
411 let stdout = String::from_utf8_lossy(&output.stdout);
412 assert!(stdout.contains("spawned"));
413 Ok(())
414 }
415
416 #[tokio::test]
417 async fn test_invalid_working_directory() {
418 let options = ScriptOptions::new()
419 .openscript_path("/bin/sh")
420 .working_directory("/nonexistent/directory");
421 let result = run("echo 'test'", options).await;
422 assert!(result.is_err());
423 assert!(matches!(result.unwrap_err(), Error::InvalidWorkingDirectory { .. }));
424 }
425
426 #[tokio::test]
427 async fn test_invalid_script_file() {
428 let options = ScriptOptions::new().openscript_path("/bin/sh");
429 let non_existent_path = PathBuf::from("/nonexistent/script.sh");
430 let result = run_file(&non_existent_path, options).await;
431 assert!(result.is_err());
432 assert!(matches!(result.unwrap_err(), Error::ScriptReadError { .. }));
433 }
434
435 #[tokio::test]
436 async fn test_error_retryability() {
437 let timeout_error = Error::timeout(Duration::from_secs(5));
438 assert!(timeout_error.is_retryable());
439
440 let not_found_error = Error::OpenScriptNotFound;
441 assert!(!not_found_error.is_retryable());
442 }
443
444 #[tokio::test]
445 async fn test_user_friendly_messages() {
446 let error = Error::OpenScriptNotFound;
447 let message = error.user_message();
448 assert!(message.contains("install OpenScript"));
449 }
450}