use crate::dev::overlay::{DevOverlayHandle, Status};
use crate::{App, Error, Result};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct WatchOpts {
pub debounce: Duration,
pub overlay: Option<DevOverlayHandle>,
pub release: bool,
}
impl Default for WatchOpts {
fn default() -> Self {
Self {
debounce: Duration::from_millis(250),
overlay: None,
release: false,
}
}
}
pub fn watch_renderer<A: App>() -> Result {
watch_renderer_with_opts::<A>(WatchOpts::default())
}
pub fn watch_renderer_with_opts<A: App>(opts: WatchOpts) -> Result {
if let Some(h) = opts.overlay.clone() {
crate::dev::register_overlay(h);
}
let crates = discover_widget_crates()?;
if crates.is_empty() {
log::info!("plushie dev: no widget crates declared; running without watcher");
return crate::run::<A>();
}
log::info!("plushie dev: watching {} widget crate(s)", crates.len());
for c in &crates {
log::info!(" - {} at {}", c.name, c.root.display());
}
run_build(&opts);
spawn_watch_thread(crates, opts.clone());
crate::run::<A>()
}
#[derive(Debug, Clone)]
struct WidgetCrate {
name: String,
root: PathBuf,
}
fn discover_widget_crates() -> std::result::Result<Vec<WidgetCrate>, Error> {
let metadata = cargo_metadata::MetadataCommand::new()
.exec()
.map_err(|e| Error::InvalidSettings(format!("cargo metadata failed: {e}")))?;
let mut out = Vec::new();
for pkg in &metadata.packages {
if pkg
.metadata
.get("plushie")
.and_then(|v| v.get("widget"))
.is_none()
{
continue;
}
let manifest = PathBuf::from(pkg.manifest_path.clone());
let Some(root) = manifest.parent().map(Path::to_path_buf) else {
continue;
};
out.push(WidgetCrate {
name: pkg.name.to_string(),
root,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
fn spawn_watch_thread(crates: Vec<WidgetCrate>, opts: WatchOpts) {
std::thread::Builder::new()
.name("plushie-dev-watch".to_string())
.spawn(move || {
if let Err(e) = watch_loop(&crates, &opts) {
log::warn!("plushie dev: watcher stopped: {e}");
}
})
.expect("failed to spawn plushie-dev-watch thread");
}
fn watch_loop(
crates: &[WidgetCrate],
opts: &WatchOpts,
) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (tx, rx) = mpsc::channel::<notify::Result<Event>>();
let mut watcher: RecommendedWatcher = notify::recommended_watcher(tx)?;
for c in crates {
let src = c.root.join("src");
if src.is_dir() {
watcher.watch(&src, RecursiveMode::Recursive)?;
}
let manifest = c.root.join("Cargo.toml");
if manifest.is_file() {
watcher.watch(&c.root, RecursiveMode::NonRecursive)?;
}
}
let mut pending_since: Option<Instant> = None;
loop {
let deadline = pending_since.map(|t| {
let elapsed = t.elapsed();
if elapsed >= opts.debounce {
Duration::ZERO
} else {
opts.debounce - elapsed
}
});
let recv = match deadline {
Some(d) => rx.recv_timeout(d),
None => rx.recv().map_err(|_| mpsc::RecvTimeoutError::Disconnected),
};
match recv {
Ok(Ok(event)) => {
if is_rebuild_trigger(&event) {
pending_since = Some(Instant::now());
}
}
Ok(Err(e)) => {
log::warn!("plushie dev: watcher error: {e}");
}
Err(mpsc::RecvTimeoutError::Timeout) => {
pending_since = None;
run_build(opts);
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
return Ok(());
}
}
}
}
fn is_rebuild_trigger(event: &Event) -> bool {
matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
) && event.paths.iter().any(|p| is_rust_source_path(p))
}
fn is_rust_source_path(path: &Path) -> bool {
if path.components().any(|c| c.as_os_str() == "target") {
return false;
}
match path.extension().and_then(|e| e.to_str()) {
Some("rs") => true,
_ => path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == "Cargo.toml"),
}
}
fn run_build(opts: &WatchOpts) {
publish_status(opts, Status::Rebuilding, String::new());
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let mut cmd = std::process::Command::new(cargo);
cmd.arg("plushie").arg("build");
if opts.release {
cmd.arg("--release");
}
log::info!("plushie dev: running cargo plushie build");
let output = match cmd.output() {
Ok(o) => o,
Err(e) => {
let msg = format!("cargo plushie build failed to spawn: {e}");
log::warn!("plushie dev: {msg}");
publish_status(opts, Status::Failed, msg);
return;
}
};
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let combined = if stderr.is_empty() {
stdout.clone()
} else if stdout.is_empty() {
stderr.clone()
} else {
format!("{stdout}\n{stderr}")
};
eprint!("{stderr}");
if !stdout.is_empty() {
eprintln!("{stdout}");
}
if output.status.success() {
log::info!("plushie dev: rebuild succeeded");
publish_status(opts, Status::Success, combined);
crate::dev::send_control_signal(crate::dev::ControlSignal::SwapRenderer);
} else {
log::warn!("plushie dev: rebuild failed (status {:?})", output.status);
publish_status(opts, Status::Failed, combined);
}
}
fn publish_status(opts: &WatchOpts, status: Status, detail: String) {
let expanded = matches!(status, Status::Failed | Status::Frozen);
let success_at = matches!(status, Status::Success).then(std::time::Instant::now);
let overlay = crate::dev::RebuildingOverlay {
status,
detail,
expanded,
success_at,
};
if let Some(h) = &opts.overlay {
crate::dev::dev_overlay::handle_overlay_message(h, overlay);
} else {
crate::dev::publish_overlay(overlay);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rust_source_path_detects_rs_and_cargo_toml() {
assert!(is_rust_source_path(Path::new("/crate/src/lib.rs")));
assert!(is_rust_source_path(Path::new("/crate/Cargo.toml")));
assert!(!is_rust_source_path(Path::new("/crate/README.md")));
}
#[test]
fn rust_source_path_skips_target_dir() {
assert!(!is_rust_source_path(Path::new(
"/crate/target/debug/foo.rs"
)));
assert!(!is_rust_source_path(Path::new(
"/crate/target/plushie-renderer/src/main.rs"
)));
}
#[test]
fn default_debounce_is_250ms() {
let o = WatchOpts::default();
assert_eq!(o.debounce, Duration::from_millis(250));
assert!(o.overlay.is_none());
assert!(!o.release);
}
}