use crate::cli::args::WatchCommand;
use crate::cli::Commands;
use crate::models::{Error, Result};
use notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
use tracing::info;
#[cfg(test)]
#[path = "watch_commands_tests.rs"]
mod tests;
pub(crate) fn watch_command(command: Commands) -> Result<()> {
match command {
Commands::Watch {
paths,
command: watch_cmd,
debounce,
extensions,
clear,
fail_fast,
} => run_watch(&paths, &watch_cmd, debounce, &extensions, clear, fail_fast),
_ => unreachable!("watch_command called with non-watch command"),
}
}
fn parse_extensions(ext_str: &str) -> Vec<String> {
ext_str
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect()
}
fn matches_extensions(path: &Path, extensions: &[String]) -> bool {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
let name_lower = name.to_lowercase();
for ext in extensions {
if name_lower == *ext {
return true;
}
}
}
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let ext_lower = ext.to_lowercase();
extensions.contains(&ext_lower)
} else {
false
}
}
fn collect_files(paths: &[PathBuf], extensions: &[String]) -> Vec<PathBuf> {
let mut files = Vec::new();
for path in paths {
if path.is_file() {
if matches_extensions(path, extensions) {
files.push(path.clone());
}
} else if path.is_dir() {
if let Ok(entries) = glob::glob(&format!("{}/**/*", path.display())) {
for entry in entries.flatten() {
if entry.is_file() && matches_extensions(&entry, extensions) {
files.push(entry);
}
}
}
}
}
files.sort();
files.dedup();
files
}
fn bashrs_exe() -> PathBuf {
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("bashrs"))
}
fn command_name(watch_cmd: &WatchCommand) -> &'static str {
match watch_cmd {
WatchCommand::Lint => "lint",
WatchCommand::Format => "format",
WatchCommand::Test => "test",
WatchCommand::Score => "score",
WatchCommand::SafetyCheck => "safety-check",
WatchCommand::Audit => "audit",
}
}
fn run_subcommand(watch_cmd: &WatchCommand, files: &[PathBuf]) -> bool {
if files.is_empty() {
eprintln!(" No matching files found.");
return true;
}
let exe = bashrs_exe();
let cmd_name = command_name(watch_cmd);
let mut cmd = std::process::Command::new(&exe);
cmd.arg(cmd_name);
if matches!(watch_cmd, WatchCommand::Format) {
cmd.arg("--check");
}
for file in files {
cmd.arg(file);
}
match cmd.status() {
Ok(status) => {
if status.success() {
println!("\x1b[32m \u{2713} All checks passed\x1b[0m");
true
} else {
false
}
}
Err(e) => {
eprintln!("\x1b[31m \u{2717} Failed to run bashrs {cmd_name}: {e}\x1b[0m");
false
}
}
}
fn print_header(watch_cmd: &WatchCommand, file_count: usize) {
let now = chrono::Local::now();
println!(
"\x1b[1m[{time}] bashrs watch \u{2192} {cmd} ({n} file{s})\x1b[0m",
time = now.format("%H:%M:%S"),
cmd = command_name(watch_cmd),
n = file_count,
s = if file_count == 1 { "" } else { "s" },
);
}
fn run_watch(
paths: &[PathBuf],
watch_cmd: &WatchCommand,
debounce_ms: u64,
extensions: &str,
clear: bool,
fail_fast: bool,
) -> Result<()> {
let exts = parse_extensions(extensions);
let files = collect_files(paths, &exts);
println!(
"\x1b[1;36mbashrs watch\x1b[0m v{ver}",
ver = env!("CARGO_PKG_VERSION")
);
println!(
" Watching {n} path{s} for changes (extensions: {exts})",
n = paths.len(),
s = if paths.len() == 1 { "" } else { "s" },
exts = extensions,
);
println!(
" Debounce: {debounce_ms}ms | Command: \x1b[1m{cmd}\x1b[0m",
cmd = command_name(watch_cmd),
);
println!(" Press Ctrl+C to stop.\n");
if clear {
print!("\x1b[2J\x1b[H");
}
print_header(watch_cmd, files.len());
let ok = run_subcommand(watch_cmd, &files);
if fail_fast && !ok {
return Err(Error::Internal("Watch: initial run failed with --fail-fast".into()));
}
println!();
let (tx, rx) = mpsc::channel();
let debounce_duration = Duration::from_millis(debounce_ms);
let mut debouncer = new_debouncer(debounce_duration, tx).map_err(|e| {
Error::Internal(format!("Failed to create file watcher: {e}"))
})?;
for path in paths {
let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
info!("Watching path: {}", canonical.display());
debouncer
.watcher()
.watch(&canonical, RecursiveMode::Recursive)
.map_err(|e| {
Error::Internal(format!("Failed to watch {}: {e}", path.display()))
})?;
}
loop {
match rx.recv() {
Ok(Ok(events)) => {
let mut changed: Vec<PathBuf> = events
.iter()
.filter(|e| e.kind == DebouncedEventKind::Any)
.map(|e| e.path.clone())
.filter(|p| matches_extensions(p, &exts))
.collect();
while let Ok(Ok(more_events)) = rx.try_recv() {
for ev in more_events {
if ev.kind == DebouncedEventKind::Any
&& matches_extensions(&ev.path, &exts)
{
changed.push(ev.path);
}
}
}
if changed.is_empty() {
continue;
}
changed.sort();
changed.dedup();
if clear {
print!("\x1b[2J\x1b[H");
}
let all_files = collect_files(paths, &exts);
print_header(watch_cmd, all_files.len());
for p in &changed {
println!(
" \x1b[33m\u{25cf}\x1b[0m {}",
p.strip_prefix(std::env::current_dir().unwrap_or_default())
.unwrap_or(p)
.display()
);
}
let ok = run_subcommand(watch_cmd, &all_files);
if fail_fast && !ok {
return Err(Error::Internal(
"Watch: run failed with --fail-fast".into(),
));
}
println!();
}
Ok(Err(errs)) => {
eprintln!("\x1b[31mWatch error: {errs:?}\x1b[0m");
}
Err(e) => {
return Err(Error::Internal(format!("Watch channel closed: {e}")));
}
}
}
}