prox 0.1.1

Rusty development process manager like foreman, but better!
Documentation
//! Rusty development process manager like foreman, but better!
//!
//! **WIP:** This project is a work-in-progress but it's basically working. **The API is subject to change.** I plan to add improvements as I use it/as needed, including a possible TUI interface.
//!
//! # Motivation
//!
//! I wanted something to run multiple processes in my project like Ruby's `foreman` (back in the day) but
//! without the drawbacks of:
//! - the complexity/sluggishness of wrapping every binary in a Docker container and maintaining a `docker-compose.yaml`
//! - `cargo bacon` has tedious config and is meant for one-off `cargo check`/`cargo test`, not running multiple procs in parallel like `docker` or `foreman`
//! - `cargo watch` is abandonware (why not transfer the name to someone else?)
//! - `just` is great, but requires manual `watchexec`, etc.
//! - something like `foreman` but a bit smarter for restarts/crash handling, almost like `docker`
//! - shell scripts to combine logs and manage the procs + `awk` + control-C are a pain!
//!
//! # Installation
//!
//! `cargo install prox`
//!
//! (Or use as a library in your dev-dependencies and create your own wrapper -- see `examples/basic_usage.rs`)
//!
//! # Usage
//!
//! ## Binary Usage:
//!
//! Create a prox.toml (or .yaml or .json) file in your workspace/crate root:
//!
//! ```toml
//! [config]
//! readiness_fallback_timeout = 15
//!
//! [[procs]]
//! name = "api"
//! command = "cargo"
//! args = ["run", "--bin", "api"]
//! working_dir = "api"
//! readiness_pattern = "Server listening"
//! # In a workspace, you can watch from the workspace root:
//! watch = ["Cargo.toml"]
//! # ... and/or relative to `working_dir`:
//! watch_rel = ["src"]
//! env_clear = true
//! env = { PORT = "3000", RUST_LOG = "debug" }
//!
//! [[procs]]
//! name = "worker"
//! command = "cargo"
//! args = ["run", "--bin", "worker"]
//! working_dir = "tests/fixtures"
//! env_clear = true
//! ```
//!
//! Then just run `prox`!
//!
//! ## Library Usage:
//!
//! Add `prox` to your `[dev-dependencies]` and create your own bin wrapper, e.g. `dev`.
//! See `examples/basic_usage.rs` for an example.
//!
//! # Features:
//! - Run several commands in parallel
//! - Colored prefixes per process
//! - File watching with automatic restart (global + per-proc watch paths)
//! - Readiness pattern or fallback timeout
//! - Debounced output idle detection
//! - Clean shutdown & optional cleanup commands
//! - Config via Rust builders or TOML / YAML / JSON files
//!
//! Events you can receive (via `setup_event_rx()`):
//! - `AllStarted` when every proc is ready
//! - `Started`, `Restarted` per process
//! - `Exited`, `StartFailed` on failure
//! - `Idle` after no output for the debounce period
//! - `SigIntReceived` on Ctrl-C (if enabled)
//!
//! Runtime control (optional): send `ProxSignal::Start`, `Restart`, `Shutdown` through the `signal_rx` channel you provide.
//!
//! Environment:
//! - Per process vars: `env` (clears first if `env_clear = true`).
//!
//! Colors:
//! - Auto-assigned from `config.colors` unless a proc sets `color`.
//!
//! Readiness:
//! - If `readiness_pattern` set, logs are scanned case-insensitively.
//! - Otherwise, after `readiness_fallback_timeout` the proc is assumed running.
//!
//! **IMPORTANT!** Not a production supervisor! For local development only.
//!
//! # Example:
//! examples/basic_usage.rs
//! ```rs
#![doc = include_str!("../examples/basic_usage.rs")]
//! ```
//!

#![warn(missing_docs)]

pub(crate) mod colors;
/// Logging utilities and macros
pub mod logging;
pub(crate) mod proc;
pub(crate) mod prox;

pub use colors::{ALL, BRIGHT, NORMAL};

use colors::color_converter::ColorAsString;
use dashmap::DashMap;
use owo_colors::AnsiColors;
use serde::{Deserialize, Serialize};
use serde_inline_default::serde_inline_default;
use serde_with::{DisplayFromStr, DurationSeconds, serde_as};
use std::{
    collections::HashMap,
    ffi::OsString,
    path::PathBuf,
    process::{Child, ExitStatus},
    sync::{Arc, Mutex, atomic::AtomicBool, mpsc},
    time::{Duration, Instant},
};
use typed_builder::TypedBuilder;

const DEFAULT_READINESS_TIMEOUT: Duration = Duration::from_secs(5);
const DEFAULT_PREFIX_WIDTH: usize = 8;
const DEFAULT_COLORS: &[AnsiColors] = BRIGHT;
const DEFAULT_OUTPUT_IDLE_DEBOUNCE_MS: u64 = 3000;

/// Configuration for the process manager
#[serde_as]
#[serde_inline_default]
#[derive(Debug, Clone, TypedBuilder, serde_derive_default::Default, Serialize, Deserialize)]
pub struct Config {
    /// Whether to continue running/watching on a process exit
    #[serde_inline_default(false)]
    #[builder(default = false)]
    pub keep_going: bool,

    /// Global watch paths
    #[serde(default)]
    #[builder(default)]
    pub watch: Vec<PathBuf>,

    /// Timeout for process readiness if a readiness pattern is not specified
    #[serde_as(as = "DurationSeconds")]
    #[serde_inline_default(DEFAULT_READINESS_TIMEOUT)]
    #[builder(default = DEFAULT_READINESS_TIMEOUT)]
    pub readiness_fallback_timeout: Duration,

    /// Width of the process prefix in log output
    #[serde_inline_default(DEFAULT_PREFIX_WIDTH)]
    #[builder(default = DEFAULT_PREFIX_WIDTH)]
    pub prefix_width: usize,

    /// Colors to use for process output prefixes
    #[serde_as(as = "Vec<ColorAsString>")]
    #[serde_inline_default(DEFAULT_COLORS.to_vec())]
    #[builder(default = DEFAULT_COLORS.to_vec())]
    pub colors: Vec<AnsiColors>,

    /// Whether to show timestamps in prox logs (not process logs)
    #[serde_inline_default(false)]
    #[builder(default = false)]
    pub show_timestamps: bool,

    /// Color for prox system logs
    #[serde_as(as = "ColorAsString")]
    #[serde_inline_default(AnsiColors::BrightWhite)]
    #[builder(default = AnsiColors::BrightWhite)]
    pub prox_color: AnsiColors,

    /// Whether to hook and handle Ctrl-C internally (SIGINT)
    /// (default: true for lib. Ignored for bin.)
    #[serde_inline_default(true)]
    #[builder(default = true)]
    pub handle_control_c: bool,

    /// Debounce time in milliseconds after which output is considered idle
    #[serde_inline_default(DEFAULT_OUTPUT_IDLE_DEBOUNCE_MS)]
    #[builder(default = DEFAULT_OUTPUT_IDLE_DEBOUNCE_MS)]
    pub output_idle_debounce_ms: u64,
}

/// Status of a managed process
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProcStatus {
    /// Process is starting up
    Starting,
    /// Process is running
    Running,
    /// Process has exited
    Exited(ExitStatus),
    /// Process failed to start
    Error(String),
}

/// Signals that can be sent to the process manager (`Prox`)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProxSignal {
    /// Start any stopped processes
    Start,
    /// Restart all running processes
    Restart,
    /// Shutdown all processes and exit
    Shutdown,
}

impl TryFrom<char> for ProxSignal {
    type Error = &'static str;

    fn try_from(value: char) -> Result<Self, Self::Error> {
        match value {
            'r' => Ok(ProxSignal::Start),
            'R' => Ok(ProxSignal::Restart),
            'q' | 'Q' => Ok(ProxSignal::Shutdown),
            _ => Err("Invalid signal character"),
        }
    }
}

/// Events emitted by the process manager (`Prox`)
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxEvent {
    /// No output from any process for the configured debounce period
    Idle,
    /// All processes have started and are ready
    AllStarted,
    /// One or more processes have exited and `keep_going` is false
    SomeFailed {
        /// Names of the processes that have exited
        exited_procs: Vec<String>,
    },
    /// A process has started
    Started {
        /// Name of the process that started
        proc_name: String,
    },
    /// A process has been restarted
    Restarted {
        /// Name of the process that restarted
        proc_name: String,
    },
    /// A process failed to start
    StartFailed {
        /// Name of the process that failed to start
        proc_name: String,
        /// Error message
        message: String,
    },
    /// A process has exited
    Exited {
        /// Name of the process that exited
        proc_name: String,
        /// Exit status of the process
        status: ExitStatus,
    },
    /// SIGINT (Ctrl-C) received
    SigIntReceived,
}

/// Process manager that handles multiple processes
#[serde_as]
#[derive(Debug, TypedBuilder, serde_derive_default::Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Prox {
    /// Configuration for the process manager
    #[builder(default)]
    pub config: Config,

    /// Processes to manage
    pub procs: Vec<Proc>,

    /// Channel to receive control signals (start, restart, shutdown)
    #[serde(skip)]
    #[builder(default, setter(strip_option))]
    pub signal_rx: Option<mpsc::Receiver<ProxSignal>>,

    /// Internal references to process statuses
    #[serde(skip)]
    #[builder(default, setter(skip))]
    pub status_refs: Arc<DashMap<String, ProcStatus>>,

    #[serde(skip)]
    #[builder(default, setter(skip))]
    child_refs: Arc<DashMap<String, Child>>,
    #[serde(skip)]
    #[builder(default, setter(skip))]
    starting: Arc<DashMap<String, Instant>>,
    #[serde(skip)]
    #[builder(default, setter(skip))]
    running: Arc<AtomicBool>,
    #[serde(skip)]
    #[builder(default, setter(skip))]
    process_group_id: Arc<Mutex<i32>>,
    #[serde(skip)]
    #[builder(default, setter(skip))]
    event_tx: Option<mpsc::Sender<ProxEvent>>,
}

/// A single process configuration for prox
#[serde_as]
#[serde_inline_default]
#[derive(Debug, Clone, Deserialize, TypedBuilder)]
#[serde(deny_unknown_fields)]
pub struct Proc {
    /// Name of the process (used in logs and events)
    pub name: String,
    #[serde_as(as = "DisplayFromStr")]

    /// Command to run (e.g. "cargo", "npm", "python", etc.)
    pub command: OsString,

    /// Arguments to pass to the command
    #[serde_as(as = "Vec<DisplayFromStr>")]
    #[serde(default)]
    #[builder(default)]
    pub args: Vec<OsString>,

    /// Working directory for the process (defaults to current directory)
    #[builder(default, setter(strip_option))]
    pub working_dir: Option<PathBuf>,

    /// Optional pattern to look for in stdout/stderr to consider the process "ready"
    #[builder(default, setter(strip_option))]
    pub readiness_pattern: Option<String>,

    /// Paths to watch for changes to trigger a restart (relative to the config file/crate/workspace root)
    #[serde(default)]
    #[builder(default)]
    pub watch: Vec<PathBuf>,

    /// Paths to watch for changes to trigger a restart (relative to working_dir)
    #[serde(default)]
    #[builder(default)]
    pub watch_rel: Vec<PathBuf>,

    /// Environment variables to set for the process
    #[serde_as(as = "HashMap<DisplayFromStr, DisplayFromStr>")]
    #[serde(default)]
    #[builder(default)]
    pub env: HashMap<OsString, OsString>,

    /// Whether to clear the environment before applying `env`
    #[serde(default)]
    #[builder(default)]
    pub env_clear: bool,

    /// Override color for the process prefix (otherwise auto-assigned from [`Config`])
    #[serde_as(as = "Option<ColorAsString>")]
    #[builder(default, setter(strip_option))]
    pub color: Option<AnsiColors>,

    /// Command to run on shutdown of [`Prox`], and optionally before start/restart of process
    #[serde_as(as = "Option<Vec<DisplayFromStr>>")]
    #[builder(default, setter(strip_option))]
    pub cleanup_cmd: Option<Vec<OsString>>,

    /// Whether to run the `cleanup_cmd` before starting/restarting the process
    #[serde(default)]
    #[builder(default)]
    pub cleanup_before_start: bool,
}

#[cfg(test)]
mod tests {
    use super::{Proc, Prox};

    #[test]
    fn test_builder() {
        Prox::builder()
            .procs(vec![
                Proc::builder()
                    .name("name".to_string())
                    .command("cargo".into())
                    .build(),
            ])
            .build();
    }
}