use anyhow::Result;
use notify::{EventKind, RecursiveMode, Watcher};
use std::collections::HashMap;
use std::io::{BufRead, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant, SystemTime};
const IGNORED: &[&str] = &["target", ".git", ".vscode", ".idea"];
const LOOP_TIMEOUT: Duration = Duration::from_millis(400);
const DEBOUNCE_WINDOW: Duration = Duration::from_millis(50);
pub fn is_ignored(path: &Path) -> bool {
path.components().any(|c| match c {
std::path::Component::Normal(name) => {
let n = name.to_string_lossy();
n.starts_with('.') || IGNORED.contains(&n.as_ref())
}
_ => false,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeKind {
StyleOnly,
TemplateOnly,
Script,
}
impl ChangeKind {
pub fn merge(self, other: ChangeKind) -> ChangeKind {
use ChangeKind::*;
match (self, other) {
(Script, _) | (_, Script) => Script,
(TemplateOnly, _) | (_, TemplateOnly) => TemplateOnly,
(StyleOnly, StyleOnly) => StyleOnly,
}
}
}
pub fn classify_change(before: &str, after: &str) -> ChangeKind {
use ChangeKind::*;
let (Ok(b), Ok(a)) = (velox_sfc::parse_sfc(before), velox_sfc::parse_sfc(after)) else {
return Script;
};
fn block(s: &velox_sfc::Sfc) -> (Option<&str>, Option<&str>, Option<&str>, Option<&str>) {
(
s.style.as_ref().map(|b| b.content.as_str()),
s.template.as_ref().map(|b| b.content.as_str()),
s.script_setup.as_ref().map(|b| b.content.as_str()),
s.script.as_ref().map(|b| b.content.as_str()),
)
}
let (b_style, b_tpl, b_setup, b_script) = block(&b);
let (a_style, a_tpl, a_setup, a_script) = block(&a);
let style = b_style != a_style;
let template = b_tpl != a_tpl;
let script = b_setup != a_setup || b_script != a_script;
match (style, template, script) {
(true, false, false) => StyleOnly,
(false, true, false) => TemplateOnly,
_ => Script,
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ChangeDebouncer {
deadline: Option<Instant>,
}
impl ChangeDebouncer {
pub fn new() -> Self {
Self::default()
}
pub fn record(&mut self, now: Instant, window: Duration) {
self.deadline = Some(now + window);
}
pub fn is_pending(&self) -> bool {
self.deadline.is_some()
}
pub fn take_if_due(&mut self, now: Instant) -> bool {
match self.deadline {
Some(deadline) if now >= deadline => {
self.deadline = None;
true
}
_ => false,
}
}
pub fn wait(&self, now: Instant) -> Option<Duration> {
let deadline = self.deadline?;
Some(
deadline
.saturating_duration_since(now)
.max(Duration::from_millis(1)),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WatchReaction {
Report { message: String, fatal: bool },
}
pub fn react_to_watch_error(message: &str) -> WatchReaction {
WatchReaction::Report {
message: format!(
"{message}\n\
\x20 The kernel's inotify watch limit is exhausted, so files may no longer be\n\
\x20 detected. Raise it (Linux):\n\
\x20 sudo sysctl -w fs.inotify.max_user_watches=524288\n\
\x20 sudo sysctl -w fs.inotify.max_user_instances=1024\n\
\x20 or persist it in /etc/sysctl.d/. The dev server keeps running;\n\
\x20 press 'r' to rebuild by hand, and check that target/ is outside the watched tree."
),
fatal: false,
}
}
pub struct DirWatcher {
_watchers: Vec<notify::RecommendedWatcher>,
}
impl DirWatcher {
pub fn start(root: &Path, tx: mpsc::Sender<DevCmd>) -> Self {
Self::start_roots(std::slice::from_ref(&root.to_path_buf()), tx)
}
pub fn start_roots(roots: &[PathBuf], tx: mpsc::Sender<DevCmd>) -> Self {
let mut watchers = Vec::new();
for root in roots {
if !root.is_dir() {
continue;
}
if let Some(w) = Self::watch_one(root, tx.clone()) {
watchers.push(w);
}
}
Self {
_watchers: watchers,
}
}
fn watch_one(root: &Path, tx: mpsc::Sender<DevCmd>) -> Option<notify::RecommendedWatcher> {
let previous: Arc<Mutex<HashMap<PathBuf, String>>> = Arc::new(Mutex::new(HashMap::new()));
seed_cache(&previous, root);
let prev_for_handler = Arc::clone(&previous);
let tx_for_handler = tx.clone();
let root_for_handler = root.to_path_buf();
let mut watcher =
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
let event = match res {
Ok(e) => e,
Err(e) => {
let _ = tx_for_handler.send(DevCmd::WatchError {
message: e.to_string(),
});
return;
}
};
if matches!(event.kind, EventKind::Access(_)) {
return;
}
for path in &event.paths {
let Ok(rel) = path.strip_prefix(&root_for_handler) else {
continue;
};
if is_ignored(rel) {
continue;
}
let kind = classify_observed(&prev_for_handler, rel);
if tx_for_handler
.send(DevCmd::FileChanged {
path: rel.to_path_buf(),
kind,
})
.is_err()
{
return;
}
}
}) {
Ok(w) => w,
Err(e) => {
let _ = tx.send(DevCmd::WatchError {
message: format!("Could not start a filesystem watcher: {e}"),
});
return None;
}
};
if let Err(e) = watcher.watch(root, RecursiveMode::Recursive) {
let _ = tx.send(DevCmd::WatchError {
message: format!("Could not watch {}: {e}", root.display()),
});
}
Some(watcher)
}
}
fn seed_cache(cache: &Arc<Mutex<HashMap<PathBuf, String>>>, root: &Path) {
fn walk(cache: &Arc<Mutex<HashMap<PathBuf, String>>>, dir: &Path, root: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(rel) = path.strip_prefix(root) else {
continue;
};
if is_ignored(rel) {
continue;
}
if path.is_dir() {
walk(cache, &path, root);
} else if let Ok(content) = std::fs::read_to_string(&path) {
cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(rel.to_path_buf(), content);
}
}
}
walk(cache, root, root);
}
fn classify_observed(previous: &Arc<Mutex<HashMap<PathBuf, String>>>, path: &Path) -> ChangeKind {
let Ok(after) = std::fs::read_to_string(path) else {
return ChangeKind::Script;
};
let mut cache = previous.lock().unwrap_or_else(|e| e.into_inner());
match cache.remove(path) {
Some(before) => {
let kind = classify_change(&before, &after);
cache.insert(path.to_path_buf(), after);
kind
}
None => {
cache.insert(path.to_path_buf(), after);
ChangeKind::Script
}
}
}
fn ansi(code: &str, s: &str) -> String {
format!("\x1b[{}m{}\x1b[0m", code, s)
}
fn dim(s: &str) -> String {
ansi("2", s)
}
fn bold(s: &str) -> String {
ansi("1", s)
}
fn cyan(s: &str) -> String {
ansi("36", s)
}
fn green(s: &str) -> String {
ansi("32", s)
}
fn red(s: &str) -> String {
ansi("31", s)
}
fn yellow(s: &str) -> String {
ansi("33", s)
}
fn clear_screen() {
print!("\x1b[2J\x1b[1;1H");
let _ = std::io::stdout().flush();
}
fn project_bin_name(project_dir: &Path) -> Option<String> {
let manifest = project_dir.join("Cargo.toml");
let content = std::fs::read_to_string(&manifest).ok()?;
let mut in_bin = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "[[bin]]" {
in_bin = true;
continue;
}
if in_bin && trimmed.starts_with('[') {
in_bin = false;
}
if in_bin
&& trimmed.starts_with("name")
&& let Some(eq) = trimmed.find('=')
{
let value = trimmed[eq + 1..].trim().trim_matches('"').to_string();
if !value.is_empty() {
return Some(value);
}
}
}
let mut in_package = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "[package]" {
in_package = true;
continue;
}
if in_package && trimmed.starts_with('[') && trimmed != "[package]" {
break;
}
if in_package
&& trimmed.starts_with("name")
&& let Some(eq) = trimmed.find('=')
{
let value = trimmed[eq + 1..].trim().trim_matches('"').to_string();
if !value.is_empty() {
return Some(value);
}
}
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChangeReaction {
Rebuild,
SwapStylesheet { path: PathBuf },
}
impl ChangeReaction {
pub fn needs_rebuild(self) -> bool {
let _ = self;
true
}
}
pub fn react_to_change(path: &Path, kind: ChangeKind) -> ChangeReaction {
match kind {
ChangeKind::StyleOnly => ChangeReaction::SwapStylesheet {
path: path.to_path_buf(),
},
ChangeKind::TemplateOnly | ChangeKind::Script => ChangeReaction::Rebuild,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DevCmd {
Reload,
Clear,
Quit,
Built(BuildOutcome),
FileChanged {
path: PathBuf,
kind: ChangeKind,
},
WatchError {
message: String,
},
}
pub struct InputSource {
handle: JoinHandle<()>,
}
impl InputSource {
pub fn from_handle(handle: JoinHandle<()>) -> Self {
Self { handle }
}
pub fn is_exhausted(&self) -> bool {
self.handle.is_finished()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuildAction {
Idle,
Start,
AlreadyInFlight,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct BuildGate {
in_flight: bool,
pending: bool,
}
impl BuildGate {
pub fn new() -> Self {
Self::default()
}
pub fn on_change(&mut self) -> BuildAction {
if self.in_flight {
self.pending = true;
BuildAction::AlreadyInFlight
} else {
self.in_flight = true;
BuildAction::Start
}
}
pub fn on_build_finished(&mut self) {
debug_assert!(
self.in_flight,
"on_build_finished called with no build in flight"
);
self.in_flight = false;
}
pub fn take_pending(&mut self) -> bool {
std::mem::take(&mut self.pending)
}
pub fn is_build_in_flight(&self) -> bool {
self.in_flight
}
pub fn has_pending_change(&self) -> bool {
self.pending
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BuildOutcome {
Compiled { elapsed: Duration },
CompileFailed { stderr: String },
InvokeFailed { message: String },
}
pub fn classify_build(success: bool, stderr: String, elapsed: Duration) -> BuildOutcome {
if success {
BuildOutcome::Compiled { elapsed }
} else {
BuildOutcome::CompileFailed { stderr }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BuildReaction {
RunApp { elapsed: Duration },
PrintBuildError { stderr: String },
ReportLaunchFailure { message: String },
}
pub fn react_to(outcome: &BuildOutcome) -> BuildReaction {
match outcome {
BuildOutcome::Compiled { elapsed } => BuildReaction::RunApp { elapsed: *elapsed },
BuildOutcome::CompileFailed { stderr } => BuildReaction::PrintBuildError {
stderr: stderr.clone(),
},
BuildOutcome::InvokeFailed { message } => BuildReaction::ReportLaunchFailure {
message: message.clone(),
},
}
}
pub struct AppChild(Option<Child>);
impl AppChild {
pub fn new(child: Child) -> Self {
Self(Some(child))
}
pub fn has_exited(&mut self) -> std::io::Result<bool> {
match self.0.as_mut() {
Some(c) => Ok(c.try_wait()?.is_some()),
None => Ok(false),
}
}
pub fn shutdown(&mut self) -> Option<ExitStatus> {
let mut c = self.0.take()?;
let _ = c.kill();
c.wait().ok()
}
}
impl Drop for AppChild {
fn drop(&mut self) {
self.shutdown();
}
}
pub struct BuildWorker {
cargo_slot: Arc<Mutex<Option<Child>>>,
handle: Option<JoinHandle<()>>,
}
impl BuildWorker {
fn start(
project_dir: PathBuf,
release: bool,
bin: Option<String>,
tx: mpsc::Sender<DevCmd>,
) -> Self {
Self::start_with(tx, move |tx, cargo_slot| {
run_build(project_dir, release, bin, tx, cargo_slot);
})
}
pub fn start_with<F>(tx: mpsc::Sender<DevCmd>, body: F) -> Self
where
F: FnOnce(&mpsc::Sender<DevCmd>, &Mutex<Option<Child>>) + Send + 'static,
{
let cargo_slot: Arc<Mutex<Option<Child>>> = Arc::new(Mutex::new(None));
let slot = Arc::clone(&cargo_slot);
let handle = thread::spawn(move || {
body(&tx, &slot);
});
Self {
cargo_slot,
handle: Some(handle),
}
}
}
impl Drop for BuildWorker {
fn drop(&mut self) {
if let Some(mut c) = self
.cargo_slot
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
{
let _ = c.kill();
let _ = c.wait();
}
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
fn run_build(
project_dir: PathBuf,
release: bool,
bin: Option<String>,
tx: &mpsc::Sender<DevCmd>,
cargo_slot: &Mutex<Option<Child>>,
) {
let start = SystemTime::now();
let mut build = Command::new("cargo");
build.arg("build");
if release {
build.arg("--release");
}
if let Some(ref name) = bin {
build.arg("--bin").arg(name);
}
build
.current_dir(&project_dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
let mut child = match build.spawn() {
Ok(c) => c,
Err(e) => {
let _ = tx.send(DevCmd::Built(BuildOutcome::InvokeFailed {
message: e.to_string(),
}));
return;
}
};
let pipe = child.stderr.take();
*cargo_slot.lock().unwrap_or_else(|e| e.into_inner()) = Some(child);
let mut stderr = Vec::new();
if let Some(mut err) = pipe {
let _ = err.read_to_end(&mut stderr);
}
let still_ours = cargo_slot.lock().unwrap_or_else(|e| e.into_inner()).take();
let Some(mut c) = still_ours else {
return;
};
let Ok(status) = c.wait() else {
return;
};
let outcome = classify_build(
status.success(),
String::from_utf8_lossy(&stderr).into_owned(),
start.elapsed().unwrap_or_default(),
);
let _ = tx.send(DevCmd::Built(outcome));
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum HmrStatus {
Available,
Unavailable {
reason: String,
},
}
impl HmrStatus {
fn is_available(&self) -> bool {
matches!(self, HmrStatus::Available)
}
fn banner_line(&self, port: u16) -> String {
match self {
HmrStatus::Available => {
format!("port {port} (auto-reload on save)")
}
HmrStatus::Unavailable { reason } => {
format!("port {port} (unavailable: {reason})")
}
}
}
fn app_started_line(&self) -> String {
match self {
HmrStatus::Available => "App started (HMR enabled)".to_string(),
HmrStatus::Unavailable { reason } => {
format!("App started (HMR unavailable: {reason})")
}
}
}
}
fn port_reason(e: &std::io::Error) -> String {
if e.kind() == std::io::ErrorKind::AddrInUse {
"in use by another process".to_string()
} else {
e.to_string()
}
}
pub struct HmrListener {
slot: HmrSlot,
shutdown: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
}
impl HmrListener {
fn start(port: u16) -> std::io::Result<Self> {
let listener = std::net::TcpListener::bind(("127.0.0.1", port))?;
listener.set_nonblocking(true).ok();
let slot: HmrSlot = Arc::new(Mutex::new(None));
let shutdown = Arc::new(AtomicBool::new(false));
let slot_clone = Arc::clone(&slot);
let shutdown_clone = Arc::clone(&shutdown);
let handle = thread::spawn(move || {
while !shutdown_clone.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, addr)) => {
eprintln!("[velox] HMR client connected: {}", addr);
*slot_clone.lock().unwrap_or_else(|e| e.into_inner()) = Some(stream);
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(50));
}
Err(e) => {
eprintln!("[velox] HMR accept error: {}", e);
thread::sleep(Duration::from_millis(100));
}
}
}
});
Ok(Self {
slot,
shutdown,
handle: Some(handle),
})
}
pub fn slot(&self) -> &HmrSlot {
&self.slot
}
}
impl Drop for HmrListener {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Relaxed);
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
pub fn watch_roots(project_dir: &Path) -> Vec<PathBuf> {
let src = project_dir.join("src");
if src.is_dir() {
vec![src, project_dir.join("assets")]
} else {
vec![project_dir.to_path_buf()]
}
}
pub fn dev_current(project_dir: &Path, release: bool) -> Result<()> {
let watch_dirs = watch_roots(project_dir);
let hmr_port = velox_renderer::DEFAULT_HMR_PORT;
let (hmr, hmr_status) = match HmrListener::start(hmr_port) {
Ok(listener) => {
eprintln!("[velox] HMR dev server listening on 127.0.0.1:{}", hmr_port);
(Some(listener), HmrStatus::Available)
}
Err(e) => (
None,
HmrStatus::Unavailable {
reason: format!("port {hmr_port} {}", port_reason(&e)),
},
),
};
print_banner(project_dir, release, &watch_dirs, &hmr_status);
let (tx, rx) = mpsc::channel::<DevCmd>();
let stdin = InputSource::from_handle(spawn_stdin_reader(tx.clone()));
let project = project_dir.to_path_buf();
let bin = project_bin_name(project_dir);
println!("{}", dim("⏳ Compiling..."));
let mut builder = Some(BuildWorker::start(
project.clone(),
release,
bin.clone(),
tx.clone(),
));
let mut child: Option<AppChild> = None;
let mut gate = BuildGate::new();
let mut debounce = ChangeDebouncer::new();
let mut burst: Option<(PathBuf, ChangeKind)> = None;
let mut crashed = false;
let _watcher = DirWatcher::start_roots(&watch_dirs, tx.clone());
loop {
let mut change_requested = false;
let wait = debounce.wait(Instant::now()).unwrap_or(LOOP_TIMEOUT);
match rx.recv_timeout(wait) {
Ok(DevCmd::Quit) => {
drop(child.take());
drop(builder.take());
println!("\n{} Dev server stopped.", bold("👋"));
break;
}
Ok(DevCmd::Clear) => {
clear_screen();
print_banner(project_dir, release, &watch_dirs, &hmr_status);
if crashed {
println!(
"{} {}\n",
red("✗ App crashed."),
dim("Fix the error and save to rebuild.")
);
}
}
Ok(DevCmd::Reload) => {
println!("{}", yellow("↻ Manual reload requested"));
change_requested = true;
}
Ok(DevCmd::FileChanged { path, kind }) => {
debounce.record(Instant::now(), DEBOUNCE_WINDOW);
burst = Some(match burst {
Some((prev_path, prev_kind)) => (prev_path, prev_kind.merge(kind)),
None => (path, kind),
});
}
Ok(DevCmd::WatchError { message }) => {
match react_to_watch_error(&message) {
WatchReaction::Report { message, fatal } => {
let label = if fatal {
"stopped"
} else {
"watching continues"
};
println!(
"{} Filesystem watcher error ({label}):\n{message}",
red("✗")
);
}
}
}
Ok(DevCmd::Built(outcome)) => {
drop(builder.take());
gate.on_build_finished();
if gate.take_pending() {
change_requested = true;
} else {
match react_to(&outcome) {
BuildReaction::RunApp { elapsed } => {
println!("{} Compiled in {:.1}s", green("✓"), elapsed.as_secs_f32());
child =
spawn_app(&project, release, bin.clone(), hmr_port, &hmr_status)
.map(AppChild::new);
crashed = false;
}
BuildReaction::PrintBuildError { stderr } => print_build_error(&stderr),
BuildReaction::ReportLaunchFailure { message } => {
println!("{} Failed to invoke cargo: {}", red("✗"), message);
}
}
}
}
Err(RecvTimeoutError::Timeout) => {
if stdin.is_exhausted() {
break;
}
}
Err(RecvTimeoutError::Disconnected) => break,
}
if debounce.take_if_due(Instant::now())
&& let Some((path, kind)) = burst.take()
{
println!(
"{} {} changed ({kind:?}) — rebuilding",
yellow("↻"),
path.display()
);
change_requested = react_to_change(&path, kind).needs_rebuild();
}
if change_requested && gate.on_change() == BuildAction::Start {
if let Some(hmr) = hmr.as_ref() {
send_hmr_reload(hmr.slot());
}
drop(child.take());
println!("{}", dim("⏳ Compiling..."));
builder = Some(BuildWorker::start(
project.clone(),
release,
bin.clone(),
tx.clone(),
));
}
if let Some(ref mut c) = child
&& c.has_exited()?
{
println!(
"{}",
dim("App exited. Press 'r' to restart, or save a file to rebuild.")
);
child = None;
crashed = true;
}
}
Ok(())
}
type HmrSlot = std::sync::Arc<std::sync::Mutex<Option<std::net::TcpStream>>>;
fn send_hmr_reload(slot: &HmrSlot) {
let mut guard = slot.lock().unwrap_or_else(|e| e.into_inner());
if let Some(ref mut stream) = *guard {
let msg = velox_renderer::HmrMessage::FullReload;
let json = serde_json::to_string(&msg).unwrap_or_default();
match stream.write_all(json.as_bytes()) {
Ok(_) => {
eprintln!("[velox] Sent FullReload to app");
}
Err(e) => {
eprintln!("[velox] HMR send failed (app may have disconnected): {}", e);
*guard = None;
}
}
} else {
eprintln!("[velox] No HMR client connected — skipping reload");
}
}
fn print_banner(project_dir: &Path, release: bool, watch_dirs: &[PathBuf], hmr: &HmrStatus) {
let name = project_dir
.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().to_string()))
.unwrap_or_else(|| "velox-app".into());
println!();
println!(
" {} {}",
bold(&cyan("⚡ Velox dev server")),
dim(&format!("v{}", env!("CARGO_PKG_VERSION")))
);
println!(" {} {}", bold("➤ Project:"), name);
for dir in watch_dirs {
println!(" {} {}", bold("➤ Watching:"), dir.display());
}
println!(
" {} {}",
bold("➤ HMR:"),
hmr.banner_line(velox_renderer::DEFAULT_HMR_PORT)
);
println!(
" {} {}",
bold("➤ Build:"),
if release { "release" } else { "debug" }
);
println!(" {}", dim(" r: reload c: clear q: quit"));
println!();
}
fn spawn_stdin_reader(tx: mpsc::Sender<DevCmd>) -> JoinHandle<()> {
thread::spawn(move || {
let stdin = std::io::stdin();
for line in stdin.lock().lines() {
match line {
Ok(l) => {
let cmd = match l.trim() {
"r" | "reload" => Some(DevCmd::Reload),
"c" | "clear" => Some(DevCmd::Clear),
"q" | "quit" | "exit" => Some(DevCmd::Quit),
_ => None,
};
if let Some(c) = cmd {
let is_quit = matches!(c, DevCmd::Quit);
if tx.send(c).is_err() {
break;
}
if is_quit {
break;
}
}
}
Err(_) => break,
}
}
})
}
fn spawn_app(
project_dir: &Path,
release: bool,
bin: Option<String>,
hmr_port: u16,
hmr: &HmrStatus,
) -> Option<Child> {
let mut run = Command::new("cargo");
run.arg("run");
if release {
run.arg("--release");
}
if let Some(ref name) = bin {
run.arg("--bin").arg(name);
}
if hmr.is_available() {
run.env("VELOX_HMR", "1")
.env("VELOX_HMR_PORT", hmr_port.to_string());
} else {
run.env("VELOX_HMR", "0");
}
run.current_dir(project_dir)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
match run.spawn() {
Ok(c) => {
println!("{}", green(&hmr.app_started_line()));
Some(c)
}
Err(e) => {
log::error!("Start failed: {}", e);
println!("{} Failed to start: {}", red("✗"), e);
None
}
}
}
fn print_build_error(stderr: &str) {
let lines: Vec<&str> = stderr.lines().collect();
let errors: Vec<&str> = lines
.iter()
.copied()
.filter(|l| {
l.contains("error[")
|| l.contains("error:")
|| l.contains("cannot find")
|| l.contains("expected")
})
.collect();
println!();
println!("{}", red("──────────── ✗ Build failed ────────────"));
if errors.is_empty() {
for l in lines.iter().rev().take(15).rev() {
println!(" {}", dim(l));
}
} else {
for l in errors.iter().take(20) {
println!(" {}", red(l));
}
}
println!("{}", red("─────────────────────────────────────────"));
println!("{}", dim(" Edit the file and save to rebuild."));
println!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_is_excluded_at_every_depth() {
assert!(is_ignored(Path::new("target/debug/junk.txt")));
assert!(is_ignored(Path::new("sub/project/target/debug/artifact")));
assert!(is_ignored(Path::new("target")));
assert!(!is_ignored(Path::new("src/target_like/keep.txt")));
}
#[test]
fn dot_directories_are_excluded_but_source_files_are_not() {
assert!(is_ignored(Path::new(".git/HEAD")));
assert!(is_ignored(Path::new(".vscode/settings.json")));
assert!(!is_ignored(Path::new("App.vx")));
assert!(!is_ignored(Path::new("components/only.vx")));
}
#[test]
fn merging_a_burst_never_cheaper_than_its_worst_input() {
use ChangeKind::*;
assert_eq!(StyleOnly.merge(StyleOnly), StyleOnly);
assert_eq!(StyleOnly.merge(TemplateOnly), TemplateOnly);
assert_eq!(TemplateOnly.merge(StyleOnly), TemplateOnly);
assert_eq!(StyleOnly.merge(Script), Script);
assert_eq!(Script.merge(StyleOnly), Script);
assert_eq!(Script.merge(TemplateOnly), Script);
assert_eq!(TemplateOnly.merge(Script), Script);
}
#[test]
fn a_style_edit_is_routed_to_a_stylesheet_swap() {
assert_eq!(
react_to_change(Path::new("App.vx"), ChangeKind::StyleOnly),
ChangeReaction::SwapStylesheet {
path: PathBuf::from("App.vx")
}
);
assert_eq!(
react_to_change(Path::new("App.vx"), ChangeKind::TemplateOnly),
ChangeReaction::Rebuild
);
assert_eq!(
react_to_change(Path::new("App.vx"), ChangeKind::Script),
ChangeReaction::Rebuild
);
}
#[test]
fn the_debounce_path_never_sleeps() {
let mut d = ChangeDebouncer::new();
let start = Instant::now();
for i in 0..10_000u32 {
let now = start + Duration::from_micros(i as u64);
d.record(now, Duration::from_millis(50));
let _ = d.take_if_due(now);
}
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_millis(50),
"10 000 debounce cycles took {elapsed:?}; the change path must not sleep"
);
}
fn a_free_port() -> u16 {
let l = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("bind an ephemeral port");
l.local_addr().expect("a local addr").port()
}
#[test]
fn a_port_in_use_makes_the_banner_say_unavailable_and_never_enabled() {
let squatter = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("bind");
let port = squatter.local_addr().expect("a local addr").port();
let err = match HmrListener::start(port) {
Ok(_) => panic!("the port was already bound; the start must fail"),
Err(e) => e,
};
assert_eq!(
err.kind(),
std::io::ErrorKind::AddrInUse,
"expected AddrInUse on a held port, got {err:?}"
);
let status = HmrStatus::Unavailable {
reason: format!("port {port} {}", port_reason(&err)),
};
assert!(
!status.is_available(),
"a failed bind must not read as available"
);
let line = status.app_started_line();
assert!(
line.contains("unavailable") && line.contains(&port.to_string()),
"the started-app line must name the unavailable port and say so: {line:?}"
);
assert!(
!line.contains("enabled"),
"the started-app line claimed HMR on a run with no listener: {line:?}"
);
let banner = status.banner_line(port);
assert!(
!banner.contains("auto-reload"),
"the banner advertised auto-reload with no listener: {banner:?}"
);
assert!(
banner.contains("unavailable") && banner.contains("in use"),
"the banner must say why hot reload is missing: {banner:?}"
);
}
#[test]
fn a_free_port_makes_the_banner_say_enabled() {
let port = a_free_port();
let listener = HmrListener::start(port).expect("a free port must bind");
assert!(
HmrStatus::Available.is_available(),
"a successful bind must read as available"
);
assert_eq!(
HmrStatus::Available.app_started_line(),
"App started (HMR enabled)"
);
assert!(
HmrStatus::Available
.banner_line(port)
.contains("auto-reload on save"),
"the banner must advertise auto-reload when the listener is up"
);
drop(listener);
}
#[test]
fn dropping_the_listener_frees_the_port() {
let port = a_free_port();
let listener = HmrListener::start(port).expect("a free port must bind");
assert!(
std::net::TcpListener::bind(("127.0.0.1", port)).is_err(),
"the port was free while an HmrListener held it"
);
drop(listener);
std::net::TcpListener::bind(("127.0.0.1", port))
.expect("the port must be released once the listener is dropped");
}
#[test]
fn an_addr_in_use_reason_names_the_cause() {
assert_eq!(
port_reason(&std::io::Error::from(std::io::ErrorKind::AddrInUse)),
"in use by another process"
);
let other = std::io::Error::other("something else entirely");
assert_eq!(port_reason(&other), other.to_string());
}
}