reeve-cli 0.1.0

Localhost web dev stack manager: web servers, per-vhost PHP versions, SSL, and DNS — RunCloud, scaled down.
//! PHP version management. Each version runs its own php-fpm master listening
//! on a dedicated unix socket, so vhosts can target different versions
//! simultaneously (the core capability mod_php cannot provide).

pub mod extensions;

use crate::brew::Brew;
use crate::daemon::{self, ServiceSpec};
use crate::paths;
use crate::state::PhpVersion;
use anyhow::{bail, Context, Result};
use std::path::PathBuf;
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};

/// Brew formula name for a PHP version, e.g. "8.3" -> "[email protected]".
pub fn formula(version: &str) -> String {
    format!("php@{version}")
}

/// Compact form, e.g. "8.3" -> "83".
fn compact(version: &str) -> String {
    version.chars().filter(|c| c.is_ascii_digit()).collect()
}

/// FPM socket path for a version, e.g. "8.3" -> `<run>/php83.sock`.
pub fn fpm_socket(version: &str) -> Result<PathBuf> {
    Ok(paths::run_dir()?.join(format!("php{}.sock", compact(version))))
}

/// launchd service id for a version's fpm master, e.g. "php-83".
pub fn service_id(version: &str) -> String {
    format!("php-{}", compact(version))
}

/// Absolute php-fpm binary for a version (shivammathur layout).
fn fpm_binary(brew: &Brew, version: &str) -> PathBuf {
    brew.opt(&formula(version)).join("sbin/php-fpm")
}

/// php.ini directory for a version, e.g. `<prefix>/etc/php/8.3`.
fn ini_dir(brew: &Brew, version: &str) -> PathBuf {
    brew.etc("php").join(version)
}

fn current_user() -> String {
    std::env::var("USER")
        .ok()
        .filter(|u| !u.is_empty())
        .unwrap_or_else(|| {
            Command::new("id")
                .arg("-un")
                .output()
                .ok()
                .and_then(|o| String::from_utf8(o.stdout).ok())
                .map(|s| s.trim().to_string())
                .unwrap_or_else(|| "_www".to_string())
        })
}

/// Render the self-contained FPM config (global + one pool) for a version into
/// `generated/fpm/phpXY.conf`, returning its path. reeve owns this file;
/// it never touches Homebrew's default php-fpm.conf.
pub fn render_fpm_conf(version: &str) -> Result<PathBuf> {
    paths::ensure_dirs()?;
    let c = compact(version);
    let socket = fpm_socket(version)?;
    let user = current_user();
    let run = paths::run_dir()?;
    let logs = paths::logs_dir()?;

    let conf = format!(
        r#"; Generated by reeve — do not edit by hand.
; PHP {version} FPM master.
[global]
pid = {run}/php{c}-fpm.pid
error_log = {logs}/php{c}-fpm.log
daemonize = no

[www]
user = {user}
group = staff
listen = {socket}
listen.owner = {user}
listen.group = staff
listen.mode = 0660
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 4
catch_workers_output = yes
clear_env = no
php_admin_value[error_log] = {logs}/php{c}-php.log
php_admin_flag[log_errors] = on
"#,
        version = version,
        c = c,
        run = run.display(),
        logs = logs.display(),
        user = user,
        socket = socket.display(),
    );

    let path = paths::generated_dir()?
        .join("fpm")
        .join(format!("php{c}.conf"));
    std::fs::write(&path, conf)
        .with_context(|| format!("Failed to write FPM config {}", path.display()))?;
    Ok(path)
}

/// Stand up (or restart) the launchd-managed FPM master for a version.
pub fn ensure_fpm_running(brew: &Brew, version: &str) -> Result<()> {
    let conf = render_fpm_conf(version)?;
    let bin = fpm_binary(brew, version);
    if !bin.exists() {
        bail!(
            "php-fpm binary not found at {} — is {} installed?",
            bin.display(),
            formula(version)
        );
    }
    let spec = ServiceSpec {
        service: service_id(version),
        program: bin,
        args: vec![
            "--nodaemonize".into(),
            "--fpm-config".into(),
            conf.display().to_string(),
            "-c".into(),
            ini_dir(brew, version).display().to_string(),
        ],
        log: paths::logs_dir()?.join(format!("php{}-launchd.log", compact(version))),
        keep_alive: true,
        run_at_load: true,
    };
    daemon::install(&spec)?;
    daemon::restart(&service_id(version))?;

    // Health check: wait for the socket to appear.
    let socket = fpm_socket(version)?;
    let deadline = Instant::now() + Duration::from_secs(8);
    while Instant::now() < deadline {
        if daemon::socket_alive(&socket) {
            return Ok(());
        }
        thread::sleep(Duration::from_millis(150));
    }
    bail!(
        "FPM master for PHP {version} started but socket {} never appeared. \
         Check {}.",
        socket.display(),
        paths::logs_dir()?
            .join(format!("php{}-launchd.log", compact(version)))
            .display()
    )
}

/// Is this PHP version's brew formula installed?
pub fn is_installed(brew: &Brew, version: &str) -> bool {
    brew.is_installed(&formula(version))
}

/// Full install: ensure the tap + formula, then stand up the FPM master and
/// return the [`PhpVersion`] record to persist. Reuses an existing brew install
/// rather than reinstalling.
pub fn install(brew: &Brew, version: &str) -> Result<PhpVersion> {
    brew.ensure_tap("shivammathur/php")?;
    // Bleeding-edge versions (e.g. 8.6+) live only in the tap, not Homebrew
    // core, and newer Homebrew refuses to load untrusted taps. Trust it so
    // tap-only versions install. (Core versions like 8.4/8.5 ignore this.)
    brew.trust_tap("shivammathur/php");
    if is_installed(brew, version) {
        println!("{} already installed — adopting it", formula(version));
    } else {
        println!(
            "Installing {} (this can take a few minutes)…",
            formula(version)
        );
        brew.install(&formula(version))?;
    }
    ensure_fpm_running(brew, version)?;
    Ok(PhpVersion {
        version: version.to_string(),
        fpm_socket: fpm_socket(version)?.display().to_string(),
        extensions: Vec::new(),
    })
}

/// Scan `<prefix>/opt/php@*` for already-installed versions.
pub fn discover(brew: &Brew) -> Vec<String> {
    let opt = brew.prefix.join("opt");
    let mut found = Vec::new();
    if let Ok(entries) = std::fs::read_dir(&opt) {
        for e in entries.flatten() {
            if let Some(name) = e.file_name().to_str() {
                if let Some(ver) = name.strip_prefix("php@") {
                    found.push(ver.to_string());
                }
            }
        }
    }
    found.sort();
    found
}

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

    #[test]
    fn naming_conventions() {
        assert_eq!(formula("8.3"), "php@8.3");
        assert_eq!(compact("8.3"), "83");
        assert_eq!(service_id("8.4"), "php-84");
        assert!(fpm_socket("8.3").unwrap().ends_with("php83.sock"));
    }
}