openrunner_rs/
runner.rs

1//! Core execution functions for running OpenScript code.
2
3use 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
13/// Run an OpenScript from a string and wait for completion.
14///
15/// This function creates a temporary file with the script content and executes it
16/// using the OpenScript interpreter, waiting for completion and capturing the output.
17///
18/// # Arguments
19///
20/// * `script` - The OpenScript code to execute
21/// * `options` - Configuration options for script execution
22///
23/// # Returns
24///
25/// Returns an `ExecResult` containing the exit code, stdout, stderr, and execution duration.
26///
27/// # Examples
28///
29/// ```rust
30/// use openrunner_rs::{run, ScriptOptions};
31/// use std::time::Duration;
32///
33/// # #[tokio::main]
34/// # async fn main() -> openrunner_rs::Result<()> {
35/// let options = ScriptOptions::new()
36///     .openscript_path("/bin/sh")
37///     .timeout(Duration::from_secs(30));
38/// let result = run("echo 'Hello, World!'", options).await?;
39/// println!("Output: {}", result.stdout);
40/// # Ok(())
41/// # }
42/// ```
43pub async fn run(script: &str, options: ScriptOptions) -> Result<ExecResult> {
44    let start_time = Instant::now();
45    
46    // Validate script is not empty
47    if script.trim().is_empty() {
48        return Err(Error::command_failed("Script content cannot be empty"));
49    }
50
51    // Create temporary file for the script
52    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    // Validate working directory if specified
64    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    // Handle timeout if specified
84    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                // Note: child is consumed by wait_with_output(), so we can't kill it here
89                // The timeout mechanism in tokio will handle process cleanup
90                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
119/// Run an OpenScript file and wait for completion.
120///
121/// This function executes a script file using the OpenScript interpreter,
122/// waiting for completion and capturing the output.
123///
124/// # Arguments
125///
126/// * `path` - Path to the script file to execute
127/// * `options` - Configuration options for script execution
128///
129/// # Returns
130///
131/// Returns an `ExecResult` containing the exit code, stdout, stderr, and execution duration.
132///
133/// # Examples
134///
135/// ```rust
136/// use openrunner_rs::{run_file, ScriptOptions};
137/// use std::path::PathBuf;
138/// use std::io::Write;
139///
140/// # #[tokio::main]
141/// # async fn main() -> openrunner_rs::Result<()> {
142/// # let mut temp_file = tempfile::NamedTempFile::new().unwrap();
143/// # writeln!(temp_file, "echo 'Hello from file!'").unwrap();
144/// # let script_path = temp_file.path().to_path_buf();
145/// let options = ScriptOptions::new().openscript_path("/bin/sh");
146/// let result = run_file(&script_path, options).await?;
147/// println!("Exit code: {}", result.exit_code);
148/// # Ok(())
149/// # }
150/// ```
151pub async fn run_file(path: &PathBuf, options: ScriptOptions) -> Result<ExecResult> {
152    // Validate the script file exists and is readable
153    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    // Check if file is readable
168    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    // Handle timeout if specified
187    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                // Note: child is consumed by wait_with_output(), so we can't kill it here
192                // The timeout mechanism in tokio will handle process cleanup
193                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
222/// Spawn an OpenScript process from a string without waiting for completion.
223///
224/// This function creates a temporary file with the script content and spawns
225/// the OpenScript process, returning a `Child` handle for further interaction.
226///
227/// The returned `SpawnResult` contains the `Child` and a handle to the temporary
228/// file, ensuring it is not deleted until the `SpawnResult` is dropped.
229///
230/// # Arguments
231///
232/// * `script` - The OpenScript code to execute
233/// * `options` - Configuration options for script execution
234///
235/// # Returns
236///
237/// Returns a `SpawnResult` that can be used to interact with the running process.
238///
239/// # Examples
240///
241/// ```rust
242/// use openrunner_rs::{spawn, ScriptOptions};
243///
244/// # #[tokio::main]
245/// # async fn main() -> openrunner_rs::Result<()> {
246/// let options = ScriptOptions::new().openscript_path("/bin/sh");
247/// let spawn_result = spawn("echo 'Background task'", options).await?;
248/// let output = spawn_result.child.wait_with_output().await?;
249/// println!("Output: {}", String::from_utf8_lossy(&output.stdout));
250/// # Ok(())
251/// # }
252/// ```
253pub async fn spawn(script: &str, options: ScriptOptions) -> Result<SpawnResult> {
254    // Validate script is not empty
255    if script.trim().is_empty() {
256        return Err(Error::command_failed("Script content cannot be empty"));
257    }
258
259    // Create temporary file for the script
260    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
278/// Spawn an OpenScript process from a file without waiting for completion.
279///
280/// This function spawns a script file using the OpenScript interpreter and returns
281/// a `Child` handle for further interaction.
282///
283/// # Arguments
284///
285/// * `path` - Path to the script file to execute
286/// * `options` - Configuration options for script execution
287///
288/// # Returns
289///
290/// Returns a `Child` process handle.
291pub async fn spawn_file(path: &PathBuf, options: ScriptOptions) -> Result<Child> {
292    // Validate the script file exists and is readable
293    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    // Validate openscript executable exists if it's an absolute path
318    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    // Validate and set working directory
328    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    // Validate environment variables
343    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}