prox 0.1.1

Rusty development process manager like foreman, but better!
Documentation
use crate::{Config, Proc, info, logging};
use anyhow::{Context, Result};
use std::{
    env,
    ffi::{OsStr, OsString},
    io::{BufRead, BufReader, Read},
    os::unix::prelude::CommandExt,
    path::PathBuf,
    process::{Child, Command, Stdio},
    sync::{Mutex, mpsc},
    thread::spawn,
};

impl Proc {
    /// Resolve the `watch` and `watch_rel` paths to absolute canonical paths.
    pub fn watch_abs(&self, config: &Config) -> Result<Vec<PathBuf>> {
        let current_dir = &env::current_dir().context("current directory")?;
        let working_dir = self
            .working_dir
            .as_ref()
            .map(|d| current_dir.join(d))
            .unwrap_or_else(|| current_dir.clone());
        let working_dir = working_dir.canonicalize().context(format!(
            "failed to canonicalize working directory: {}",
            working_dir.display()
        ))?;
        let mut all_paths = config
            .watch
            .iter()
            .map(|path| {
                if path.is_absolute() {
                    path.clone()
                } else {
                    current_dir.join(path)
                }
            })
            .collect::<Vec<_>>();

        #[allow(unknown_lints, nesting_too_deep)]
        for (paths, rel) in [(&self.watch, false), (&self.watch_rel, true)] {
            for path in paths {
                let full_path = if rel {
                    &working_dir.join(path)
                } else if path.is_absolute() {
                    path
                } else {
                    &current_dir.join(path)
                };

                let canonical_path = full_path.canonicalize().context(format!(
                    "failed to canonicalize path: {}",
                    full_path.display()
                ))?;

                all_paths.push(canonical_path);
            }
        }
        Ok(all_paths)
    }

    fn spawn_reader<R: Read + Send + 'static>(
        &self,
        reader: R,
        readiness_tx: mpsc::Sender<()>,
        stream_name: &'static str,
        _config: &Config,
        proc_output_tx: Option<mpsc::Sender<()>>,
    ) {
        let readiness_pattern = self.readiness_pattern.clone();
        let proc_name = self.name.clone();
        let proc_color = self.color;

        spawn(move || {
            let buf_reader = BufReader::new(reader);
            let mut readiness_signaled = false;

            for line in buf_reader.lines() {
                match line {
                    Err(err) => {
                        logging::log_proc_error(&proc_name, &format!("{err}"), proc_color);
                        break;
                    }
                    Ok(l) => {
                        logging::log_proc(&proc_name, &l, proc_color);

                        // Signal output activity for idle detection
                        if let Some(ref tx) = proc_output_tx {
                            tx.send(()).ok();
                        }

                        if !readiness_signaled
                            && let Some(ref pattern) = readiness_pattern
                            && l.to_ascii_lowercase()
                                .contains(&pattern.to_ascii_lowercase())
                        {
                            logging::log_proc(
                                &proc_name,
                                &format!(
                                    "[{stream_name}] Readiness pattern '{pattern}' detected in: {l}"
                                ),
                                proc_color,
                            );
                            readiness_tx.send(()).ok();
                            readiness_signaled = true;
                        }
                    }
                }
            }
        });
    }

    pub(crate) fn start_service(
        &self,
        process_group_id: &Mutex<i32>,
        config: &Config,
        proc_output_tx: Option<mpsc::Sender<()>>,
    ) -> Result<(Child, mpsc::Receiver<()>), String> {
        let working_dir = self
            .working_dir
            .clone()
            .unwrap_or_else(|| env::current_dir().expect("Failed to get current directory"));

        logging::log_proc(
            &self.name,
            &format!(
                "Running: {} {} (in {})",
                self.command.display(),
                args_display(&self.args),
                working_dir.display()
            ),
            self.color,
        );

        if self.cleanup_before_start {
            self.cleanup();
        }

        let mut command = Command::new(&self.command);
        if self.env_clear {
            command.env_clear();
        }
        command
            .current_dir(working_dir)
            .args(&self.args)
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());

        // Add environment variables
        for (key, value) in &self.env {
            command.env(key, value);
        }

        // Set process group
        let mut gid = process_group_id
            .lock()
            .expect("failed to lock process group ID");

        // Only set process group if we have a valid one, otherwise let the OS create a new one
        if *gid > 0 {
            // Check if the process group still exists before trying to use it
            let check_result = std::process::Command::new("kill")
                .args(["-0", &format!("-{}", *gid)])
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .status();

            match check_result {
                Ok(status) if status.success() => {
                    // Process group exists, safe to use
                    command.process_group(*gid);
                }
                _ => {
                    // Process group doesn't exist or error checking, reset it
                    *gid = 0;
                }
            }
        }

        let mut child = command
            .spawn()
            .map_err(|e| format!("Failed to start service {}: {e}", self.name))?;

        if *gid == 0 {
            *gid = child.id() as i32;
            info!("Created process group: {}", *gid);
        }

        let (readiness_tx, readiness_rx) = mpsc::channel();

        if let Some(stdout) = child.stdout.take() {
            self.spawn_reader(
                stdout,
                readiness_tx.clone(),
                "stdout",
                config,
                proc_output_tx.clone(),
            );
        }
        if let Some(stderr) = child.stderr.take() {
            self.spawn_reader(
                stderr,
                readiness_tx.clone(),
                "stderr",
                config,
                proc_output_tx.clone(),
            );
        }

        Ok((child, readiness_rx))
    }

    pub(crate) fn cleanup(&self) {
        if let Some(args) = &self.cleanup_cmd
            && !args.is_empty()
        {
            logging::log_proc(
                &self.name,
                &format!("Running cleanup: {}", args_display(&args)),
                self.color,
            );
            Command::new(&args[0])
                .args(&args[1..])
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .status()
                .ok();
        }
    }
}

fn args_display(args: &[OsString]) -> String {
    args.join(OsStr::new(" ")).to_string_lossy().to_string()
}