devist 0.1.1

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
use anyhow::{anyhow, Result};
use console::style;
use notify::RecursiveMode;
use notify_debouncer_mini::new_debouncer;
use std::path::Path;
use std::sync::mpsc::channel;
use std::time::Duration;

use crate::registry::Registry;

pub fn run(name: String, debounce_ms: u64) -> Result<()> {
    let reg = Registry::load()?;
    let project = reg
        .find(&name)
        .ok_or_else(|| anyhow!("Project not found: {}", name))?
        .clone();

    if !project.path.exists() {
        return Err(anyhow!(
            "Project directory missing: {}",
            project.path.display()
        ));
    }

    println!("{}", style("devist watch").bold());
    println!(
        "  watching {} (debounce {}ms)",
        style(project.path.display()).cyan(),
        debounce_ms
    );
    println!("  press Ctrl+C to stop");
    println!();

    let (tx, rx) = channel();
    let mut debouncer = new_debouncer(Duration::from_millis(debounce_ms), tx)?;

    debouncer
        .watcher()
        .watch(&project.path, RecursiveMode::Recursive)?;

    for res in rx {
        match res {
            Ok(events) => {
                let interesting: Vec<&notify_debouncer_mini::DebouncedEvent> =
                    events.iter().filter(|e| !is_ignored(&e.path)).collect();
                if interesting.is_empty() {
                    continue;
                }
                print_change_block(&interesting, &project.path);
            }
            Err(error) => {
                eprintln!("  {} {:?}", style("[WATCH-ERR]").red(), error);
            }
        }
    }

    Ok(())
}

fn print_change_block(events: &[&notify_debouncer_mini::DebouncedEvent], project_root: &Path) {
    let now = chrono::Local::now().format("%H:%M:%S");
    println!(
        "{} {} {}",
        style(format!("[{}]", now)).dim(),
        style("CHANGE").yellow(),
        style(format!("{} file(s)", events.len())).bold()
    );
    for e in events {
        let rel = e
            .path
            .strip_prefix(project_root)
            .map(|p| p.to_path_buf())
            .unwrap_or_else(|_| e.path.clone());
        println!("  {} {}", style("ยท").dim(), rel.display());
    }
    println!();
}

const IGNORED_DIRS: &[&str] = &[
    "node_modules",
    "target",
    "dist",
    "build",
    ".next",
    ".turbo",
    ".cache",
    ".venv",
    "venv",
    "__pycache__",
    ".pytest_cache",
    ".git",
    ".idea",
    ".vscode",
    ".expo",
    ".dart_tool",
];

const IGNORED_FILES: &[&str] = &[".DS_Store"];

fn is_ignored(path: &Path) -> bool {
    let s = path.to_string_lossy();
    for d in IGNORED_DIRS {
        if s.contains(&format!("/{}/", d))
            || s.contains(&format!("\\{}\\", d))
            || s.ends_with(&format!("/{}", d))
        {
            return true;
        }
    }
    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
        if IGNORED_FILES.contains(&name) {
            return true;
        }
    }
    false
}