use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchConfig {
pub watch_paths: Vec<PathBuf>,
#[serde(default)]
pub include_patterns: Vec<String>,
#[serde(default)]
pub exclude_patterns: Vec<String>,
#[serde(default = "default_debounce")]
pub debounce_ms: u64,
pub piece_name: String,
#[serde(default = "default_true")]
pub full_execution: bool,
#[serde(default)]
pub clear_screen: bool,
#[serde(default = "default_max_consecutive")]
pub max_consecutive_runs: u32,
}
fn default_debounce() -> u64 {
500
}
fn default_true() -> bool {
true
}
fn default_max_consecutive() -> u32 {
10
}
impl Default for WatchConfig {
fn default() -> Self {
Self {
watch_paths: vec![PathBuf::from(".")],
include_patterns: vec!["**/*.rs".to_string(), "**/*.ts".to_string()],
exclude_patterns: vec![
"**/target/**".to_string(),
"**/node_modules/**".to_string(),
"**/.git/**".to_string(),
],
debounce_ms: default_debounce(),
piece_name: "default".to_string(),
full_execution: true,
clear_screen: false,
max_consecutive_runs: default_max_consecutive(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileChange {
pub path: PathBuf,
pub change_type: ChangeType,
pub detected_at: std::time::SystemTime,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChangeType {
Created,
Modified,
Deleted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum WatchState {
Idle,
Debouncing,
Executing,
Paused,
Stopped,
}
pub struct WatchController {
config: WatchConfig,
state: WatchState,
pending_changes: Vec<FileChange>,
last_execution: Option<Instant>,
consecutive_runs: u32,
debounce_start: Option<Instant>,
known_files: std::collections::HashMap<PathBuf, std::time::SystemTime>,
}
impl WatchController {
pub fn new(config: WatchConfig) -> Self {
Self {
config,
state: WatchState::Idle,
pending_changes: Vec::new(),
last_execution: None,
consecutive_runs: 0,
debounce_start: None,
known_files: std::collections::HashMap::new(),
}
}
pub fn state(&self) -> &WatchState {
&self.state
}
pub fn record_change(&mut self, change: FileChange) {
if self.state == WatchState::Stopped {
return;
}
let path_str = change.path.to_string_lossy().to_string();
if !self.config.exclude_patterns.is_empty()
&& self
.config
.exclude_patterns
.iter()
.any(|p| simple_glob_match(p, &path_str))
{
debug!("Excluded change: {}", path_str);
return;
}
if !self.config.include_patterns.is_empty()
&& !self
.config
.include_patterns
.iter()
.any(|p| simple_glob_match(p, &path_str))
{
debug!("Not included: {}", path_str);
return;
}
info!(
"Change detected: {:?} {:?}",
change.change_type, change.path
);
self.pending_changes.push(change);
self.debounce_start = Some(Instant::now());
self.state = WatchState::Debouncing;
}
pub fn poll(&mut self) -> Option<Vec<FileChange>> {
if self.state != WatchState::Debouncing {
return None;
}
if let Some(start) = self.debounce_start {
let debounce = Duration::from_millis(self.config.debounce_ms);
if start.elapsed() >= debounce {
if self.consecutive_runs >= self.config.max_consecutive_runs {
warn!(
"Max consecutive runs ({}) reached, pausing watch",
self.config.max_consecutive_runs
);
self.state = WatchState::Paused;
return None;
}
let changes: Vec<FileChange> = self.pending_changes.drain(..).collect();
self.state = WatchState::Executing;
self.last_execution = Some(Instant::now());
self.consecutive_runs += 1;
self.debounce_start = None;
return Some(changes);
}
}
None
}
pub fn execution_complete(&mut self) {
self.state = WatchState::Idle;
}
pub fn resume(&mut self) {
self.consecutive_runs = 0;
self.state = WatchState::Idle;
info!("Watch mode resumed");
}
pub fn pause(&mut self) {
self.state = WatchState::Paused;
}
pub fn stop(&mut self) {
self.state = WatchState::Stopped;
self.pending_changes.clear();
}
pub fn pending_summary(&self) -> WatchSummary {
let unique_files: HashSet<&PathBuf> =
self.pending_changes.iter().map(|c| &c.path).collect();
WatchSummary {
pending_count: self.pending_changes.len(),
unique_files: unique_files.len(),
consecutive_runs: self.consecutive_runs,
state: self.state.clone(),
}
}
pub fn scan_for_changes(&mut self) -> Result<Vec<FileChange>> {
let mut changes = Vec::new();
let watch_paths = self.config.watch_paths.clone();
for watch_path in &watch_paths {
if !watch_path.exists() {
continue;
}
self.scan_directory(watch_path, &mut changes)?;
}
for change in &changes {
self.record_change(change.clone());
}
Ok(changes)
}
fn scan_directory(&mut self, dir: &Path, changes: &mut Vec<FileChange>) -> Result<()> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
warn!("Cannot read directory {}: {}", dir.display(), e);
return Ok(());
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let path_str = path.to_string_lossy().to_string();
if self
.config
.exclude_patterns
.iter()
.any(|p| simple_glob_match(p, &path_str))
{
continue;
}
self.scan_directory(&path, changes)?;
} else if path.is_file()
&& let Ok(metadata) = std::fs::metadata(&path)
&& let Ok(modified) = metadata.modified()
{
match self.known_files.get(&path) {
Some(known_time) if *known_time != modified => {
changes.push(FileChange {
path: path.clone(),
change_type: ChangeType::Modified,
detected_at: std::time::SystemTime::now(),
});
}
None => {
changes.push(FileChange {
path: path.clone(),
change_type: ChangeType::Created,
detected_at: std::time::SystemTime::now(),
});
}
_ => {} }
self.known_files.insert(path, modified);
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchSummary {
pub pending_count: usize,
pub unique_files: usize,
pub consecutive_runs: u32,
pub state: WatchState,
}
fn simple_glob_match(pattern: &str, path: &str) -> bool {
if pattern.contains("**") {
let segments: Vec<&str> = pattern.split("**").collect();
let segments: Vec<&str> = segments.iter().map(|s| s.trim_matches('/')).collect();
let mut remaining = path;
for seg in &segments {
if seg.is_empty() {
continue;
}
if seg.contains('*') {
let filename = path.rsplit('/').next().unwrap_or(path);
if !simple_star_match(seg, filename) {
return false;
}
} else if let Some(pos) = remaining.find(seg) {
remaining = &remaining[pos + seg.len()..];
} else {
return false;
}
}
return true;
}
if pattern.contains('*') {
return simple_star_match(pattern, path);
}
path == pattern || path.ends_with(pattern)
}
fn simple_star_match(pattern: &str, text: &str) -> bool {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
return text.starts_with(parts[0]) && text.ends_with(parts[1]);
}
let mut remaining = text;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !remaining.starts_with(part) {
return false;
}
remaining = &remaining[part.len()..];
} else if i == parts.len() - 1 {
if !remaining.ends_with(part) {
return false;
}
} else if let Some(pos) = remaining.find(part) {
remaining = &remaining[pos + part.len()..];
} else {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_watch_config_default() {
let config = WatchConfig::default();
assert_eq!(config.debounce_ms, 500);
assert!(config.full_execution);
assert_eq!(config.max_consecutive_runs, 10);
}
#[test]
fn test_record_and_poll() {
let config = WatchConfig {
debounce_ms: 0, ..WatchConfig::default()
};
let mut controller = WatchController::new(config);
controller.record_change(FileChange {
path: PathBuf::from("src/main.rs"),
change_type: ChangeType::Modified,
detected_at: std::time::SystemTime::now(),
});
assert_eq!(*controller.state(), WatchState::Debouncing);
std::thread::sleep(Duration::from_millis(1));
let changes = controller.poll();
assert!(changes.is_some());
assert_eq!(changes.unwrap().len(), 1);
assert_eq!(*controller.state(), WatchState::Executing);
}
#[test]
fn test_max_consecutive_runs() {
let config = WatchConfig {
debounce_ms: 0,
max_consecutive_runs: 2,
..WatchConfig::default()
};
let mut controller = WatchController::new(config);
controller.record_change(FileChange {
path: PathBuf::from("a.rs"),
change_type: ChangeType::Modified,
detected_at: std::time::SystemTime::now(),
});
std::thread::sleep(Duration::from_millis(1));
assert!(controller.poll().is_some());
controller.execution_complete();
controller.record_change(FileChange {
path: PathBuf::from("b.rs"),
change_type: ChangeType::Modified,
detected_at: std::time::SystemTime::now(),
});
std::thread::sleep(Duration::from_millis(1));
assert!(controller.poll().is_some());
controller.execution_complete();
controller.record_change(FileChange {
path: PathBuf::from("c.rs"),
change_type: ChangeType::Modified,
detected_at: std::time::SystemTime::now(),
});
std::thread::sleep(Duration::from_millis(1));
assert!(controller.poll().is_none());
assert_eq!(*controller.state(), WatchState::Paused);
}
#[test]
fn test_resume_after_pause() {
let config = WatchConfig {
debounce_ms: 0,
max_consecutive_runs: 1,
..WatchConfig::default()
};
let mut controller = WatchController::new(config);
controller.record_change(FileChange {
path: PathBuf::from("a.rs"),
change_type: ChangeType::Modified,
detected_at: std::time::SystemTime::now(),
});
std::thread::sleep(Duration::from_millis(1));
controller.poll();
controller.execution_complete();
controller.record_change(FileChange {
path: PathBuf::from("b.rs"),
change_type: ChangeType::Modified,
detected_at: std::time::SystemTime::now(),
});
std::thread::sleep(Duration::from_millis(1));
controller.poll();
controller.resume();
assert_eq!(*controller.state(), WatchState::Idle);
assert_eq!(controller.consecutive_runs, 0);
}
#[test]
fn test_stop() {
let mut controller = WatchController::new(WatchConfig::default());
controller.stop();
assert_eq!(*controller.state(), WatchState::Stopped);
controller.record_change(FileChange {
path: PathBuf::from("a.rs"),
change_type: ChangeType::Modified,
detected_at: std::time::SystemTime::now(),
});
assert_eq!(*controller.state(), WatchState::Stopped);
}
#[test]
fn test_exclude_patterns() {
let config = WatchConfig {
debounce_ms: 0,
exclude_patterns: vec!["**/target/**".to_string()],
include_patterns: vec![], ..WatchConfig::default()
};
let mut controller = WatchController::new(config);
controller.record_change(FileChange {
path: PathBuf::from("target/debug/main"),
change_type: ChangeType::Modified,
detected_at: std::time::SystemTime::now(),
});
assert_eq!(controller.pending_changes.len(), 0);
}
#[test]
fn test_glob_match() {
assert!(simple_glob_match("**/*.rs", "src/main.rs"));
assert!(simple_glob_match("**/*.rs", "deep/nested/file.rs"));
assert!(!simple_glob_match("**/*.rs", "src/main.ts"));
assert!(simple_glob_match("**/target/**", "target/debug/build"));
assert!(simple_glob_match("*.txt", "readme.txt"));
}
#[test]
fn test_pending_summary() {
let config = WatchConfig {
debounce_ms: 10000, include_patterns: vec![],
exclude_patterns: vec![],
..WatchConfig::default()
};
let mut controller = WatchController::new(config);
controller.record_change(FileChange {
path: PathBuf::from("a.rs"),
change_type: ChangeType::Modified,
detected_at: std::time::SystemTime::now(),
});
controller.record_change(FileChange {
path: PathBuf::from("a.rs"),
change_type: ChangeType::Modified,
detected_at: std::time::SystemTime::now(),
});
controller.record_change(FileChange {
path: PathBuf::from("b.rs"),
change_type: ChangeType::Created,
detected_at: std::time::SystemTime::now(),
});
let summary = controller.pending_summary();
assert_eq!(summary.pending_count, 3);
assert_eq!(summary.unique_files, 2);
}
}