prox 0.1.1

Rusty development process manager like foreman, but better!
Documentation
#![allow(unused)]

use anyhow::Context;
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use prox::{Prox, ProxEvent, ProxSignal, error};
use std::{path::PathBuf, sync::mpsc, thread::spawn, time::Duration};

#[derive(Parser)]
#[command(version, about = "A process manager for development environments", long_about = None)]
struct ProxCli {
    #[command(subcommand)]
    command: Option<Commands>,

    /// Path to the configuration file (default: ./prox.toml or .cargo/prox.toml)
    #[arg(short, long, global = true)]
    config: Option<PathBuf>,
}

#[derive(Subcommand, Default)]
enum Commands {
    #[default]
    /// Start all processes (default command)
    Start,
    /// Initialize a new configuration file
    Init {
        /// Output format for the configuration file
        #[arg(short, long, value_enum, default_value = "toml")]
        format: ConfigFormat,
        /// Path to create the configuration file (default: prox.{format})
        path: Option<PathBuf>,
    },
    /// Check configuration file for errors
    Check {
        /// Path to the configuration file to check
        path: Option<PathBuf>,
    },
    /// Show current configuration
    Show {
        /// Path to the configuration file to show
        path: Option<PathBuf>,
    },
}

#[derive(Clone, ValueEnum)]
enum ConfigFormat {
    Toml,
    Yaml,
    Json,
}

fn main() -> anyhow::Result<()> {
    let args = ProxCli::parse();

    match args.command.unwrap_or_default() {
        Commands::Start => {
            let config_path = find_config_file(args.config)?;
            let mut prox = load_config(&config_path)?;
            let (signal_tx, signal_rx) = mpsc::channel::<ProxSignal>();
            prox.signal_rx = Some(signal_rx);
            prox.config.handle_control_c = false;

            let event_rx = prox.setup_event_rx()?;
            let status_refs = prox.status_refs.clone();

            spawn(move || {
                for event in event_rx {
                    println!("EVENT: {event:?}");

                    match event {
                        ProxEvent::Idle => {
                            println!("Statuses: {status_refs:?}");
                            println!("Key controls: 's' = Start, 'r' = Restart, 'q' = Shutdown");
                        }
                        _ => {}
                    }
                }
            });

            let tx_clone = signal_tx.clone();
            ctrlc::set_handler(move || {
                tx_clone.send(ProxSignal::Shutdown).ok();
            })
            .context("set Control-C handler")?;

            spawn(move || {
                use std::io::{self, Read};
                use termios::{ECHO, ICANON, TCSANOW, Termios, tcgetattr, tcsetattr};

                // Save original terminal settings
                let stdin_fd = 0;
                let mut orig_termios =
                    Termios::from_fd(stdin_fd).expect("Failed to get terminal attributes");
                let mut new_termios = orig_termios.clone();

                // Disable canonical mode and echo
                new_termios.c_lflag &= !(ICANON | ECHO);
                new_termios.c_cc[termios::VMIN] = 1;
                new_termios.c_cc[termios::VTIME] = 0;

                // Apply new settings
                if tcsetattr(stdin_fd, TCSANOW, &new_termios).is_err() {
                    eprintln!("Failed to set terminal attributes");
                    return;
                }

                // Restore original settings on exit
                let _cleanup = scopeguard::guard((), move |_| {
                    let _ = tcsetattr(stdin_fd, TCSANOW, &orig_termios);
                });

                let mut stdin = io::stdin();
                let mut buffer = [0u8; 1];

                loop {
                    match stdin.read(&mut buffer) {
                        Ok(1) => {
                            let ch = buffer[0] as char;
                            let signal = ProxSignal::try_from(ch).ok();

                            if let Some(sig) = signal {
                                // println!("Key pressed: {:?}", sig);
                                if signal_tx.send(sig).is_err() {
                                    error!("Failed to send signal, exiting key listener");
                                    break;
                                }
                            }
                        }
                        Ok(_) => continue,
                        Err(_) => break,
                    }
                }
            });

            println!("Starting prox from: {}", config_path.display());
            prox.start()?;
        }
        Commands::Init { format, path } => {
            init_config(format, path)?;
        }
        Commands::Check { path } => {
            let config_path = find_config_file(path)?;
            check_config(&config_path)?;
        }
        Commands::Show { path } => {
            let config_path = find_config_file(path)?;
            show_config(&config_path)?;
        }
    }

    Ok(())
}

// Load configuration from file, detecting format by extension
fn load_config(path: &PathBuf) -> anyhow::Result<Prox> {
    match path.extension().and_then(|ext| ext.to_str()) {
        Some("toml") => Prox::load_toml(path),
        Some("yaml") | Some("yml") => Prox::load_yaml(path),
        Some("json") => Prox::load_json(path),
        _ => anyhow::bail!("Unsupported config file format. Use .toml, .yaml/.yml, or .json"),
    }
}

// Initialize a new configuration file
fn init_config(format: ConfigFormat, path: Option<PathBuf>) -> anyhow::Result<()> {
    let file_path = path.unwrap_or_else(|| {
        PathBuf::from(match format {
            ConfigFormat::Toml => "prox.toml",
            ConfigFormat::Yaml => "prox.yaml",
            ConfigFormat::Json => "prox.json",
        })
    });

    if file_path.exists() {
        anyhow::bail!("Configuration file already exists: {}", file_path.display());
    }

    let template_content = match format {
        ConfigFormat::Toml => include_str!("../templates/prox.toml"),
        ConfigFormat::Yaml => include_str!("../templates/prox.yaml"),
        ConfigFormat::Json => include_str!("../templates/prox.json"),
    };

    std::fs::write(&file_path, template_content)
        .context(format!("write template file to: {}", file_path.display()))?;
    println!("Created configuration file: {}", file_path.display());
    Ok(())
}

// Check configuration file for errors
fn check_config(path: &PathBuf) -> anyhow::Result<()> {
    println!("Checking configuration file: {}", path.display());

    let prox = load_config(path)?;

    println!("✓ Configuration file is valid");
    println!("✓ Found {} process(es)", prox.procs().len());

    for proc in prox.procs() {
        println!(
            "  - {}: {} {}",
            proc.name,
            proc.command.to_string_lossy(),
            proc.args
                .iter()
                .map(|a| a.to_string_lossy())
                .collect::<Vec<_>>()
                .join(" ")
        );
    }

    Ok(())
}

// Show current configuration
fn show_config(path: &PathBuf) -> anyhow::Result<()> {
    println!("Configuration from: {}", path.display());
    println!();

    let prox = load_config(path)?;
    println!("{prox:#?}");

    Ok(())
}

// Logic to find the config file in common locations
fn find_config_file(explicit_path: Option<PathBuf>) -> anyhow::Result<PathBuf> {
    // If a path is provided, use it.
    if let Some(path) = explicit_path {
        if path.exists() {
            return Ok(path);
        } else {
            anyhow::bail!("Config file not found: {path:?}");
        }
    }

    // Start from current directory and walk up ancestors
    let mut current_dir = std::env::current_dir().context("get current directory")?;

    loop {
        let mut found_configs = Vec::new();

        // Check for config files in current directory
        let config_names = ["prox.toml", "prox.yaml", "prox.yml", "prox.json"];
        for name in &config_names {
            let path = current_dir.join(name);
            if path.exists() {
                found_configs.push(path);
            }
        }

        match found_configs.len() {
            0 => {
                // No config found, try parent directory
                if let Some(parent) = current_dir.parent() {
                    current_dir = parent.to_path_buf();
                    continue;
                } else {
                    eprintln!(
                        "\nNo prox.(toml|json|yaml) config file found. Please create one with `init`, or use `--config`.\n"
                    );
                    ProxCli::command().print_help().ok();
                    std::process::exit(1);
                }
            }
            1 => {
                // Exactly one config found
                return Ok(found_configs.into_iter().next().unwrap());
            }
            _ => {
                // Multiple configs found - ambiguous
                let config_list = found_configs
                    .iter()
                    .map(|p| p.file_name().unwrap().to_string_lossy())
                    .collect::<Vec<_>>()
                    .join(", ");
                anyhow::bail!(
                    "Ambiguous config files found in {}: {config_list}. Please use --config to specify which one to use.",
                    current_dir.display()
                );
            }
        }
    }
}