rusty-beads 0.1.0

Git-backed graph issue tracker for AI coding agents - a Rust implementation with context store, dependency tracking, and semantic compaction
Documentation
//! Daemon client for RPC communication.

use std::path::{Path, PathBuf};
use std::time::Duration;

use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::time::timeout;

use super::protocol::{HealthInfo, Operation, Request, Response, PROTOCOL_VERSION};
use super::get_socket_path;

/// Client for communicating with the daemon.
pub struct DaemonClient {
    socket_path: PathBuf,
    timeout: Duration,
}

impl DaemonClient {
    /// Create a new client for the given beads directory.
    pub fn new(beads_dir: impl AsRef<Path>) -> Self {
        Self {
            socket_path: get_socket_path(beads_dir.as_ref()),
            timeout: Duration::from_secs(30),
        }
    }

    /// Set the request timeout.
    pub fn with_timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Check if the daemon is available.
    pub async fn is_available(&self) -> bool {
        self.health().await.is_ok()
    }

    /// Connect to the daemon.
    async fn connect(&self) -> Result<UnixStream> {
        let connect_timeout = Duration::from_millis(500);
        timeout(connect_timeout, UnixStream::connect(&self.socket_path))
            .await
            .context("Connection timeout")?
            .context("Failed to connect to daemon")
    }

    /// Send a request and receive a response.
    pub async fn send(&self, request: &Request) -> Result<Response> {
        let stream = self.connect().await?;
        let (reader, mut writer) = stream.into_split();
        let mut reader = BufReader::new(reader);

        // Send request
        let json = serde_json::to_string(request)?;
        writer.write_all(json.as_bytes()).await?;
        writer.write_all(b"\n").await?;
        writer.flush().await?;

        // Read response
        let mut line = String::new();
        timeout(self.timeout, reader.read_line(&mut line))
            .await
            .context("Response timeout")?
            .context("Failed to read response")?;

        let response: Response = serde_json::from_str(&line)
            .context("Failed to parse response")?;

        Ok(response)
    }

    /// Get health information from the daemon.
    pub async fn health(&self) -> Result<HealthInfo> {
        let request = Request::new(Operation::Health, "client");
        let response = self.send(&request).await?;

        if response.success {
            response.parse_data()
                .context("Failed to parse health info")
        } else {
            anyhow::bail!(response.error.unwrap_or_else(|| "Unknown error".to_string()))
        }
    }

    /// Ping the daemon.
    pub async fn ping(&self) -> Result<()> {
        let request = Request::new(Operation::Ping, "client");
        let response = self.send(&request).await?;

        if response.success {
            Ok(())
        } else {
            anyhow::bail!(response.error.unwrap_or_else(|| "Ping failed".to_string()))
        }
    }

    /// Request daemon shutdown.
    pub async fn shutdown(&self) -> Result<()> {
        let request = Request::new(Operation::Shutdown, "client");
        let _ = self.send(&request).await;
        Ok(())
    }

    /// Check version compatibility.
    pub async fn check_compatibility(&self) -> Result<bool> {
        let health = self.health().await?;
        Ok(is_compatible(&health.protocol_version, PROTOCOL_VERSION))
    }
}

/// Check if two protocol versions are compatible.
fn is_compatible(server_version: &str, client_version: &str) -> bool {
    // Extract major versions
    let server_major = server_version.split('.').next().and_then(|s| s.parse::<u32>().ok());
    let client_major = client_version.split('.').next().and_then(|s| s.parse::<u32>().ok());

    match (server_major, client_major) {
        (Some(s), Some(c)) => s == c,
        _ => true, // Be permissive if we can't parse
    }
}

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

    #[test]
    fn test_version_compatibility() {
        assert!(is_compatible("1.0.0", "1.0.0"));
        assert!(is_compatible("1.0.0", "1.1.0"));
        assert!(is_compatible("1.2.3", "1.0.0"));
        assert!(!is_compatible("2.0.0", "1.0.0"));
        assert!(!is_compatible("1.0.0", "2.0.0"));
    }
}