use anyhow::{Context, Result};
use console::style;
use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use crate::lanes;
use crate::run as task_runner;
const IGNORE_DIRS: &[&str] = &[".git", "target", "node_modules", ".fledge", "__pycache__"];
pub struct WatchOptions {
pub name: String,
pub lane: bool,
pub path: Option<PathBuf>,
pub extensions: Vec<String>,
pub debounce_ms: u64,
pub clear: bool,
}
pub fn run(opts: WatchOptions) -> Result<()> {
let watch_path = match &opts.path {
Some(p) => p.clone(),
None => std::env::current_dir().context("getting current directory")?,
};
if !watch_path.exists() {
anyhow::bail!("Watch path does not exist: {}", watch_path.display());
}
let mode_label = if opts.lane { "lane" } else { "task" };
println!(
"{} Watching for changes to re-run {} {}",
style("*").cyan().bold(),
mode_label,
style(&opts.name).green()
);
println!(
" {} {}",
style("Path:").dim(),
style(watch_path.display()).dim()
);
if !opts.extensions.is_empty() {
println!(
" {} {}",
style("Extensions:").dim(),
style(opts.extensions.join(", ")).dim()
);
}
println!(
" {} {}ms",
style("Debounce:").dim(),
style(opts.debounce_ms).dim()
);
println!(" {}", style("Press Ctrl+C to stop.").dim());
println!();
run_target(&opts);
let (tx, rx) = mpsc::channel::<notify::Result<Event>>();
let mut watcher = notify::recommended_watcher(tx).context("failed to create file watcher")?;
watcher
.watch(&watch_path, RecursiveMode::Recursive)
.with_context(|| format!("failed to watch path: {}", watch_path.display()))?;
let debounce = Duration::from_millis(opts.debounce_ms);
println!("{}", style("Watching for changes...").cyan());
loop {
let event = match rx.recv() {
Ok(Ok(event)) => event,
Ok(Err(e)) => {
eprintln!("{} Watcher error: {}", style("warning:").yellow().bold(), e);
continue;
}
Err(_) => {
break;
}
};
if !is_relevant_event(&event) {
continue;
}
let changed_paths: Vec<&Path> = event
.paths
.iter()
.filter(|p| !should_ignore_path(p))
.filter(|p| matches_extensions(p, &opts.extensions))
.map(|p| p.as_path())
.collect();
if changed_paths.is_empty() {
continue;
}
drain_events(&rx, debounce, &opts.extensions);
for p in &changed_paths {
let display = p.strip_prefix(&watch_path).unwrap_or(p).display();
println!(
"{} {}",
style("Change detected:").yellow().bold(),
style(display).dim()
);
}
run_target(&opts);
println!("\n{}", style("Watching for changes...").cyan());
}
Ok(())
}
fn run_target(opts: &WatchOptions) {
if opts.clear {
print!("\x1B[2J\x1B[1;1H");
}
let start = Instant::now();
let mode_label = if opts.lane { "lane" } else { "task" };
println!(
"{} Re-running {}: {}",
style(">>>").cyan().bold(),
mode_label,
style(&opts.name).green()
);
println!();
let result = if opts.lane {
lanes::run(lanes::LaneAction::Run {
name: opts.name.clone(),
dry_run: false,
json: false,
})
} else {
task_runner::run(task_runner::RunOptions {
task: Some(opts.name.clone()),
init: false,
list: false,
lang: None,
json: false,
})
};
let elapsed = start.elapsed();
let elapsed_str = format_duration(elapsed);
match result {
Ok(()) => {
println!(
"\n{} Completed in {}",
style("OK").green().bold(),
style(&elapsed_str).dim()
);
}
Err(e) => {
eprintln!(
"\n{} Failed in {}: {:#}",
style("FAIL").red().bold(),
style(&elapsed_str).dim(),
e
);
}
}
}
fn is_relevant_event(event: &Event) -> bool {
matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
)
}
pub fn should_ignore_path(path: &Path) -> bool {
for component in path.components() {
if let std::path::Component::Normal(name) = component {
let name_str = name.to_string_lossy();
for ignored in IGNORE_DIRS {
if name_str == *ignored {
return true;
}
}
}
}
false
}
pub fn matches_extensions(path: &Path, extensions: &[String]) -> bool {
if extensions.is_empty() {
return true;
}
match path.extension() {
Some(ext) => {
let ext_str = ext.to_string_lossy();
extensions.iter().any(|e| e == ext_str.as_ref())
}
None => false,
}
}
pub fn parse_extensions(input: &str) -> Vec<String> {
input
.split(',')
.map(|s| s.trim().trim_start_matches('.').to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn drain_events(
rx: &mpsc::Receiver<notify::Result<Event>>,
debounce: Duration,
extensions: &[String],
) {
let mut deadline = Instant::now() + debounce;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
match rx.recv_timeout(remaining) {
Ok(Ok(event)) => {
let dominated = event
.paths
.iter()
.all(|p| should_ignore_path(p) || !matches_extensions(p, extensions));
if dominated {
continue;
}
deadline = Instant::now() + debounce;
}
Ok(Err(_)) | Err(mpsc::RecvTimeoutError::Timeout) => break,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
}
fn format_duration(d: Duration) -> String {
let secs = d.as_secs();
let millis = d.subsec_millis();
if secs >= 60 {
let mins = secs / 60;
let remaining = secs % 60;
format!("{mins}m {remaining}.{millis:03}s")
} else if secs > 0 {
format!("{secs}.{millis:03}s")
} else {
format!("{millis}ms")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ignore_git_directory() {
assert!(should_ignore_path(Path::new("/project/.git/objects/abc")));
assert!(should_ignore_path(Path::new("some/path/.git/HEAD")));
}
#[test]
fn ignore_target_directory() {
assert!(should_ignore_path(Path::new("/project/target/debug/build")));
}
#[test]
fn ignore_node_modules() {
assert!(should_ignore_path(Path::new(
"/project/node_modules/foo/index.js"
)));
}
#[test]
fn ignore_fledge_directory() {
assert!(should_ignore_path(Path::new(
"/project/.fledge/lanes/foo.toml"
)));
}
#[test]
fn ignore_pycache() {
assert!(should_ignore_path(Path::new(
"/project/__pycache__/mod.pyc"
)));
}
#[test]
fn do_not_ignore_regular_path() {
assert!(!should_ignore_path(Path::new("/project/src/main.rs")));
assert!(!should_ignore_path(Path::new("src/lib.rs")));
}
#[test]
fn do_not_ignore_similar_names() {
assert!(!should_ignore_path(Path::new("/project/target_dir/foo")));
assert!(!should_ignore_path(Path::new(
"/project/.github/workflows/ci.yml"
)));
}
#[test]
fn extension_filter_matches_rs() {
let exts = vec!["rs".to_string(), "toml".to_string()];
assert!(matches_extensions(Path::new("src/main.rs"), &exts));
assert!(matches_extensions(Path::new("Cargo.toml"), &exts));
assert!(!matches_extensions(Path::new("README.md"), &exts));
}
#[test]
fn extension_filter_empty_matches_all() {
let exts: Vec<String> = vec![];
assert!(matches_extensions(Path::new("anything.xyz"), &exts));
assert!(matches_extensions(Path::new("no_ext"), &exts));
}
#[test]
fn extension_filter_no_ext_file() {
let exts = vec!["rs".to_string()];
assert!(!matches_extensions(Path::new("Makefile"), &exts));
}
#[test]
fn parse_extensions_basic() {
let result = parse_extensions("rs,toml");
assert_eq!(result, vec!["rs", "toml"]);
}
#[test]
fn parse_extensions_with_dots() {
let result = parse_extensions(".rs,.toml");
assert_eq!(result, vec!["rs", "toml"]);
}
#[test]
fn parse_extensions_with_spaces() {
let result = parse_extensions("rs , toml , py");
assert_eq!(result, vec!["rs", "toml", "py"]);
}
#[test]
fn parse_extensions_empty() {
let result = parse_extensions("");
assert!(result.is_empty());
}
#[test]
fn parse_extensions_single() {
let result = parse_extensions("rs");
assert_eq!(result, vec!["rs"]);
}
#[test]
fn format_duration_millis() {
let d = Duration::from_millis(42);
assert_eq!(format_duration(d), "42ms");
}
#[test]
fn format_duration_seconds() {
let d = Duration::from_millis(3456);
assert_eq!(format_duration(d), "3.456s");
}
#[test]
fn format_duration_minutes() {
let d = Duration::from_secs(125) + Duration::from_millis(100);
assert_eq!(format_duration(d), "2m 5.100s");
}
#[test]
fn format_duration_zero() {
let d = Duration::from_millis(0);
assert_eq!(format_duration(d), "0ms");
}
#[test]
fn ignore_dirs_list_is_complete() {
assert!(IGNORE_DIRS.contains(&".git"));
assert!(IGNORE_DIRS.contains(&"target"));
assert!(IGNORE_DIRS.contains(&"node_modules"));
assert!(IGNORE_DIRS.contains(&".fledge"));
assert!(IGNORE_DIRS.contains(&"__pycache__"));
}
#[test]
fn watch_options_defaults() {
let opts = WatchOptions {
name: "build".to_string(),
lane: false,
path: None,
extensions: vec![],
debounce_ms: 500,
clear: false,
};
assert_eq!(opts.name, "build");
assert!(!opts.lane);
assert!(opts.path.is_none());
assert!(opts.extensions.is_empty());
assert_eq!(opts.debounce_ms, 500);
assert!(!opts.clear);
}
#[test]
fn combined_ignore_and_extension_filter() {
let exts = vec!["rs".to_string()];
let ignored = Path::new("/project/target/debug/foo.rs");
assert!(should_ignore_path(ignored));
let ok = Path::new("/project/src/main.rs");
assert!(!should_ignore_path(ok));
assert!(matches_extensions(ok, &exts));
let no_match = Path::new("/project/src/README.md");
assert!(!should_ignore_path(no_match));
assert!(!matches_extensions(no_match, &exts));
}
}