vyctor 0.1.0

A fast CLI tool for semantic file search using vector embeddings
Documentation
//! Daemon management for vyctor watch
//!
//! Handles starting, stopping, and monitoring the background file watcher daemon.

use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::SystemTime;

use crate::config::VYCTOR_DIR;

/// PID file name within .vyctor directory
const PID_FILE: &str = "watch.pid";
/// Log file name within .vyctor directory
const LOG_FILE: &str = "watch.log";

/// Status of the daemon
#[derive(Debug)]
pub struct DaemonStatus {
    /// Whether the daemon is running
    pub running: bool,
    /// Process ID if running
    pub pid: Option<u32>,
    /// Time the daemon was started (from PID file mtime)
    pub started_at: Option<SystemTime>,
}

/// Get the path to the PID file
pub fn pid_file_path(root: &Path) -> PathBuf {
    root.join(VYCTOR_DIR).join(PID_FILE)
}

/// Get the path to the log file
pub fn log_file_path(root: &Path) -> PathBuf {
    root.join(VYCTOR_DIR).join(LOG_FILE)
}

/// Check if a process with the given PID is running
#[cfg(unix)]
fn is_process_running(pid: u32) -> bool {
    // Use kill -0 to check if process exists
    // This is a standard Unix way to check process existence without sending a signal
    unsafe { libc::kill(pid as i32, 0) == 0 }
}

#[cfg(not(unix))]
fn is_process_running(_pid: u32) -> bool {
    // On non-Unix systems (Windows), we can't easily check process existence
    // without additional dependencies. For now, assume running if PID file exists.
    //
    // Note: Windows users should use foreground watch mode (`vyctor watch`) instead
    // of daemon mode (`vyctor watch --daemon`). The daemon mode is designed for
    // Unix-like systems (macOS, Linux) where process management is straightforward.
    //
    // A future version may add Windows-specific process checking using the
    // Windows API (e.g., OpenProcess/GetExitCodeProcess).
    true
}

/// Check if the daemon is running and return its PID if so
pub fn is_daemon_running(root: &Path) -> Option<u32> {
    let pid_path = pid_file_path(root);

    if !pid_path.exists() {
        return None;
    }

    // Read PID from file
    let pid_str = match fs::read_to_string(&pid_path) {
        Ok(s) => s,
        Err(_) => return None,
    };

    let pid: u32 = match pid_str.trim().parse() {
        Ok(p) => p,
        Err(_) => {
            // Invalid PID file, clean it up
            let _ = fs::remove_file(&pid_path);
            return None;
        }
    };

    // Check if process is actually running
    if is_process_running(pid) {
        Some(pid)
    } else {
        // Stale PID file, clean it up
        let _ = fs::remove_file(&pid_path);
        None
    }
}

/// Get the full status of the daemon
pub fn get_daemon_status(root: &Path) -> DaemonStatus {
    let pid_path = pid_file_path(root);

    if let Some(pid) = is_daemon_running(root) {
        let started_at = fs::metadata(&pid_path).ok().and_then(|m| m.modified().ok());

        DaemonStatus {
            running: true,
            pid: Some(pid),
            started_at,
        }
    } else {
        DaemonStatus {
            running: false,
            pid: None,
            started_at: None,
        }
    }
}

/// Start the daemon process
///
/// This spawns a new vyctor process with the `watch` command and the `--daemon-child` flag
/// (an internal flag indicating this is the actual daemon process).
pub fn start_daemon(root: &Path, debounce_ms: u64) -> Result<u32> {
    // Check if already running
    if let Some(pid) = is_daemon_running(root) {
        anyhow::bail!("Daemon already running (PID {})", pid);
    }

    let vyctor_dir = root.join(VYCTOR_DIR);
    let pid_path = pid_file_path(root);
    let log_path = log_file_path(root);

    // Ensure .vyctor directory exists
    fs::create_dir_all(&vyctor_dir).context("Failed to create .vyctor directory")?;

    // Open log file for output
    let log_file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&log_path)
        .context("Failed to open log file")?;

    let log_file_err = log_file
        .try_clone()
        .context("Failed to clone log file handle")?;

    // Get the path to the current executable
    let exe_path = std::env::current_exe().context("Failed to get current executable path")?;

    // Spawn the daemon process
    let child = Command::new(&exe_path)
        .arg("watch")
        .arg("--debounce")
        .arg(debounce_ms.to_string())
        .arg("--daemon-child")
        .current_dir(root)
        .stdin(Stdio::null())
        .stdout(Stdio::from(log_file))
        .stderr(Stdio::from(log_file_err))
        .spawn()
        .context("Failed to spawn daemon process")?;

    let pid = child.id();

    // Write PID to file
    fs::write(&pid_path, pid.to_string()).context("Failed to write PID file")?;

    Ok(pid)
}

/// Stop the daemon process
///
/// Returns true if daemon was stopped, false if it wasn't running
#[cfg(unix)]
pub fn stop_daemon(root: &Path) -> Result<bool> {
    let pid_path = pid_file_path(root);

    let pid = match is_daemon_running(root) {
        Some(p) => p,
        None => return Ok(false),
    };

    // Send SIGTERM to the process
    let result = unsafe { libc::kill(pid as i32, libc::SIGTERM) };

    if result != 0 {
        // Process might have already exited
        let _ = fs::remove_file(&pid_path);
        return Ok(true);
    }

    // Wait a bit for the process to exit gracefully
    for _ in 0..50 {
        std::thread::sleep(std::time::Duration::from_millis(100));
        if !is_process_running(pid) {
            break;
        }
    }

    // If still running, send SIGKILL
    if is_process_running(pid) {
        unsafe { libc::kill(pid as i32, libc::SIGKILL) };
        std::thread::sleep(std::time::Duration::from_millis(100));
    }

    // Clean up PID file
    let _ = fs::remove_file(&pid_path);

    Ok(true)
}

#[cfg(not(unix))]
pub fn stop_daemon(root: &Path) -> Result<bool> {
    let pid_path = pid_file_path(root);

    if is_daemon_running(root).is_none() {
        return Ok(false);
    }

    // On non-Unix systems, we can't easily stop the process
    // Just remove the PID file and hope the process notices
    let _ = fs::remove_file(&pid_path);

    anyhow::bail!("Cannot stop daemon on this platform. Please stop the process manually.");
}

/// Read the last N lines from the log file
pub fn read_log_tail(root: &Path, lines: usize) -> Result<String> {
    let log_path = log_file_path(root);

    if !log_path.exists() {
        return Ok(String::new());
    }

    let content = fs::read_to_string(&log_path).context("Failed to read log file")?;

    let all_lines: Vec<&str> = content.lines().collect();
    let start = all_lines.len().saturating_sub(lines);

    Ok(all_lines[start..].join("\n"))
}

/// Format uptime duration for display
pub fn format_uptime(started_at: SystemTime) -> String {
    let duration = match SystemTime::now().duration_since(started_at) {
        Ok(d) => d,
        Err(_) => return "unknown".to_string(),
    };

    let secs = duration.as_secs();
    let mins = secs / 60;
    let hours = mins / 60;
    let days = hours / 24;

    if days > 0 {
        format!("{}d {}h", days, hours % 24)
    } else if hours > 0 {
        format!("{}h {}m", hours, mins % 60)
    } else if mins > 0 {
        format!("{}m {}s", mins, secs % 60)
    } else {
        format!("{}s", secs)
    }
}

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

    #[test]
    fn test_pid_file_path() {
        let root = PathBuf::from("/test/project");
        let path = pid_file_path(&root);
        assert_eq!(path, PathBuf::from("/test/project/.vyctor/watch.pid"));
    }

    #[test]
    fn test_log_file_path() {
        let root = PathBuf::from("/test/project");
        let path = log_file_path(&root);
        assert_eq!(path, PathBuf::from("/test/project/.vyctor/watch.log"));
    }

    #[test]
    fn test_is_daemon_running_no_pid_file() {
        let dir = tempdir().unwrap();
        assert!(is_daemon_running(dir.path()).is_none());
    }

    #[test]
    fn test_is_daemon_running_stale_pid() {
        let dir = tempdir().unwrap();
        let vyctor_dir = dir.path().join(".vyctor");
        fs::create_dir_all(&vyctor_dir).unwrap();

        // Write a PID that definitely doesn't exist
        fs::write(vyctor_dir.join("watch.pid"), "999999999").unwrap();

        // Should return None and clean up the stale file
        assert!(is_daemon_running(dir.path()).is_none());
        assert!(!vyctor_dir.join("watch.pid").exists());
    }

    #[test]
    fn test_is_daemon_running_invalid_pid() {
        let dir = tempdir().unwrap();
        let vyctor_dir = dir.path().join(".vyctor");
        fs::create_dir_all(&vyctor_dir).unwrap();

        // Write invalid PID
        fs::write(vyctor_dir.join("watch.pid"), "not_a_number").unwrap();

        // Should return None and clean up the invalid file
        assert!(is_daemon_running(dir.path()).is_none());
        assert!(!vyctor_dir.join("watch.pid").exists());
    }

    #[test]
    fn test_format_uptime() {
        use std::time::Duration;

        let now = SystemTime::now();

        // Test seconds
        let started = now - Duration::from_secs(30);
        assert_eq!(format_uptime(started), "30s");

        // Test minutes
        let started = now - Duration::from_secs(90);
        assert_eq!(format_uptime(started), "1m 30s");

        // Test hours
        let started = now - Duration::from_secs(3700);
        assert_eq!(format_uptime(started), "1h 1m");

        // Test days
        let started = now - Duration::from_secs(90000);
        assert_eq!(format_uptime(started), "1d 1h");
    }

    #[test]
    fn test_read_log_tail_empty() {
        let dir = tempdir().unwrap();
        let result = read_log_tail(dir.path(), 10).unwrap();
        assert_eq!(result, "");
    }

    #[test]
    fn test_read_log_tail() {
        let dir = tempdir().unwrap();
        let vyctor_dir = dir.path().join(".vyctor");
        fs::create_dir_all(&vyctor_dir).unwrap();

        let log_content = "line1\nline2\nline3\nline4\nline5\n";
        fs::write(vyctor_dir.join("watch.log"), log_content).unwrap();

        let result = read_log_tail(dir.path(), 3).unwrap();
        assert_eq!(result, "line3\nline4\nline5");
    }

    #[test]
    fn test_get_daemon_status_not_running() {
        let dir = tempdir().unwrap();
        let status = get_daemon_status(dir.path());
        assert!(!status.running);
        assert!(status.pid.is_none());
        assert!(status.started_at.is_none());
    }
}