use std::{
path::{Path, PathBuf},
sync::mpsc,
time::Duration,
};
use notify::RecursiveMode;
use notify_debouncer_full::new_debouncer;
use crate::{
error::{AppError, Result},
sync::{sync, SyncOptions, SyncReport},
};
const IGNORED_DIRS: [&str; 2] = [".ggen-v2", ".git"];
const DEBOUNCE_WINDOW: Duration = Duration::from_millis(500);
pub fn watch(root: &Path, dry_run: bool) -> Result<()> {
watch_loop(root, dry_run, |_| {})
}
fn watch_loop(root: &Path, dry_run: bool, mut on_sync: impl FnMut(&SyncReport)) -> Result<()> {
let initial = sync(
root,
SyncOptions {
dry_run,
..Default::default()
},
)
.map_err(|e| AppError::fm_watch(1, format!("initial sync failed: {e}")))?;
eprintln!(
"ggen sync: {} written, {} skipped",
initial.written.len(),
initial.skipped.len()
);
on_sync(&initial);
let (tx, rx) = mpsc::channel();
let mut debouncer = new_debouncer(DEBOUNCE_WINDOW, None, tx)
.map_err(|e| AppError::fm_watch(2, format!("failed to create filesystem watcher: {e}")))?;
debouncer
.watch(root, RecursiveMode::Recursive)
.map_err(|e| AppError::fm_watch(2, format!("failed to watch `{}`: {e}", root.display())))?;
eprintln!(
"watching {} for changes... (Ctrl-C to stop)",
root.display()
);
for result in rx {
let events = match result {
Ok(events) => events,
Err(errors) => {
for e in errors {
eprintln!("ggen sync: watch error: {e}");
}
continue;
}
};
let paths: Vec<PathBuf> = events.iter().flat_map(|e| e.paths.clone()).collect();
if should_ignore(root, &paths) {
continue;
}
match sync(
root,
SyncOptions {
dry_run,
..Default::default()
},
) {
Ok(report) => {
eprintln!(
"ggen sync: {} written, {} skipped",
report.written.len(),
report.skipped.len()
);
on_sync(&report);
}
Err(e) => {
eprintln!("ggen sync: {e}");
}
}
}
Ok(())
}
fn should_ignore(root: &Path, paths: &[PathBuf]) -> bool {
if paths.is_empty() {
return true;
}
let ignored: Vec<PathBuf> = IGNORED_DIRS.iter().map(|d| root.join(d)).collect();
paths
.iter()
.all(|p| ignored.iter().any(|dir| p.starts_with(dir)))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::{
sync::mpsc::{RecvTimeoutError, Sender},
thread,
};
use tempfile::TempDir;
use super::*;
#[test]
fn should_ignore_true_for_receipt_and_git_paths_only() {
let root = PathBuf::from("/proj");
let paths = vec![
root.join(".ggen-v2/receipt.json"),
root.join(".ggen-v2/receipt-log.jsonl"),
root.join(".git/index"),
];
assert!(should_ignore(&root, &paths));
}
#[test]
fn should_ignore_false_when_a_real_path_is_present() {
let root = PathBuf::from("/proj");
let paths = vec![
root.join(".ggen-v2/receipt.json"),
root.join("templates/foo.tmpl"),
];
assert!(!should_ignore(&root, &paths));
}
#[test]
fn should_ignore_true_for_empty_batch() {
let root = PathBuf::from("/proj");
assert!(should_ignore(&root, &[]));
}
#[test]
fn watch_loop_resyncs_on_template_change() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().to_path_buf();
seed_fixture_project(&root);
let (tx, rx): (Sender<()>, _) = mpsc::channel();
let watch_root = root.clone();
thread::spawn(move || {
let _ = watch_loop(&watch_root, false, move |_report| {
let _ = tx.send(());
});
});
rx.recv_timeout(Duration::from_secs(5))
.expect("initial sync did not complete within 5s");
let deadline = std::time::Instant::now() + Duration::from_secs(30);
loop {
std::fs::write(
root.join("templates/greeting.tmpl"),
"---\nto: out/greeting.txt\nforce: true\n---\nhello again\n",
)
.expect("edit template");
match rx.recv_timeout(Duration::from_millis(500)) {
Ok(()) => break,
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => {}
Err(RecvTimeoutError::Timeout) => {
panic!("watch_loop did not observe any template edit within 30s")
}
Err(RecvTimeoutError::Disconnected) => {
panic!("watch_loop's on_sync sender disconnected before re-sync was observed")
}
}
}
}
fn seed_fixture_project(root: &Path) {
std::fs::write(
root.join("ggen.toml"),
r#"
[project]
name = "watch-fixture"
[ontology]
source = "ontology.ttl"
[templates]
dir = "templates"
"#,
)
.expect("write ggen.toml");
std::fs::write(
root.join("ontology.ttl"),
"@prefix ex: <http://example.org/> .\nex:thing ex:name \"world\" .\n",
)
.expect("write ontology.ttl");
std::fs::create_dir_all(root.join("templates")).expect("mkdir templates");
std::fs::write(
root.join("templates/greeting.tmpl"),
"---\nto: out/greeting.txt\nforce: true\n---\nhello\n",
)
.expect("write template");
}
}