zacor 0.1.0

Package manager and dispatcher for zr — install, manage, and run modular CLI packages
Documentation
use crate::error::*;
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
use std::path::Path;

const DAEMON_PORT: u16 = 19100;

#[derive(Debug, Serialize)]
struct DaemonRequest {
    request: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    name: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct DaemonResponse {
    pub ok: bool,
    #[serde(default)]
    pub error: Option<String>,
    #[serde(default)]
    pub port: Option<u16>,
    #[serde(default)]
    pub services: Option<Vec<ServiceStatus>>,
}

#[derive(Debug, Deserialize)]
pub struct ServiceStatus {
    pub name: String,
    pub port: u16,
    pub status: String,
}

fn daemon_addr() -> String {
    #[cfg(unix)]
    {
        // On Unix we use a Unix domain socket, but for cross-platform simplicity
        // we fall back to TCP for now (same as Windows)
        format!("127.0.0.1:{}", DAEMON_PORT)
    }
    #[cfg(windows)]
    {
        format!("127.0.0.1:{}", DAEMON_PORT)
    }
}

fn send_request(stream: &TcpStream, req: &DaemonRequest) -> Result<DaemonResponse> {
    let mut stream = stream.try_clone().context("failed to clone daemon stream")?;
    let json = serde_json::to_string(req).context("failed to serialize daemon request")?;
    writeln!(stream, "{}", json).context("failed to write to daemon")?;
    stream.flush().context("failed to flush daemon stream")?;

    let mut reader = BufReader::new(stream);
    let mut line = String::new();
    reader
        .read_line(&mut line)
        .context("failed to read daemon response")?;
    serde_json::from_str(line.trim()).context("failed to parse daemon response")
}

/// Connect to the daemon. Returns None if the daemon is not running.
pub fn connect() -> Option<TcpStream> {
    TcpStream::connect(daemon_addr()).ok()
}

/// Connect to the daemon, starting it if needed.
pub fn connect_or_start_daemon(home: &Path) -> Result<TcpStream> {
    if let Some(stream) = connect() {
        return Ok(stream);
    }

    // Start daemon in background
    start_daemon(home)?;

    // Poll for daemon availability (50ms intervals, 5s timeout)
    let start = std::time::Instant::now();
    let timeout = std::time::Duration::from_secs(5);
    let interval = std::time::Duration::from_millis(50);

    loop {
        if let Some(stream) = connect() {
            return Ok(stream);
        }
        if start.elapsed() > timeout {
            bail!("failed to start daemon: timeout after 5 seconds");
        }
        std::thread::sleep(interval);
    }
}

/// Start the daemon process in the background.
fn start_daemon(home: &Path) -> Result<()> {
    let exe = std::env::current_exe().context("failed to determine current executable path")?;
    // The daemon is started via `zacor daemon start`
    // We use the same binary (zacor) with daemon start subcommand
    let zacor_bin = if exe.file_stem().is_some_and(|s| s.to_string_lossy().starts_with("zr")) {
        // We're running as `zr`, find the `zacor` binary alongside it
        let parent = exe.parent().unwrap_or(Path::new("."));
        let zacor_name = if cfg!(windows) { "zacor.exe" } else { "zacor" };
        parent.join(zacor_name)
    } else {
        exe
    };

    std::process::Command::new(&zacor_bin)
        .args(["daemon", "start"])
        .env("ZR_HOME", home)
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()
        .with_context(|| format!("failed to spawn daemon: {}", zacor_bin.display()))?;

    Ok(())
}

/// Send a ping to the daemon.
pub fn ping(stream: &TcpStream) -> Result<DaemonResponse> {
    send_request(
        stream,
        &DaemonRequest {
            request: "ping".into(),
            name: None,
        },
    )
}

/// Request the daemon to start a service.
pub fn start_service(stream: &TcpStream, name: &str) -> Result<DaemonResponse> {
    send_request(
        stream,
        &DaemonRequest {
            request: "start-service".into(),
            name: Some(name.into()),
        },
    )
}

/// Request the daemon to stop a service.
pub fn stop_service(stream: &TcpStream, name: &str) -> Result<DaemonResponse> {
    send_request(
        stream,
        &DaemonRequest {
            request: "stop-service".into(),
            name: Some(name.into()),
        },
    )
}

/// Request daemon status.
pub fn status(stream: &TcpStream) -> Result<DaemonResponse> {
    send_request(
        stream,
        &DaemonRequest {
            request: "status".into(),
            name: None,
        },
    )
}

/// Send shutdown to the daemon.
pub fn shutdown(stream: &TcpStream) -> Result<DaemonResponse> {
    send_request(
        stream,
        &DaemonRequest {
            request: "shutdown".into(),
            name: None,
        },
    )
}