use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, SystemTime};
use anyhow::{Context, Result, bail};
use indicatif::ProgressBar;
use walkdir::WalkDir;
use crate::core::registry::RecipeRegistry;
use crate::utils::terminal;
const DEFAULT_DEBOUNCE_MS: u64 = 500;
const POLL_INTERVAL_MS: u64 = 250;
pub fn execute(path: &Path, recipes: &[String], debounce_ms: u64) -> Result<()> {
if !path.exists() {
bail!("Watch path does not exist: {}", path.display());
}
let path = if path.is_relative() {
std::env::current_dir()?.join(path)
} else {
path.to_path_buf()
};
let debounce = Duration::from_millis(if debounce_ms == 0 {
DEFAULT_DEBOUNCE_MS
} else {
debounce_ms
});
let registry = RecipeRegistry::new();
let selected_recipes = selected_recipes(®istry, recipes)?;
let mut snapshot = scan_snapshot(&path);
let mut pending = HashSet::<PathBuf>::new();
let mut last_change = None::<SystemTime>;
println!("{}", terminal::label("Watch Mode"));
println!("path: {}", path.display());
println!(
"recipes: {}",
selected_recipes
.iter()
.map(|recipe| recipe.metadata().name)
.collect::<Vec<_>>()
.join(", ")
);
println!("debounce: {}ms", debounce.as_millis());
loop {
let current = scan_snapshot(&path);
let changed = changed_files(&snapshot, ¤t);
if !changed.is_empty() {
pending.extend(changed);
last_change = Some(SystemTime::now());
snapshot = current;
}
if !pending.is_empty()
&& last_change
.and_then(|last| last.elapsed().ok())
.is_some_and(|elapsed| elapsed >= debounce)
{
let files = pending.drain().collect::<Vec<_>>();
run_detection_cycle(&selected_recipes, &files)?;
last_change = None;
}
thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
}
}
fn selected_recipes<'a>(
registry: &'a RecipeRegistry,
recipes: &[String],
) -> Result<Vec<&'a dyn crate::core::recipe::Recipe>> {
if recipes.is_empty() {
return Ok(registry.all());
}
let mut selected = Vec::new();
for name in recipes {
selected.push(
registry
.find(name)
.with_context(|| format!("Unknown recipe `{}`", name))?,
);
}
Ok(selected)
}
fn run_detection_cycle(
recipes: &[&dyn crate::core::recipe::Recipe],
files: &[PathBuf],
) -> Result<()> {
let progress = ProgressBar::hidden();
let mut triggered = Vec::new();
let mut skipped_files = 0usize;
for recipe in recipes {
let metadata = recipe.metadata();
let matching_files = files
.iter()
.filter(|path| has_supported_extension(path, metadata.supported_extensions))
.cloned()
.collect::<Vec<_>>();
if matching_files.is_empty() {
continue;
}
triggered.push(metadata.name);
for file in &matching_files {
let report = recipe.detect(file, &progress)?;
skipped_files += report.skipped_files.len() + report.failed_files.len();
if report.analyses.is_empty() {
skipped_files += 1;
}
}
}
println!();
println!("{}", terminal::label("Watch Summary"));
println!("changed files: {}", files.len());
println!(
"triggered recipes: {}",
if triggered.is_empty() {
"none".to_string()
} else {
triggered.join(", ")
}
);
println!("skipped files: {}", skipped_files);
Ok(())
}
fn scan_snapshot(root: &Path) -> HashMap<PathBuf, SystemTime> {
WalkDir::new(root)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
name != "node_modules" && name != ".git" && name != "target" && name != "dist" && name != "build"
})
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_file())
.filter(|entry| !is_ignored_path(entry.path()))
.filter_map(|entry| {
let modified = entry.metadata().ok()?.modified().ok()?;
Some((entry.path().to_path_buf(), modified))
})
.collect()
}
fn changed_files(
previous: &HashMap<PathBuf, SystemTime>,
current: &HashMap<PathBuf, SystemTime>,
) -> Vec<PathBuf> {
current
.iter()
.filter_map(|(path, modified)| match previous.get(path) {
Some(previous_modified) if previous_modified == modified => None,
_ => Some(path.clone()),
})
.collect()
}
fn has_supported_extension(path: &Path, supported_extensions: &[&str]) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| supported_extensions.contains(&extension))
}
fn is_ignored_path(path: &Path) -> bool {
path.components().any(|component| {
let value = component.as_os_str().to_string_lossy();
matches!(
value.as_ref(),
".git" | ".morph-cli" | "node_modules" | "target" | "dist" | "build"
)
})
}