collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Watch mode: monitor files for changes and auto-run agent tasks.
//!
//! Usage: `collet --watch "run cargo test" --ext rs --cwd ./src --agent code`
//! Watches the working directory for file changes and re-runs the prompt.

use std::path::Path;
use std::time::Duration;

use anyhow::Result;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

use crate::agent::context::ConversationContext;
use crate::agent::r#loop::AgentEvent;
use crate::agent::prompt;
use crate::api::provider::OpenAiCompatibleProvider;
use crate::config::Config;
use crate::project_cache;

/// Configuration for watch mode.
pub struct WatchConfig {
    /// The prompt to execute on file changes.
    pub prompt: String,
    /// Debounce delay before triggering (to batch rapid saves).
    pub debounce_ms: u64,
    /// File extensions to watch (empty = all).
    pub extensions: Vec<String>,
    /// Directory to watch via --cwd (None = current directory).
    pub watch_dir: Option<String>,
    /// Model override (None = use config default).
    pub model: Option<String>,
    /// Agent name to use (None = use AgentMode).
    pub agent: Option<String>,
}

/// Run in watch mode — monitor files and re-run agent on changes.
pub async fn run_watch(
    mut config: Config,
    client: OpenAiCompatibleProvider,
    watch_config: WatchConfig,
) -> Result<()> {
    // Use watch_dir if specified, otherwise current directory
    let working_dir = watch_config.watch_dir.clone().unwrap_or_else(|| {
        std::env::current_dir()
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_else(|_| ".".to_string())
    });

    // Apply model override
    if let Some(ref model) = watch_config.model {
        config.model = model.clone();
    }

    // Apply agent settings if specified
    if let Some(ref agent_name) = watch_config.agent
        && let Some(agent) = config.agents.iter().find(|a| &a.name == agent_name)
    {
        config.model = agent.model.clone();
    }

    eprintln!(
        "👁  Watch mode active (debounce: {}ms)",
        watch_config.debounce_ms
    );
    eprintln!("📁 Directory: {}", working_dir);
    eprintln!("🤖 Model: {}", config.model);
    eprintln!("📝 Prompt: {}", &watch_config.prompt);
    if !watch_config.extensions.is_empty() {
        eprintln!(
            "📎 Watching extensions: {}",
            watch_config.extensions.join(", ")
        );
    }
    if let Some(ref agent) = watch_config.agent {
        eprintln!("👤 Agent: {}", agent);
    }
    eprintln!("Press Ctrl+C to stop.");
    eprintln!("---");

    // Start global project cache background tasks.
    project_cache::global().ensure_background_tasks();

    let cancel = CancellationToken::new();
    let cancel_clone = cancel.clone();
    tokio::spawn(async move {
        tokio::signal::ctrl_c().await.ok();
        cancel_clone.cancel();
    });

    // Simple polling-based file watcher (avoids extra dependency on notify)
    let mut last_snapshot = snapshot_mtimes(&working_dir, &watch_config.extensions);
    let debounce = Duration::from_millis(watch_config.debounce_ms);

    loop {
        if cancel.is_cancelled() {
            break;
        }

        tokio::time::sleep(debounce).await;

        let current_snapshot = snapshot_mtimes(&working_dir, &watch_config.extensions);

        // Find changed files
        let changed: Vec<String> = current_snapshot
            .iter()
            .filter(|(path, mtime)| {
                last_snapshot
                    .get(path.as_str())
                    .map(|t| t != *mtime)
                    .unwrap_or(true)
            })
            .map(|(path, _)| path.clone())
            .collect();

        if changed.is_empty() {
            continue;
        }

        last_snapshot = current_snapshot;

        eprintln!("\n🔄 Files changed: {}", changed.join(", "));

        // Notify global cache about changed files so it can do incremental rebuild.
        for path in &changed {
            let abs = if Path::new(path).is_absolute() {
                path.clone()
            } else {
                format!("{}/{}", working_dir, path)
            };
            project_cache::global().notify_file_modified(&abs);
        }

        // Run the agent with the cached repo map.
        let snap = {
            let wd = working_dir.clone();
            tokio::task::spawn_blocking(move || project_cache::snapshot(&wd))
                .await
                .unwrap()
        };

        let system_prompt = prompt::build_default_prompt(
            &snap.map_string,
            snap.file_count,
            snap.symbol_count,
            None,
        );

        let context = ConversationContext::with_budget(
            system_prompt,
            config.context_max_tokens,
            config.compaction_threshold,
        );
        let (event_tx, mut event_rx) = mpsc::unbounded_channel::<AgentEvent>();
        let run_cancel = CancellationToken::new();

        let client_clone = client.clone();
        let config_clone = config.clone();
        let wd = working_dir.clone();
        let prompt = format!(
            "{}\n\nChanged files: {}",
            watch_config.prompt,
            changed.join(", "),
        );
        let lsp_manager = crate::lsp::manager::LspManager::new(working_dir.clone());
        let lsp_clone = lsp_manager.clone();

        tokio::spawn(async move {
            crate::agent::r#loop::run_with_mode(crate::agent::r#loop::AgentParams {
                client: client_clone,
                config: config_clone,
                context,
                user_msg: prompt,
                working_dir: wd,
                event_tx,
                cancel: run_cancel,
                lsp_manager: lsp_clone,
                trust_level: crate::trust::TrustLevel::Full, // watch mode trusts by default
                approval_gate: crate::agent::approval::ApprovalGate::yolo(),
                images: Vec::new(),
            })
            .await;
        });

        // Drain events
        while let Some(event) = event_rx.recv().await {
            match event {
                AgentEvent::Token(token) => print!("{token}"),
                AgentEvent::ToolCall { name, .. } => eprintln!("  🔧 {name}"),
                AgentEvent::ToolResult { name, success, .. } => {
                    let icon = if success { "" } else { "" };
                    eprintln!("  {icon} {name}");
                }
                AgentEvent::FileModified { path } => eprintln!("  📄 {path}"),
                AgentEvent::Error(msg) => eprintln!("{msg}"),
                AgentEvent::Done { .. } => {
                    println!();
                    eprintln!("--- Run complete. Watching for changes...");
                    break;
                }
                _ => {}
            }
        }

        if cancel.is_cancelled() {
            break;
        }
    }

    eprintln!("👁  Watch mode stopped.");
    Ok(())
}

/// Snapshot file modification times for change detection.
fn snapshot_mtimes(dir: &str, extensions: &[String]) -> std::collections::HashMap<String, u64> {
    let mut map = std::collections::HashMap::new();

    let walker = ignore::WalkBuilder::new(dir)
        .hidden(true)
        .git_ignore(true)
        .add_custom_ignore_filename(".colletignore")
        .max_depth(Some(10))
        .build();

    for entry in walker.flatten() {
        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
            continue;
        }

        let path = entry.path();

        // Filter by extension if specified
        if !extensions.is_empty() {
            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
                if !extensions.iter().any(|e| e == ext) {
                    continue;
                }
            } else {
                continue;
            }
        }

        if let Ok(meta) = path.metadata()
            && let Ok(modified) = meta.modified()
        {
            let secs = modified
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .as_secs();
            let rel = path
                .strip_prefix(dir)
                .unwrap_or(path)
                .to_string_lossy()
                .to_string();
            map.insert(rel, secs);
        }
    }

    map
}