smart-tree 8.0.1

Smart Tree - An intelligent, AI-friendly directory visualization tool
Documentation
//! STD Client - Talk to the Smart Tree Daemon via Unix socket
//!
//! This module provides a client for the ST binary protocol daemon.
//! Falls back gracefully to local operation if daemon isn't running.
//!
//! "The thin client to the fat brain!" - Cheet

use anyhow::{Context, Result};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;

use st_protocol::{Frame, Verb};

/// Get the default socket path
pub fn socket_path() -> PathBuf {
    std::env::var("XDG_RUNTIME_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from("/tmp"))
        .join("st.sock")
}

/// Check if the STD daemon is running
pub async fn is_daemon_running() -> bool {
    let path = socket_path();
    if !path.exists() {
        return false;
    }

    // Try to connect and ping
    match UnixStream::connect(&path).await {
        Ok(mut stream) => {
            let ping = Frame::ping();
            if stream.write_all(&ping.encode()).await.is_err() {
                return false;
            }

            let mut buf = [0u8; 256];
            match tokio::time::timeout(Duration::from_millis(500), stream.read(&mut buf)).await {
                Ok(Ok(n)) if n > 0 => {
                    // Got a response - daemon is alive
                    true
                }
                _ => false,
            }
        }
        Err(_) => false,
    }
}

/// Start the STD daemon in the background
pub async fn start_daemon() -> Result<bool> {
    if is_daemon_running().await {
        return Ok(false); // Already running
    }

    // Find the std binary - try same directory as current exe first
    let exe_path = std::env::current_exe().ok();
    let exe_dir = exe_path.as_ref().and_then(|p| p.parent());

    let std_path = if let Some(dir) = exe_dir {
        let candidate = dir.join("std");
        if candidate.exists() {
            candidate
        } else {
            // Fall back to PATH
            PathBuf::from("std")
        }
    } else {
        PathBuf::from("std")
    };

    // Start daemon as background process using setsid to fully detach
    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;

        // Use setsid to create a new session, fully detaching the daemon
        let mut cmd = Command::new(&std_path);
        cmd.arg("start")
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());

        // Create new process group
        unsafe {
            cmd.pre_exec(|| {
                libc::setsid();
                Ok(())
            });
        }

        cmd.spawn().context("Failed to start std daemon")?;
    }

    #[cfg(windows)]
    {
        Command::new(&std_path)
            .arg("start")
            .creation_flags(0x00000008) // DETACHED_PROCESS
            .spawn()
            .context("Failed to start std daemon")?;
    }

    // Wait for daemon to become ready (up to 5 seconds)
    // Daemon may take time to load memories
    for _ in 0..50 {
        tokio::time::sleep(Duration::from_millis(100)).await;
        if is_daemon_running().await {
            return Ok(true);
        }
    }

    Err(anyhow::anyhow!("Daemon started but not responding after 5 seconds"))
}

/// Client for communicating with the STD daemon
pub struct StdClient {
    stream: Option<UnixStream>,
}

impl StdClient {
    /// Connect to the daemon (returns None if not running)
    pub async fn connect() -> Option<Self> {
        let path = socket_path();
        match UnixStream::connect(&path).await {
            Ok(stream) => Some(Self {
                stream: Some(stream),
            }),
            Err(_) => None,
        }
    }

    /// Connect or start daemon if not running
    pub async fn connect_or_start() -> Result<Self> {
        if let Some(client) = Self::connect().await {
            return Ok(client);
        }

        // Not running - start it
        start_daemon().await?;

        // Try again
        Self::connect()
            .await
            .ok_or_else(|| anyhow::anyhow!("Failed to connect after starting daemon"))
    }

    /// Send a frame and get response
    pub async fn send(&mut self, frame: &Frame) -> Result<Vec<u8>> {
        let stream = self
            .stream
            .as_mut()
            .ok_or_else(|| anyhow::anyhow!("Not connected"))?;

        stream
            .write_all(&frame.encode())
            .await
            .context("Failed to send frame")?;

        let mut buf = vec![0u8; 65536];
        let n = stream.read(&mut buf).await.context("Failed to read response")?;
        buf.truncate(n);
        Ok(buf)
    }

    /// Ping the daemon
    pub async fn ping(&mut self) -> Result<bool> {
        let resp = self.send(&Frame::ping()).await?;
        Ok(!resp.is_empty() && resp[0] == Verb::Ping as u8)
    }

    /// Scan a directory via daemon
    pub async fn scan(&mut self, path: &str, depth: u8) -> Result<String> {
        let frame = Frame::scan(path, depth);
        let resp = self.send(&frame).await?;

        // Response format: [SCAN verb][payload...][END]
        if resp.is_empty() {
            return Ok(String::new());
        }

        // Skip verb byte and END byte, decode payload
        if resp.len() > 2 {
            let payload = &resp[1..resp.len() - 1];
            String::from_utf8(payload.to_vec()).context("Invalid UTF-8 in scan response")
        } else {
            Ok(String::new())
        }
    }

    /// Format directory via daemon (7 modes: classic, ai, json, hex, quantum, stats, digest)
    pub async fn format(&mut self, path: &str, depth: u8, mode: &str) -> Result<String> {
        let frame = Frame::format_path(mode, path, depth);
        let resp = self.send(&frame).await?;

        if resp.len() > 2 {
            let payload = &resp[1..resp.len() - 1];
            String::from_utf8(payload.to_vec()).context("Invalid UTF-8 in format response")
        } else {
            Ok(String::new())
        }
    }

    /// Search content via daemon
    pub async fn search(&mut self, path: &str, pattern: &str, max_results: u8) -> Result<String> {
        let frame = Frame::search_path(path, pattern, max_results);
        let resp = self.send(&frame).await?;

        if resp.len() > 2 {
            let payload = &resp[1..resp.len() - 1];
            String::from_utf8(payload.to_vec()).context("Invalid UTF-8 in search response")
        } else {
            Ok(String::new())
        }
    }

    /// Store a memory
    pub async fn remember(
        &mut self,
        content: &str,
        keywords: &str,
        memory_type: &str,
    ) -> Result<String> {
        let frame = Frame::remember(content, keywords, memory_type);
        let resp = self.send(&frame).await?;

        if resp.len() > 2 {
            let payload = &resp[1..resp.len() - 1];
            String::from_utf8(payload.to_vec()).context("Invalid UTF-8 in remember response")
        } else {
            Ok(String::new())
        }
    }

    /// Recall memories
    pub async fn recall(&mut self, keywords: &str, max_results: u8) -> Result<String> {
        let frame = Frame::recall(keywords, max_results);
        let resp = self.send(&frame).await?;

        if resp.len() > 2 {
            let payload = &resp[1..resp.len() - 1];
            String::from_utf8(payload.to_vec()).context("Invalid UTF-8 in recall response")
        } else {
            Ok(String::new())
        }
    }

    /// Get daemon stats (version, memories, grid info)
    pub async fn stats(&mut self) -> Result<serde_json::Value> {
        let frame = Frame::stats();
        let resp = self.send(&frame).await?;

        if resp.len() > 2 {
            let payload = &resp[1..resp.len() - 1];
            let json_str = String::from_utf8(payload.to_vec())
                .context("Invalid UTF-8 in stats response")?;
            serde_json::from_str(&json_str).context("Invalid JSON in stats response")
        } else {
            Ok(serde_json::json!({}))
        }
    }

    /// Get wave grid state
    pub async fn m8_wave(&mut self) -> Result<String> {
        let frame = Frame::m8_wave();
        let resp = self.send(&frame).await?;

        if resp.len() > 2 {
            let payload = &resp[1..resp.len() - 1];
            String::from_utf8(payload.to_vec()).context("Invalid UTF-8 in wave response")
        } else {
            Ok(String::new())
        }
    }

    /// Store audio memory (from liquid-rust AcousticMemory)
    ///
    /// Pass the raw bytes from AcousticMemory::to_bytes()
    pub async fn audio(&mut self, acoustic_bytes: &[u8]) -> Result<String> {
        let frame = Frame::audio(acoustic_bytes);
        let resp = self.send(&frame).await?;

        if resp.len() > 2 {
            let payload = &resp[1..resp.len() - 1];
            String::from_utf8(payload.to_vec()).context("Invalid UTF-8 in audio response")
        } else {
            Ok(String::new())
        }
    }

    /// Store simple audio memory (text + emotion)
    pub async fn audio_simple(&mut self, text: &str, valence: f32, arousal: f32) -> Result<String> {
        let frame = Frame::audio_simple(text, valence, arousal);
        let resp = self.send(&frame).await?;

        if resp.len() > 2 {
            let payload = &resp[1..resp.len() - 1];
            String::from_utf8(payload.to_vec()).context("Invalid UTF-8 in audio response")
        } else {
            Ok(String::new())
        }
    }
}

/// Ensure daemon is running, with user feedback
pub async fn ensure_daemon(quiet: bool) -> Result<()> {
    if is_daemon_running().await {
        return Ok(());
    }

    if !quiet {
        eprintln!("🌳 Starting Smart Tree daemon...");
    }

    start_daemon().await?;

    if !quiet {
        eprintln!("✓ Daemon ready");
    }

    Ok(())
}

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

    #[tokio::test]
    async fn test_socket_path() {
        let path = socket_path();
        assert!(path.to_string_lossy().contains("st.sock"));
    }

    #[tokio::test]
    async fn test_daemon_check() {
        // Just verify it doesn't panic
        let _ = is_daemon_running().await;
    }
}