rong_command 0.3.1

Command execution APIs for RongJS
use rong::{
    AnyJSTypedArray, HostError, JSArray, JSArrayBuffer, JSContext, JSObject, JSResult, JSValue,
};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum StreamMode {
    #[default]
    Pipe,
    Ignore,
    Inherit,
}

impl StreamMode {
    fn from_js_object(obj: &JSObject, key: &str, default: StreamMode) -> JSResult<Self> {
        if !obj.has_property(key)? {
            return Ok(default);
        }

        let value: JSValue = obj.get(key)?;
        if value.is_null() || value.is_undefined() {
            return Ok(StreamMode::Ignore);
        }

        let mode: String = value.to_rust().map_err(|_| {
            HostError::new(
                rong::error::E_TYPE,
                format!("options.{key} must be \"pipe\", \"ignore\", or \"inherit\""),
            )
            .with_name("TypeError")
        })?;

        match mode.as_str() {
            "pipe" => Ok(StreamMode::Pipe),
            "ignore" => Ok(StreamMode::Ignore),
            "inherit" => Ok(StreamMode::Inherit),
            _ => Err(HostError::new(
                rong::error::E_TYPE,
                format!("options.{key} must be \"pipe\", \"ignore\", or \"inherit\""),
            )
            .with_name("TypeError")
            .into()),
        }
    }
}

#[derive(Default)]
struct SpawnSyncOptions {
    cmd: Vec<String>,
    cwd: Option<String>,
    env: Option<HashMap<String, String>>,
    shell: bool,
    stdin: Option<Vec<u8>>,
    stdout: StreamMode,
    stderr: StreamMode,
    timeout: Option<u64>,
}

impl SpawnSyncOptions {
    fn from_js_object(obj: &JSObject) -> JSResult<Self> {
        let mut options = Self {
            stdout: StreamMode::Pipe,
            stderr: StreamMode::Pipe,
            ..Default::default()
        };

        let cmd_value: JSValue = obj.get("cmd")?;
        options.cmd = parse_string_array(&cmd_value, "options.cmd")?;
        if options.cmd.is_empty() {
            return Err(
                HostError::new(rong::error::E_INVALID_ARG, "options.cmd cannot be empty")
                    .with_name("TypeError")
                    .into(),
            );
        }

        if obj.has_property("cwd")? {
            options.cwd = Some(obj.get::<_, String>("cwd").map_err(|_| {
                HostError::new(rong::error::E_TYPE, "options.cwd must be a string")
                    .with_name("TypeError")
            })?);
        }

        if obj.has_property("env")? {
            let env_obj = obj.get::<_, JSObject>("env").map_err(|_| {
                HostError::new(rong::error::E_TYPE, "options.env must be an object")
                    .with_name("TypeError")
            })?;
            let entries = env_obj.entries_as::<String, String>().map_err(|_| {
                HostError::new(
                    rong::error::E_TYPE,
                    "options.env must contain string values",
                )
                .with_name("TypeError")
            })?;
            options.env = Some(entries.into_iter().collect());
        }

        if obj.has_property("shell")? {
            options.shell = obj.get::<_, bool>("shell").map_err(|_| {
                HostError::new(rong::error::E_TYPE, "options.shell must be a boolean")
                    .with_name("TypeError")
            })?;
        }

        if obj.has_property("stdin")? {
            let stdin_value: JSValue = obj.get("stdin")?;
            options.stdin = js_value_to_bytes(&stdin_value, "options.stdin")?;
        }

        options.stdout = StreamMode::from_js_object(obj, "stdout", StreamMode::Pipe)?;
        options.stderr = StreamMode::from_js_object(obj, "stderr", StreamMode::Pipe)?;

        if obj.has_property("timeout")? {
            let timeout = obj.get::<_, f64>("timeout").map_err(|_| {
                HostError::new(
                    rong::error::E_TYPE,
                    "options.timeout must be a non-negative number",
                )
                .with_name("TypeError")
            })?;
            if !timeout.is_finite() || timeout < 0.0 {
                return Err(HostError::new(
                    rong::error::E_TYPE,
                    "options.timeout must be a non-negative number",
                )
                .with_name("TypeError")
                .into());
            }
            options.timeout = Some(timeout as u64);
        }

        Ok(options)
    }
}

fn parse_string_array(value: &JSValue, label: &str) -> JSResult<Vec<String>> {
    let Some(obj) = value.clone().into_object() else {
        return Err(HostError::new(
            rong::error::E_TYPE,
            format!("{label} must be an array of strings"),
        )
        .with_name("TypeError")
        .into());
    };

    let Some(array) = JSArray::from_object(obj) else {
        return Err(HostError::new(
            rong::error::E_TYPE,
            format!("{label} must be an array of strings"),
        )
        .with_name("TypeError")
        .into());
    };

    let mut values = Vec::with_capacity(array.len()? as usize);
    for index in 0..array.len()? {
        let item = array.get_opt::<String>(index)?.ok_or_else(|| {
            HostError::new(
                rong::error::E_TYPE,
                format!("{label} must be an array of strings"),
            )
            .with_name("TypeError")
        })?;
        values.push(item);
    }

    Ok(values)
}

fn js_value_to_bytes(value: &JSValue, label: &str) -> JSResult<Option<Vec<u8>>> {
    if value.is_null() || value.is_undefined() {
        return Ok(None);
    }

    if !value.is_object() {
        let text: String = value.clone().to_rust().map_err(|_| {
            HostError::new(
                rong::error::E_TYPE,
                format!("{label} must be a string, ArrayBuffer, or TypedArray"),
            )
            .with_name("TypeError")
        })?;
        return Ok(Some(text.into_bytes()));
    }

    let obj = value.clone().into_object().ok_or_else(|| {
        HostError::new(
            rong::error::E_TYPE,
            format!("{label} must be a string, ArrayBuffer, or TypedArray"),
        )
        .with_name("TypeError")
    })?;

    if let Some(typed_array) = AnyJSTypedArray::from_object(obj.clone()) {
        let bytes = typed_array.byte_view().ok_or_else(|| {
            HostError::new(
                rong::error::E_TYPE,
                format!("{label} contains an invalid TypedArray"),
            )
            .with_name("TypeError")
        })?;
        return Ok(Some(bytes.to_vec()));
    }

    if let Some(array_buffer) = JSArrayBuffer::from_object(obj) {
        return Ok(Some(array_buffer.to_vec()));
    }

    Err(HostError::new(
        rong::error::E_TYPE,
        format!("{label} must be a string, ArrayBuffer, or TypedArray"),
    )
    .with_name("TypeError")
    .into())
}

#[cfg(not(target_os = "windows"))]
fn shell_escape(input: &str) -> String {
    if input.is_empty() {
        return "''".to_string();
    }
    format!("'{}'", input.replace('\'', "'\\''"))
}

fn build_sync_command(options: &SpawnSyncOptions) -> Command {
    let mut command = if options.shell {
        #[cfg(target_os = "windows")]
        {
            let mut cmd = Command::new("cmd");
            cmd.arg("/C");
            cmd.arg(options.cmd.join(" "));
            cmd
        }

        #[cfg(not(target_os = "windows"))]
        {
            let mut cmd = Command::new("sh");
            cmd.arg("-c");
            let shell_command = options
                .cmd
                .iter()
                .map(|part| shell_escape(part))
                .collect::<Vec<_>>()
                .join(" ");
            cmd.arg(shell_command);
            cmd
        }
    } else {
        let mut cmd = Command::new(&options.cmd[0]);
        cmd.args(&options.cmd[1..]);
        cmd
    };

    if let Some(cwd) = &options.cwd {
        command.current_dir(cwd);
    }

    if let Some(env) = &options.env {
        command.env_clear();
        for (key, value) in env {
            command.env(key, value);
        }
    }

    command.stdin(match options.stdin {
        Some(_) => Stdio::piped(),
        None => Stdio::null(),
    });
    command.stdout(match options.stdout {
        StreamMode::Pipe => Stdio::piped(),
        StreamMode::Ignore => Stdio::null(),
        StreamMode::Inherit => Stdio::inherit(),
    });
    command.stderr(match options.stderr {
        StreamMode::Pipe => Stdio::piped(),
        StreamMode::Ignore => Stdio::null(),
        StreamMode::Inherit => Stdio::inherit(),
    });

    command
}

fn read_pipe_to_end<T>(pipe: Option<T>) -> thread::JoinHandle<Vec<u8>>
where
    T: Read + Send + 'static,
{
    thread::spawn(move || {
        let mut bytes = Vec::new();
        if let Some(mut pipe) = pipe {
            let _ = pipe.read_to_end(&mut bytes);
        }
        bytes
    })
}

fn build_spawn_sync_result(
    ctx: &JSContext,
    exit_code: Option<i32>,
    stdout: Vec<u8>,
    stderr: Vec<u8>,
) -> JSResult<JSObject> {
    let result = JSObject::new(ctx);
    result.set("exitCode", exit_code)?;
    result.set("success", exit_code == Some(0))?;
    result.set("signalCode", JSValue::null(ctx))?;
    result.set("stdout", JSArrayBuffer::from_bytes_owned(ctx, stdout)?)?;
    result.set("stderr", JSArrayBuffer::from_bytes_owned(ctx, stderr)?)?;
    Ok(result)
}

pub(crate) fn spawn_sync_native(ctx: JSContext, options: JSObject) -> JSResult<JSObject> {
    let options = SpawnSyncOptions::from_js_object(&options)?;
    let mut command = build_sync_command(&options);
    let mut child = command
        .spawn()
        .map_err(|err| HostError::new(rong::error::E_IO, err.to_string()))?;

    if let Some(stdin_bytes) = &options.stdin
        && let Some(mut stdin) = child.stdin.take()
    {
        stdin
            .write_all(stdin_bytes)
            .map_err(|err| HostError::new(rong::error::E_IO, err.to_string()))?;
    }

    let stdout_task = read_pipe_to_end(child.stdout.take());
    let stderr_task = read_pipe_to_end(child.stderr.take());

    let timeout = options.timeout.map(Duration::from_millis);
    let start = Instant::now();
    let status = loop {
        match child
            .try_wait()
            .map_err(|err| HostError::new(rong::error::E_IO, err.to_string()))?
        {
            Some(status) => break status,
            None => {
                if let Some(timeout) = timeout
                    && start.elapsed() >= timeout
                {
                    let _ = child.kill();
                    let status = child
                        .wait()
                        .map_err(|err| HostError::new(rong::error::E_IO, err.to_string()))?;
                    let stdout = stdout_task.join().unwrap_or_default();
                    let stderr = stderr_task.join().unwrap_or_default();
                    return build_spawn_sync_result(&ctx, status.code(), stdout, stderr);
                }

                thread::sleep(Duration::from_millis(10));
            }
        }
    };

    let stdout = stdout_task.join().unwrap_or_default();
    let stderr = stderr_task.join().unwrap_or_default();

    build_spawn_sync_result(&ctx, status.code(), stdout, stderr)
}

pub fn init(ctx: &JSContext) -> JSResult<()> {
    let _ = ctx;
    Ok(())
}