vyctor 0.1.0

A fast CLI tool for semantic file search using vector embeddings
Documentation
//! Implementation of the `vyctor watch` command

use crate::config::{find_vyctor_root, load_config, VYCTOR_DIR};
use crate::daemon::{
    format_uptime, get_daemon_status, is_daemon_running, log_file_path, read_log_tail,
    start_daemon, stop_daemon,
};
use crate::indexer::{FileWalker, Indexer};
use anyhow::Result;
use colored::Colorize;
use notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use std::collections::HashSet;
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::path::PathBuf;
use std::sync::mpsc::channel;
use std::time::Duration;

/// Run the watch command with various modes
pub async fn run(
    debounce_ms: u64,
    daemon: bool,
    stop: bool,
    status: bool,
    logs: bool,
    follow: bool,
    daemon_child: bool,
) -> Result<()> {
    let root = find_vyctor_root()?;

    // Handle the different modes
    if stop {
        return run_stop(&root);
    }

    if status {
        return run_status(&root);
    }

    if logs {
        return run_logs(&root, follow);
    }

    if daemon {
        return run_daemon(&root, debounce_ms);
    }

    // Default: run in foreground (or as daemon child)
    run_foreground(&root, debounce_ms, daemon_child).await
}

/// Start the daemon in background
fn run_daemon(root: &std::path::Path, debounce_ms: u64) -> Result<()> {
    // Check if already running
    if let Some(pid) = is_daemon_running(root) {
        println!("{} Watcher already running (PID {})", "".cyan(), pid);
        return Ok(());
    }

    println!("{} Starting watcher daemon...", "".cyan());

    match start_daemon(root, debounce_ms) {
        Ok(pid) => {
            println!("{} Watcher started (PID {})", "".green(), pid);
            println!("  Log file: {}", log_file_path(root).display());
            println!();
            println!("  Run 'vyctor watch --status' to check status");
            println!("  Run 'vyctor watch --logs -f' to follow logs");
            println!("  Run 'vyctor watch --stop' to stop");
            Ok(())
        }
        Err(e) => {
            eprintln!("{} Failed to start daemon: {}", "!".red(), e);
            Err(e)
        }
    }
}

/// Stop the running daemon
fn run_stop(root: &std::path::Path) -> Result<()> {
    match stop_daemon(root) {
        Ok(true) => {
            println!("{} Watcher stopped", "".green());
            Ok(())
        }
        Ok(false) => {
            println!("{} Watcher is not running", "".cyan());
            Ok(())
        }
        Err(e) => {
            eprintln!("{} Failed to stop daemon: {}", "!".red(), e);
            Err(e)
        }
    }
}

/// Show daemon status
fn run_status(root: &std::path::Path) -> Result<()> {
    let status = get_daemon_status(root);

    println!("{} Watcher status for {}", "".cyan(), root.display());
    println!();

    if status.running {
        let pid = status
            .pid
            .expect("PID should be present when status.running is true");
        println!("  Status: {}", "Running".green());
        println!("  PID: {}", pid);

        if let Some(started_at) = status.started_at {
            println!("  Uptime: {}", format_uptime(started_at));
        }

        println!();
        println!("  Log file: {}", log_file_path(root).display());
    } else {
        println!("  Status: {}", "Not running".yellow());
        println!();
        println!("  Run 'vyctor watch --daemon' to start");
    }

    Ok(())
}

/// Show daemon logs
fn run_logs(root: &std::path::Path, follow: bool) -> Result<()> {
    let log_path = log_file_path(root);

    if !log_path.exists() {
        println!("{} No log file found", "".cyan());
        return Ok(());
    }

    if follow {
        // Follow mode: tail -f behavior
        println!("{} Following logs (Ctrl+C to stop)...", "".cyan());
        println!();

        let file = std::fs::File::open(&log_path)?;
        let mut reader = BufReader::new(file);

        // Seek to end
        reader.seek(SeekFrom::End(0))?;

        // First show last 20 lines
        let initial = read_log_tail(root, 20)?;
        if !initial.is_empty() {
            for line in initial.lines() {
                println!("{}", line);
            }
        }

        // Now follow new content
        loop {
            let mut line = String::new();
            match reader.read_line(&mut line) {
                Ok(0) => {
                    // No new content, wait a bit
                    std::thread::sleep(Duration::from_millis(100));
                }
                Ok(_) => {
                    print!("{}", line);
                }
                Err(e) => {
                    eprintln!("{} Error reading log: {}", "!".red(), e);
                    break;
                }
            }
        }
    } else {
        // Show last 50 lines
        let content = read_log_tail(root, 50)?;
        if content.is_empty() {
            println!("{} Log file is empty", "".cyan());
        } else {
            println!("{}", content);
        }
    }

    Ok(())
}

/// Run the watcher in foreground
async fn run_foreground(root: &std::path::Path, debounce_ms: u64, is_daemon: bool) -> Result<()> {
    let config = load_config()?;

    if is_daemon {
        // Daemon mode: minimal output, log format
        println!(
            "[{}] Daemon started",
            chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
        );
        println!("  Root: {}", root.display());
        println!("  Debounce: {}ms", debounce_ms);
    } else {
        println!("{} Starting file watcher...", "".cyan());
        println!("  Root: {}", root.display());
        println!("  Debounce: {}ms", debounce_ms);
        println!();
        println!(
            "{}",
            "Watching for file changes. Press Ctrl+C to stop.".dimmed()
        );
        println!();
    }

    // Create the file walker for checking if files should be indexed
    let file_walker = FileWalker::new(
        root.to_path_buf(),
        config.indexing.include.clone(),
        config.indexing.exclude.clone(),
    );

    // Create the indexer
    let indexer = Indexer::new(root, &config)?;

    // Set up the debounced watcher
    let (tx, rx) = channel();

    let mut debouncer = new_debouncer(Duration::from_millis(debounce_ms), move |res| {
        if let Ok(events) = res {
            let _ = tx.send(events);
        }
    })?;

    // Watch the root directory
    debouncer.watcher().watch(root, RecursiveMode::Recursive)?;

    // Process events
    loop {
        match rx.recv() {
            Ok(events) => {
                // Collect unique paths that need processing
                let mut paths_to_index: HashSet<PathBuf> = HashSet::new();
                let mut paths_to_remove: HashSet<PathBuf> = HashSet::new();

                for event in events {
                    let path = &event.path;

                    // Skip .vyctor directory
                    if path.components().any(|c| c.as_os_str() == VYCTOR_DIR) {
                        continue;
                    }

                    // Skip if path doesn't match our include/exclude patterns
                    if path.is_file() && !file_walker.should_index(path) {
                        continue;
                    }

                    match event.kind {
                        DebouncedEventKind::Any => {
                            if path.exists() && path.is_file() {
                                paths_to_index.insert(path.clone());
                            } else if !path.exists() {
                                paths_to_remove.insert(path.clone());
                            }
                        }
                        DebouncedEventKind::AnyContinuous => {
                            // Ignore continuous events (e.g., during large writes)
                        }
                        _ => {
                            // Handle any future variants
                        }
                    }
                }

                let timestamp = if is_daemon {
                    format!("[{}] ", chrono::Local::now().format("%H:%M:%S"))
                } else {
                    String::new()
                };

                // Process removed files
                for path in paths_to_remove {
                    let relative = path.strip_prefix(root).unwrap_or(&path).display();

                    match indexer.remove_file(&path) {
                        Ok(true) => {
                            if is_daemon {
                                println!("{}{} (removed)", timestamp, relative);
                            } else {
                                println!("  {} {} (removed)", "".red(), relative);
                            }
                        }
                        Ok(false) => {
                            // File wasn't in index
                        }
                        Err(e) => {
                            if is_daemon {
                                println!("{}! Failed to remove {}: {}", timestamp, relative, e);
                            } else {
                                eprintln!(
                                    "  {} Failed to remove {}: {}",
                                    "!".yellow(),
                                    relative,
                                    e
                                );
                            }
                        }
                    }
                }

                // Process modified/new files
                for path in paths_to_index {
                    let relative = path.strip_prefix(root).unwrap_or(&path).display();

                    match indexer.index_file(&path).await {
                        Ok(true) => {
                            if is_daemon {
                                println!("{}+ {} (indexed)", timestamp, relative);
                            } else {
                                println!("  {} {} (indexed)", "+".green(), relative);
                            }
                        }
                        Ok(false) => {
                            // File unchanged
                        }
                        Err(e) => {
                            if is_daemon {
                                println!("{}! Failed to index {}: {}", timestamp, relative, e);
                            } else {
                                eprintln!("  {} Failed to index {}: {}", "!".yellow(), relative, e);
                            }
                        }
                    }
                }
            }
            Err(e) => {
                if is_daemon {
                    println!(
                        "[{}] Watcher error: {}",
                        chrono::Local::now().format("%H:%M:%S"),
                        e
                    );
                } else {
                    eprintln!("{} Watcher error: {}", "!".red(), e);
                }
            }
        }
    }
}