use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, RecvError, RecvTimeoutError, channel};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use notify::{Event, RecursiveMode, Watcher};
use super::pipeline::{ReloadOutcome, rebuild_repackage_reload};
use crate::deploy::DeployTarget;
const GLEAM_EXTENSION: &str = "gleam";
pub struct WatchSession<'a> {
pub project_root: &'a Path,
pub gleam_path: &'a Path,
pub target: &'a DeployTarget,
pub debounce: Option<Duration>,
}
pub async fn watch(session: &WatchSession<'_>) -> Result<()> {
let watch_root = source_root(session.project_root);
let (events_tx, events_rx) = channel::<notify::Result<Event>>();
let mut watcher = notify::recommended_watcher(move |event| {
let _ = events_tx.send(event);
})
.context("failed to create the filesystem watcher")?;
watcher
.watch(&watch_root, RecursiveMode::Recursive)
.with_context(|| format!("failed to watch {}", watch_root.display()))?;
println!(
"aion dev: priming {} with the current source",
session.project_root.display()
);
run_cycle(session).await;
println!(
"aion dev: watching {} for changes (Ctrl-C to stop)",
watch_root.display()
);
watch_loop(session, &events_rx).await
}
async fn watch_loop(
session: &WatchSession<'_>,
events_rx: &Receiver<notify::Result<Event>>,
) -> Result<()> {
loop {
match events_rx.recv() {
Ok(event) => {
if !is_relevant(&event) {
continue;
}
}
Err(RecvError) => {
return Ok(());
}
}
if let Some(window) = session.debounce {
drain_debounce_window(events_rx, window);
}
run_cycle(session).await;
}
}
fn drain_debounce_window(events_rx: &Receiver<notify::Result<Event>>, window: Duration) {
let deadline = Instant::now() + window;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return;
}
match events_rx.recv_timeout(remaining) {
Ok(_) | Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => return,
}
if Instant::now() >= deadline {
return;
}
}
}
async fn run_cycle(session: &WatchSession<'_>) {
match rebuild_repackage_reload(session.project_root, session.gleam_path, session.target).await {
Ok(outcome) => report_reload(&outcome),
Err(error) => {
eprintln!("aion dev: rebuild failed: {error:#}");
}
}
}
fn report_reload(outcome: &ReloadOutcome) {
if outcome.freshly_loaded {
let routing = if outcome.route_changed {
"fresh runs use it; in-flight runs stay pinned"
} else {
"loaded but not routed; existing route still serves fresh runs"
};
println!(
"aion dev: hot-loaded {} @ {} from {} ({routing})",
outcome.workflow_type,
outcome.content_hash,
outcome.archive_path.display()
);
} else {
println!(
"aion dev: {} @ {} already loaded (no change)",
outcome.workflow_type, outcome.content_hash
);
}
}
fn source_root(project_root: &Path) -> PathBuf {
let src = project_root.join("src");
if src.is_dir() {
src
} else {
project_root.to_path_buf()
}
}
fn is_relevant(event: ¬ify::Result<Event>) -> bool {
let Ok(event) = event else {
return true;
};
if !is_content_kind(event.kind) {
return false;
}
event.paths.iter().any(|path| is_gleam_source(path))
}
fn is_content_kind(kind: notify::EventKind) -> bool {
use notify::EventKind;
matches!(
kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) | EventKind::Any
)
}
fn is_gleam_source(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case(GLEAM_EXTENSION))
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use notify::event::{CreateKind, EventAttributes, ModifyKind};
use notify::{Event, EventKind};
use super::{is_gleam_source, is_relevant, source_root};
fn event(kind: EventKind, path: &str) -> Event {
Event {
kind,
paths: vec![PathBuf::from(path)],
attrs: EventAttributes::default(),
}
}
fn modify_event(path: &str) -> Event {
event(EventKind::Modify(ModifyKind::Any), path)
}
#[test]
fn gleam_sources_are_recognised_case_insensitively() {
assert!(is_gleam_source(Path::new("src/order.gleam")));
assert!(is_gleam_source(Path::new("src/Order.GLEAM")));
assert!(!is_gleam_source(Path::new("src/order.erl")));
assert!(!is_gleam_source(Path::new("workflow.toml")));
}
#[test]
fn relevant_only_for_source_content_changes() {
assert!(is_relevant(&Ok(modify_event("src/order.gleam"))));
assert!(is_relevant(&Ok(event(
EventKind::Create(CreateKind::File),
"src/new.gleam"
))));
assert!(!is_relevant(&Ok(modify_event(
"build/dev/erlang/order.beam"
))));
assert!(!is_relevant(&Ok(modify_event("README.md"))));
}
#[test]
fn watcher_errors_force_a_rebuild() {
let error: notify::Result<Event> = Err(notify::Error::generic("event queue overflowed"));
assert!(is_relevant(&error));
}
#[test]
fn source_root_prefers_src_then_falls_back_to_root() -> Result<(), Box<dyn std::error::Error>> {
let temp = tempfile::tempdir()?;
assert_eq!(source_root(temp.path()), temp.path());
std::fs::create_dir(temp.path().join("src"))?;
assert_eq!(source_root(temp.path()), temp.path().join("src"));
Ok(())
}
}