use ggen_core::utils::error::Result;
use notify::{Event, RecursiveMode, Watcher};
use notify_debouncer_full::{new_debouncer, DebounceEventResult, Debouncer, FileIdMap};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{channel, Receiver};
use std::time::Duration;
use super::planner::{GenerationPlan, GenerationPlanner};
use super::resolver::ConventionResolver;
pub struct ProjectWatcher {
debouncer: Debouncer<notify::RecommendedWatcher, FileIdMap>,
receiver: Receiver<DebounceEventResult>,
resolver: ConventionResolver,
planner: GenerationPlanner,
#[allow(dead_code)]
debounce_ms: u64,
}
impl ProjectWatcher {
pub fn new(project_root: PathBuf) -> Result<Self> {
Self::with_debounce(project_root, 300)
}
fn with_debounce(project_root: PathBuf, debounce_ms: u64) -> Result<Self> {
let (tx, rx) = channel();
let debouncer = new_debouncer(
Duration::from_millis(debounce_ms),
None,
move |result: DebounceEventResult| {
let _ = tx.send(result);
},
)
.map_err(|e| {
ggen_core::utils::error::Error::new(&format!("Failed to create file watcher: {}", e))
})?;
let resolver = ConventionResolver::new(project_root.clone());
let conventions = resolver.discover()?;
let planner = GenerationPlanner::new(conventions.clone());
Ok(Self {
debouncer,
receiver: rx,
resolver,
planner,
debounce_ms,
})
}
pub fn watch(&mut self) -> Result<()> {
let conventions = self.resolver.discover()?;
let watched_dirs = vec![&conventions.rdf_dir, &conventions.templates_dir];
for dir in watched_dirs {
if dir.exists() {
self.debouncer
.watcher()
.watch(dir, RecursiveMode::Recursive)
.map_err(|e| {
ggen_core::utils::error::Error::new(&format!(
"Failed to watch directory {}: {}",
dir.display(),
e
))
})?;
}
}
Ok(())
}
pub fn stop(self) -> Result<()> {
drop(self.debouncer);
Ok(())
}
pub fn process_events(&mut self) -> Result<Vec<GenerationPlan>> {
let mut plans = Vec::new();
while let Ok(result) = self.receiver.try_recv() {
match result {
Ok(events) => {
for event in events {
if let Some(plan) = self.handle_change(event.event)? {
plans.push(plan);
}
}
}
Err(errors) => {
for error in errors {
log::error!("Watch error: {:?}", error);
}
}
}
}
Ok(plans)
}
fn handle_change(&self, event: Event) -> Result<Option<GenerationPlan>> {
use notify::EventKind;
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
let has_changed_files = event.paths.iter().any(|p| self.should_process_file(p));
if !has_changed_files {
return Ok(None);
}
let plan = self.planner.plan()?;
Ok(Some(plan))
}
_ => Ok(None),
}
}
fn should_process_file(&self, path: &Path) -> bool {
if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with('.'))
{
return false;
}
if path.extension().and_then(|e| e.to_str()) == Some("tmp") {
return false;
}
true
}
#[allow(dead_code)]
fn find_affected_templates(&self, _changed: &[PathBuf]) -> Vec<String> {
match self.resolver.discover() {
Ok(conventions) => conventions.templates.keys().cloned().collect(),
Err(e) => {
log::warn!(
"Failed to discover conventions for affected templates: {}",
e
);
Vec::new()
}
}
}
pub fn regenerate_template(&self, template: &str) -> Result<()> {
log::info!("Regenerating template: {}", template);
Ok(())
}
pub fn resolver(&self) -> &ConventionResolver {
&self.resolver
}
pub fn planner(&self) -> &GenerationPlanner {
&self.planner
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_watcher_creation() {
let temp_dir = TempDir::new().unwrap();
let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf());
assert!(watcher.is_ok());
}
#[test]
fn test_watcher_with_debounce() {
let temp_dir = TempDir::new().unwrap();
let watcher = ProjectWatcher::with_debounce(temp_dir.path().to_path_buf(), 500);
assert!(watcher.is_ok());
}
#[test]
fn test_should_process_file() {
let temp_dir = TempDir::new().unwrap();
let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
assert!(watcher.should_process_file(&PathBuf::from("domain/test.yaml")));
assert!(watcher.should_process_file(&PathBuf::from("templates/test.tera")));
assert!(!watcher.should_process_file(&PathBuf::from(".hidden")));
assert!(!watcher.should_process_file(&PathBuf::from("test.tmp")));
}
#[test]
fn test_watch_and_stop() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir_all(temp_dir.path().join("domain")).unwrap();
fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
let mut watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
assert!(watcher.watch().is_ok());
assert!(watcher.stop().is_ok());
}
#[test]
fn test_process_events() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir_all(temp_dir.path().join("domain")).unwrap();
let mut watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
watcher.watch().unwrap();
let plans = watcher.process_events().unwrap();
assert_eq!(plans.len(), 0);
}
#[test]
fn test_regenerate_template() {
let temp_dir = TempDir::new().unwrap();
let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
assert!(watcher.regenerate_template("test_template").is_ok());
}
#[test]
fn test_find_affected_templates() {
let temp_dir = TempDir::new().unwrap();
let watcher = ProjectWatcher::new(temp_dir.path().to_path_buf()).unwrap();
let changed = vec![temp_dir.path().join("domain/test.yaml")];
let affected = watcher.find_affected_templates(&changed);
assert_eq!(affected.len(), 0);
}
}