mecha10-cli 0.9.0

Mecha10 CLI tool
Documentation
//! Plain process spawning primitives for `ProcessService`
//!
//! Covers spawning with inherited/piped output, custom env, and a specific
//! working directory. Higher-level node spawning (via `mecha10-node-runner`
//! or standalone mode) lives in [`super::spawn_node`].

use super::ProcessService;
use crate::paths;
use anyhow::{Context, Result};
use mecha10_cli_core::utils::strip_node_scope;
use std::collections::HashMap;
use std::path::Path;
use std::process::{Command, Stdio};

impl ProcessService {
    /// Spawn `binary_path` with `args`, wiring stdout/stderr to `stdio` and running it in
    /// `working_dir`, then track the resulting child under `name` and return its PID.
    fn spawn_tracked(
        &mut self,
        name: &str,
        binary_path: &str,
        args: &[&str],
        stdio: impl Fn() -> Stdio,
        working_dir: Option<&Path>,
    ) -> Result<u32> {
        let mut command = Command::new(binary_path);
        command.args(args).stdout(stdio()).stderr(stdio());
        if let Some(dir) = working_dir {
            command.current_dir(dir);
        }

        let child = command
            .spawn()
            .with_context(|| format!("Failed to spawn process: {}", binary_path))?;

        let pid = child.id();
        self.manager.track(name.to_string(), child);

        Ok(pid)
    }

    /// Spawn a node process, tracked under `name`. Returns an error if the process cannot be
    /// spawned.
    pub fn spawn_node(&mut self, name: &str, binary_path: &str, args: &[&str]) -> Result<u32> {
        self.spawn_tracked(name, binary_path, args, Stdio::inherit, None)
    }

    /// Spawn a process with output capture, tracked under `name`.
    pub fn spawn_with_output(&mut self, name: &str, binary_path: &str, args: &[&str]) -> Result<u32> {
        self.spawn_tracked(name, binary_path, args, Stdio::piped, None)
    }

    /// Spawn a process with custom environment variables, tracked under `name`.
    pub fn spawn_with_env(
        &mut self,
        name: &str,
        binary_path: &str,
        args: &[&str],
        env: HashMap<String, String>,
    ) -> Result<u32> {
        let disk_logging_enabled = self.disk_logging_enabled();

        tracing::debug!("🔧 spawn_with_env for '{}': received {} env vars", name, env.len());
        if !env.is_empty() {
            for (key, value) in &env {
                if key == "ROBOT_API_KEY" {
                    tracing::debug!("  {} = <redacted>", key);
                } else {
                    tracing::debug!("  {} = {}", key, value);
                }
            }
        } else {
            tracing::warn!("⚠️  No environment variables to inject!");
        }

        let mut cmd = Command::new(binary_path);
        cmd.args(args).envs(&env);

        cmd.env("MECHA10_DISK_LOGGING_ENABLED", disk_logging_enabled.to_string());

        if disk_logging_enabled {
            let logs_dir = std::path::PathBuf::from(paths::project::LOGS_DIR);
            if !logs_dir.exists() {
                std::fs::create_dir_all(&logs_dir)?;
            }

            let log_name = strip_node_scope(name);
            let log_file_path = logs_dir.join(format!("{}.log", log_name));
            let log_file = std::fs::OpenOptions::new()
                .create(true)
                .append(true)
                .open(&log_file_path)
                .with_context(|| format!("Failed to create log file: {}", log_file_path.display()))?;

            let log_file_stderr = log_file.try_clone().context("Failed to clone log file handle")?;

            cmd.stdout(log_file).stderr(log_file_stderr);
        } else {
            tracing::debug!(
                "📴 Disk logging disabled via project config; discarding stdout/stderr for '{}'",
                name
            );
            cmd.stdout(Stdio::null()).stderr(Stdio::null());
        }

        #[cfg(unix)]
        {
            use std::os::unix::process::CommandExt;
            cmd.process_group(0);
        }

        let child = cmd
            .spawn()
            .with_context(|| format!("Failed to spawn process: {}", binary_path))?;

        let pid = child.id();
        self.manager.track(name.to_string(), child);

        Ok(pid)
    }

    /// Spawn a process in a specific working directory, tracked under `name`.
    pub fn spawn_in_dir(
        &mut self,
        name: &str,
        binary_path: &str,
        args: &[&str],
        working_dir: impl AsRef<Path>,
    ) -> Result<u32> {
        self.spawn_tracked(name, binary_path, args, Stdio::inherit, Some(working_dir.as_ref()))
    }

    /// Spawn multiple node processes from a list of (name, binary_path, args) tuples, returning
    /// a HashMap of node names to PIDs.
    pub fn spawn_nodes(&mut self, nodes: Vec<(&str, &str, Vec<&str>)>) -> Result<HashMap<String, u32>> {
        let mut pids = HashMap::new();

        for (name, binary_path, args) in nodes {
            match self.spawn_node(name, binary_path, &args) {
                Ok(pid) => {
                    pids.insert(name.to_string(), pid);
                }
                Err(e) => {
                    eprintln!("Failed to spawn {}: {}", name, e);
                }
            }
        }

        Ok(pids)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn spawn_node_tracks_the_process_under_the_given_name() {
        let mut service = ProcessService::new();

        let pid = service.spawn_node("test_process", "sleep", &["1"]).unwrap();

        assert!(pid > 0);
        assert_eq!(service.count(), 1);

        service.cleanup();
    }

    #[test]
    fn spawn_with_output_tracks_the_process_under_the_given_name() {
        let mut service = ProcessService::new();

        let pid = service.spawn_with_output("test_output", "sleep", &["1"]).unwrap();

        assert!(pid > 0);
        assert_eq!(service.count(), 1);

        service.cleanup();
    }

    #[test]
    fn spawn_in_dir_tracks_the_process_and_runs_it_in_the_given_directory() {
        let mut service = ProcessService::new();

        let pid = service.spawn_in_dir("test_dir", "sleep", &["1"], "/tmp").unwrap();

        assert!(pid > 0);
        assert_eq!(service.count(), 1);

        service.cleanup();
    }

    #[test]
    fn spawn_node_and_spawn_with_output_use_different_stdio_modes() {
        let mut inherited = ProcessService::new();
        let inherited_pid = inherited
            .spawn_node("echo_inherited", "sh", &["-c", "echo hi"])
            .unwrap();
        assert!(inherited_pid > 0);
        inherited.cleanup();

        let mut piped = ProcessService::new();
        let piped_pid = piped.spawn_with_output("echo_piped", "sh", &["-c", "echo hi"]).unwrap();
        assert!(piped_pid > 0);
        piped.cleanup();
    }

    #[test]
    fn spawn_nodes_returns_a_pid_per_successfully_spawned_node() {
        let mut service = ProcessService::new();

        let pids = service
            .spawn_nodes(vec![("node1", "sleep", vec!["1"]), ("node2", "sleep", vec!["1"])])
            .unwrap();

        assert_eq!(pids.len(), 2);
        assert!(pids.contains_key("node1"));
        assert!(pids.contains_key("node2"));

        service.cleanup();
    }
}