car-proto 0.15.0

JSON-RPC protocol types for Common Agent Runtime client-server communication
Documentation
//! Mechanism helpers for the CAR daemon — URL parsing, probing,
//! and spawning the `car-server` binary. Hoisted out of
//! `car-cli` and `car-ffi-common::proxy` (#139-fu1) so both sides
//! share one implementation.
//!
//! These functions encode **mechanism only**, not policy:
//!
//! - "Where is the daemon?" — `daemon_ws_url`, `daemon_port`,
//!   `parse_host_port`.
//! - "Is something listening on its port?" — `probe_daemon_port`.
//! - "Try to start one." — `try_spawn_daemon`.
//!
//! Policy decisions ("should we probe? should we auto-spawn? what
//! happens on failure?") live in the callers — `car-cli` for
//! per-request retry policy, `car-ffi-common::proxy::RuntimeMode`
//! for FFI construction-time daemon-or-embedded resolution. Each
//! has a different shape; they share these primitives.

use std::path::PathBuf;
use std::time::Duration;

/// WebSocket URL the FFI proxy and CLI both target. Honors
/// `CAR_DAEMON_URL` for cross-host or alt-port setups; defaults
/// to the loopback singleton at port 9100 (matches `car-server`'s
/// default bind).
pub fn daemon_ws_url() -> String {
    std::env::var("CAR_DAEMON_URL").unwrap_or_else(|_| "ws://127.0.0.1:9100".to_string())
}

/// Port number for the daemon. IPv4 and IPv6 forms both accepted.
/// Returns 9100 when the URL is malformed or has no port.
pub fn daemon_port() -> u16 {
    parse_port(&daemon_ws_url()).unwrap_or(9100)
}

/// Extract `host:port` (or `[ipv6]:port`) from a `ws://...` /
/// `wss://...` / bare URL. Returns `None` for malformed inputs
/// and for missing-port URLs (the probe wouldn't know where to
/// look without one).
pub fn parse_host_port(url: &str) -> Option<String> {
    let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
    let host_port = after_scheme.split('/').next().unwrap_or("");
    if host_port.starts_with('[') {
        // IPv6 — must have `]:port` to be addressable.
        if host_port.contains("]:") {
            Some(host_port.to_string())
        } else {
            None
        }
    } else if host_port.contains(':') {
        Some(host_port.to_string())
    } else {
        None
    }
}

/// Pull just the trailing port from a URL. `None` when the URL
/// has no port.
pub fn parse_port(url: &str) -> Option<u16> {
    let host_port = parse_host_port(url)?;
    let port_str = if host_port.starts_with('[') {
        host_port.rsplit("]:").next()?
    } else {
        host_port.rsplit(':').next()?
    };
    port_str.parse().ok()
}

/// TCP-probe the daemon's host:port from `daemon_ws_url()`. True
/// iff a TCP connect completes within `timeout`. No WebSocket
/// handshake — just port reachability. Localhost connect to a
/// listening port resolves sub-millisecond on Darwin and Linux,
/// so callers can use a 100ms timeout for the "is it up?"
/// question without waiting on hung-daemon detection (a separate
/// failure mode handled by the WS-handshake timeout downstream).
pub fn probe_daemon_port(timeout: Duration) -> bool {
    let url = daemon_ws_url();
    let host_port = match parse_host_port(&url) {
        Some(hp) => hp,
        None => return false,
    };
    let addrs: Vec<_> = match std::net::ToSocketAddrs::to_socket_addrs(&host_port) {
        Ok(it) => it.collect(),
        Err(_) => return false,
    };
    addrs
        .into_iter()
        .any(|addr| std::net::TcpStream::connect_timeout(&addr, timeout).is_ok())
}

/// Best-effort `car-server` spawn. Returns `Ok(())` on successful
/// fork (the daemon may still take ~500ms-2s to bind its port; the
/// caller is expected to re-probe). Returns `Err` describing the
/// failure mode — `binary not found` for missing `car-server`,
/// `spawn failed: <io error>` when fork itself returned an error
/// (`EACCES`, `ENOMEM`, sandbox refusal, etc.). Callers today
/// only check `is_ok()`, but the structured error string is here
/// for when someone surfaces it to the user.
///
/// Lookup order: sibling-of-current-exe (so `target/release/car`
/// finds its sibling `car-server` without needing PATH) → bare
/// `car-server` (PATH lookup).
pub fn try_spawn_daemon() -> Result<(), String> {
    let mut candidates: Vec<PathBuf> = Vec::new();
    if let Ok(exe) = std::env::current_exe() {
        if let Some(dir) = exe.parent() {
            candidates.push(dir.join("car-server"));
        }
    }
    candidates.push(PathBuf::from("car-server"));

    let port = daemon_port().to_string();
    let mut last_err: Option<std::io::Error> = None;
    for candidate in candidates {
        let mut cmd = std::process::Command::new(&candidate);
        cmd.args(["--port", &port])
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null());
        match cmd.spawn() {
            Ok(_) => return Ok(()),
            Err(e) => last_err = Some(e),
        }
    }

    // `candidates` is never empty (sibling-of-current-exe or PATH
    // lookup always pushes at least the bare "car-server" path),
    // so the loop above touched `last_err` at least once. The
    // `None` arm here is defensive; the optimizer drops it.
    Err(match last_err {
        Some(e) if e.kind() == std::io::ErrorKind::NotFound => {
            "car-server binary not found".to_string()
        }
        Some(e) => format!("car-server spawn failed: {e}"),
        None => unreachable!("candidates is never empty"),
    })
}

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

    #[test]
    fn parse_host_port_basic() {
        assert_eq!(
            parse_host_port("ws://127.0.0.1:9100").as_deref(),
            Some("127.0.0.1:9100")
        );
        assert_eq!(
            parse_host_port("wss://other:1234/json-rpc").as_deref(),
            Some("other:1234")
        );
        assert!(parse_host_port("ws://localhost").is_none());
        assert_eq!(
            parse_host_port("127.0.0.1:9100").as_deref(),
            Some("127.0.0.1:9100")
        );
        // IPv6 + TLS combination.
        assert_eq!(
            parse_host_port("wss://[::1]:9100/").as_deref(),
            Some("[::1]:9100")
        );
        assert!(parse_host_port("ws://[::1]").is_none());
    }

    #[test]
    fn parse_port_basic() {
        assert_eq!(parse_port("ws://127.0.0.1:9100"), Some(9100));
        assert_eq!(parse_port("wss://other:1234"), Some(1234));
        assert_eq!(parse_port("ws://[::1]:9101"), Some(9101));
        assert_eq!(parse_port("ws://localhost"), None);
        assert_eq!(parse_port("ws://[::1]"), None);
    }

    #[test]
    fn probe_dead_port_returns_false() {
        let prev = std::env::var("CAR_DAEMON_URL").ok();
        std::env::set_var("CAR_DAEMON_URL", "ws://127.0.0.1:1");
        assert!(!probe_daemon_port(Duration::from_millis(100)));
        match prev {
            Some(v) => std::env::set_var("CAR_DAEMON_URL", v),
            None => std::env::remove_var("CAR_DAEMON_URL"),
        }
    }
}