use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::Duration;
const SHUTDOWN_CHECK_INTERVAL_MS: u64 = 200;
static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);
const DEBOUNCE_MS: u64 = 500;
const WATCH_FILES: &[&str] = &[
"autumn.toml",
"Cargo.toml",
"Cargo.lock",
"build.rs",
"tailwind.config.js",
];
const WATCH_DIRS: &[&str] = &["src", "static", "templates", "migrations"];
const DEV_RELOAD_ENV: &str = "AUTUMN_DEV_RELOAD";
const DEV_RELOAD_STATE_ENV: &str = "AUTUMN_DEV_RELOAD_STATE";
const DEV_RELOAD_STATE_FILE: &str = "live-reload.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChangeEffect {
Ignore,
BrowserReloadOnly,
TailwindOnly,
RestartOnly,
BuildRestart,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
enum ReloadKind {
#[default]
None,
Css,
Full,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct ChangePlan {
build: bool,
restart: bool,
tailwind: bool,
reload: ReloadKind,
}
impl ChangePlan {
fn is_empty(self) -> bool {
!self.build && !self.restart && !self.tailwind && self.reload == ReloadKind::None
}
fn register(&mut self, effect: ChangeEffect) {
match effect {
ChangeEffect::Ignore => {}
ChangeEffect::BrowserReloadOnly => {
self.reload = self.reload.max(ReloadKind::Full);
}
ChangeEffect::TailwindOnly => {
self.tailwind = true;
self.reload = self.reload.max(ReloadKind::Css);
}
ChangeEffect::RestartOnly => {
self.restart = true;
self.reload = self.reload.max(ReloadKind::Full);
}
ChangeEffect::BuildRestart => {
self.build = true;
self.restart = true;
self.tailwind = false;
self.reload = ReloadKind::Full;
}
}
}
const fn finalize(mut self) -> Self {
if self.build {
self.tailwind = false;
}
self
}
}
#[derive(Debug)]
struct DevReloadState {
path: PathBuf,
version: u64,
}
impl DevReloadState {
fn initialize() -> Result<Self, String> {
let path = resolve_dev_reload_state_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
}
let state = Self { path, version: 0 };
state.write(ReloadKind::Full)?;
Ok(state)
}
fn path(&self) -> &Path {
&self.path
}
fn signal(&mut self, kind: ReloadKind) -> Result<(), String> {
if kind == ReloadKind::None {
return Ok(());
}
self.version = self
.version
.checked_add(1)
.ok_or("live reload version overflowed")?;
self.write(kind)
}
fn write(&self, kind: ReloadKind) -> Result<(), String> {
let kind = match kind {
ReloadKind::None | ReloadKind::Full => "full",
ReloadKind::Css => "css",
};
let body = serde_json::json!({
"version": self.version,
"kind": kind,
});
std::fs::write(&self.path, body.to_string())
.map_err(|e| format!("failed to write {}: {e}", self.path.display()))
}
}
pub fn run(package: Option<&str>, show_config: bool) {
eprintln!("\u{1F342} autumn dev\n");
if let Err(err) = ctrlc::set_handler(move || {
SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
}) {
eprintln!(" Warning: failed to set Ctrl-C handler: {err}");
}
let mut reload_state = match DevReloadState::initialize() {
Ok(state) => Some(state),
Err(error) => {
eprintln!(" Warning: live reload disabled: {error}");
None
}
};
if !cargo_build(package) {
eprintln!("\u{2717} Initial build failed. Fix errors and save to retry.\n");
}
let binary = find_binary(package);
let mut child = start_server(
&binary,
reload_state.as_ref().map(DevReloadState::path),
show_config,
);
let (tx, rx) = mpsc::channel();
let mut debouncer = new_debouncer(Duration::from_millis(DEBOUNCE_MS), tx)
.expect("failed to create file watcher");
let watcher = debouncer.watcher();
for dir in WATCH_DIRS {
let path = Path::new(dir);
if path.exists() {
if let Err(e) = watcher.watch(path, notify::RecursiveMode::Recursive) {
eprintln!(" Warning: could not watch {dir}/: {e}");
}
}
}
if let Err(e) = watcher.watch(Path::new("."), notify::RecursiveMode::NonRecursive) {
eprintln!(" Warning: could not watch project root: {e}");
}
eprintln!(" Watching for changes... (press Ctrl+C to stop)\n");
loop {
if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
eprintln!("\n Shutting down...");
break;
}
if !process_events(&rx, package, &mut child, reload_state.as_mut(), show_config) {
break;
}
}
stop_server(&mut child);
}
fn process_events(
rx: &mpsc::Receiver<Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>>,
package: Option<&str>,
child: &mut Option<Child>,
reload_state: Option<&mut DevReloadState>,
show_config: bool,
) -> bool {
match rx.recv_timeout(Duration::from_millis(SHUTDOWN_CHECK_INTERVAL_MS)) {
Ok(Ok(events)) => {
let plan = plan_changes(&events);
if plan.is_empty() {
return true;
}
let changed = collect_relevant_changes(&events);
if changed.is_empty() {
return true;
}
eprintln!("\n Changed: {}", changed.join(", "));
eprintln!(" Action: {}", describe_plan(plan));
execute_plan(plan, package, child, reload_state, show_config);
true
}
Ok(Err(error)) => {
eprintln!(" Watch error: {error:?}");
true
}
Err(mpsc::RecvTimeoutError::Timeout) => true,
Err(mpsc::RecvTimeoutError::Disconnected) => {
eprintln!(" Watch channel error: channel disconnected");
false
}
}
}
fn execute_plan(
plan: ChangePlan,
package: Option<&str>,
child: &mut Option<Child>,
mut reload_state: Option<&mut DevReloadState>,
show_config: bool,
) {
let mut applied_reload = ReloadKind::None;
if plan.build {
stop_server(child);
if cargo_build(package) {
if restart_server(
package,
child,
reload_state.as_ref().map(|s| s.path()),
show_config,
) {
applied_reload = ReloadKind::Full;
}
} else {
eprintln!(" \u{2717} Build failed. Waiting for changes...\n");
*child = None;
}
} else {
if plan.tailwind && tailwind_build() {
applied_reload = applied_reload.max(ReloadKind::Css);
}
if plan.restart {
stop_server(child);
if restart_server(
package,
child,
reload_state.as_ref().map(|s| s.path()),
show_config,
) {
applied_reload = ReloadKind::Full;
}
} else if plan.reload == ReloadKind::Full {
applied_reload = ReloadKind::Full;
}
}
if let Some(reload_state) = reload_state.as_mut() {
if let Err(error) = reload_state.signal(applied_reload) {
eprintln!(" Warning: live reload signal failed: {error}");
}
}
}
fn collect_relevant_changes(events: &[notify_debouncer_mini::DebouncedEvent]) -> Vec<String> {
events
.iter()
.filter(|e| is_relevant_change(&e.path, e.kind))
.map(|e| e.path.display().to_string())
.collect()
}
fn plan_changes(events: &[notify_debouncer_mini::DebouncedEvent]) -> ChangePlan {
let mut plan = ChangePlan::default();
for event in events {
plan.register(classify_change(&event.path, event.kind));
}
plan.finalize()
}
fn build_cargo_command(package: Option<&str>) -> Command {
let mut cmd = Command::new("cargo");
cmd.arg("build");
if let Some(pkg) = package {
cmd.args(["-p", pkg]);
}
cmd
}
fn cargo_build(package: Option<&str>) -> bool {
let mut cmd = build_cargo_command(package);
eprintln!(" Compiling...");
match cmd.status() {
Ok(status) if status.success() => {
eprintln!(" \u{2713} Build succeeded");
true
}
Ok(_) => false,
Err(e) => {
eprintln!(" \u{2717} Failed to run cargo build: {e}");
false
}
}
}
fn start_server(
binary: &Path,
reload_state_path: Option<&Path>,
show_config: bool,
) -> Option<Child> {
eprintln!(" Starting server...\n");
let mut command = Command::new(binary);
command.stdout(Stdio::inherit()).stderr(Stdio::inherit());
if let Some(path) = reload_state_path {
command.env(DEV_RELOAD_ENV, "1");
command.env(DEV_RELOAD_STATE_ENV, path);
}
if show_config {
command.env("AUTUMN_SHOW_CONFIG", "1");
}
match command.spawn() {
Ok(child) => Some(child),
Err(e) => {
eprintln!(" \u{2717} Failed to start {}: {e}", binary.display());
None
}
}
}
#[cfg(unix)]
fn validate_pid_for_kill(pid: u32) -> Option<libc::pid_t> {
let cast_pid = pid.try_into().ok()?;
if cast_pid > 0 { Some(cast_pid) } else { None }
}
fn stop_server(child: &mut Option<Child>) {
if let Some(proc) = child {
#[cfg(unix)]
{
if let Some(pid) = validate_pid_for_kill(proc.id()) {
if let Err(e) = nix::sys::signal::kill(
nix::unistd::Pid::from_raw(pid),
nix::sys::signal::Signal::SIGTERM,
) {
eprintln!(" Warning: failed to send SIGTERM to process: {e}");
}
}
if wait_with_timeout(proc, Duration::from_secs(5)).is_err() {
let _ = proc.kill();
let _ = proc.wait();
}
}
#[cfg(not(unix))]
{
let _ = proc.kill();
let _ = proc.wait();
}
}
*child = None;
}
#[cfg(unix)]
fn wait_with_timeout(child: &mut Child, timeout: Duration) -> Result<(), ()> {
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => return Ok(()),
Ok(None) => {
if start.elapsed() >= timeout {
return Err(());
}
std::thread::sleep(Duration::from_millis(50));
}
Err(_) => return Err(()),
}
}
}
fn is_relevant_change(path: &Path, kind: DebouncedEventKind) -> bool {
classify_change(path, kind) != ChangeEffect::Ignore
}
fn classify_change(path: &Path, kind: DebouncedEventKind) -> ChangeEffect {
if !matches!(kind, DebouncedEventKind::Any) || should_ignore_path(path) {
return ChangeEffect::Ignore;
}
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
return ChangeEffect::Ignore;
};
if WATCH_FILES.contains(&file_name)
&& matches!(file_name, "Cargo.toml" | "Cargo.lock" | "build.rs")
{
return ChangeEffect::BuildRestart;
}
if WATCH_FILES.contains(&file_name) && file_name == "tailwind.config.js" {
return ChangeEffect::TailwindOnly;
}
if (WATCH_FILES.contains(&file_name) && file_name == "autumn.toml")
|| is_profile_config_file(file_name)
{
return ChangeEffect::RestartOnly;
}
if has_component(path, "src") && path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
return ChangeEffect::BuildRestart;
}
if has_component(path, "templates") {
return ChangeEffect::BuildRestart;
}
if has_component(path, "migrations")
&& path.extension().and_then(|ext| ext.to_str()) == Some("sql")
{
return ChangeEffect::RestartOnly;
}
if has_component(path, "static") {
if path.ends_with(Path::new("static").join("css").join("input.css")) {
return ChangeEffect::TailwindOnly;
}
return ChangeEffect::BrowserReloadOnly;
}
ChangeEffect::Ignore
}
fn should_ignore_path(path: &Path) -> bool {
if path.ends_with(Path::new("static").join("css").join("autumn.css")) {
return true;
}
if path.ends_with(
Path::new("target")
.join("autumn")
.join(DEV_RELOAD_STATE_FILE),
) {
return true;
}
for component in path.components() {
if let std::path::Component::Normal(name) = component {
let name = name.to_string_lossy();
if name == "target" || name.starts_with('.') {
return true;
}
}
}
false
}
fn has_component(path: &Path, target: &str) -> bool {
path.components().any(|component| {
matches!(
component,
std::path::Component::Normal(name) if name == std::ffi::OsStr::new(target)
)
})
}
fn is_profile_config_file(file_name: &str) -> bool {
file_name.starts_with("autumn-")
&& Path::new(file_name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
&& file_name.len() > "autumn-.toml".len()
}
const fn describe_plan(plan: ChangePlan) -> &'static str {
match plan {
ChangePlan {
build: true,
restart: true,
..
} => "cargo build + restart + full reload",
ChangePlan {
restart: true,
tailwind: true,
..
} => "Tailwind rebuild + restart + full reload",
ChangePlan { restart: true, .. } => "restart + full reload",
ChangePlan {
tailwind: true,
reload: ReloadKind::Css,
..
} => "Tailwind rebuild + CSS reload",
ChangePlan {
reload: ReloadKind::Full,
..
} => "browser full reload",
_ => "no-op",
}
}
fn restart_server(
package: Option<&str>,
child: &mut Option<Child>,
reload_state_path: Option<&Path>,
show_config: bool,
) -> bool {
let binary = find_binary(package);
*child = start_server(&binary, reload_state_path, show_config);
child.is_some()
}
fn tailwind_build() -> bool {
let Some(mut cmd) = build_tailwind_command() else {
eprintln!(
" \u{2717} Tailwind CSS CLI not found. Run `autumn setup` or install `tailwindcss`."
);
return false;
};
eprintln!(" Rebuilding Tailwind...");
match cmd.status() {
Ok(status) if status.success() => {
eprintln!(" \u{2713} Tailwind rebuild succeeded");
true
}
Ok(_) => {
eprintln!(" \u{2717} Tailwind rebuild failed");
false
}
Err(error) => {
eprintln!(" \u{2717} Failed to run Tailwind CLI: {error}");
false
}
}
}
fn build_tailwind_command() -> Option<Command> {
let tailwind = find_tailwind_cli()?;
Some(build_tailwind_command_for(&tailwind))
}
fn build_tailwind_command_for(tailwind: &Path) -> Command {
let mut cmd = Command::new(tailwind);
cmd.args([
"-i",
"static/css/input.css",
"-o",
"static/css/autumn.css",
"--content",
"src/**/*.rs",
"--minify",
]);
cmd
}
fn find_tailwind_cli() -> Option<PathBuf> {
let local = resolve_target_directory().ok().map(|dir| {
dir.join("autumn").join(if cfg!(windows) {
"tailwindcss.exe"
} else {
"tailwindcss"
})
});
if let Some(local) = local.filter(|path| path.exists()) {
return Some(local);
}
which("tailwindcss")
}
fn which(binary: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(binary);
if candidate.exists() {
return Some(candidate);
}
#[cfg(target_os = "windows")]
{
let candidate_exe = dir.join(format!("{binary}.exe"));
if candidate_exe.exists() {
return Some(candidate_exe);
}
}
}
None
}
fn resolve_dev_reload_state_path() -> Result<PathBuf, String> {
Ok(resolve_target_directory()?
.join("autumn")
.join(DEV_RELOAD_STATE_FILE))
}
fn resolve_target_directory() -> Result<PathBuf, String> {
let metadata = cargo_metadata();
metadata["target_directory"]
.as_str()
.map(PathBuf::from)
.ok_or_else(|| "missing target_directory in cargo metadata".to_owned())
}
fn cargo_metadata() -> serde_json::Value {
let output = Command::new("cargo")
.args(["metadata", "--format-version=1", "--no-deps"])
.output()
.expect("failed to run cargo metadata");
if !output.status.success() {
eprintln!("\u{2717} Failed to read cargo metadata");
std::process::exit(1);
}
serde_json::from_slice(&output.stdout).expect("parse cargo metadata")
}
fn resolve_binary_from_metadata(
metadata: &serde_json::Value,
package: Option<&str>,
cwd: &Path,
) -> Result<PathBuf, String> {
let target_dir = metadata["target_directory"]
.as_str()
.ok_or("missing target_directory in metadata")?;
let packages = metadata["packages"]
.as_array()
.ok_or("missing packages array in metadata")?;
let matching_packages: Vec<_> = package.map_or_else(
|| {
packages
.iter()
.filter(|pkg| {
let manifest = pkg["manifest_path"].as_str().unwrap_or("");
Path::new(manifest)
.parent()
.is_some_and(|dir| dir.starts_with(cwd))
})
.collect()
},
|pkg_name| {
packages
.iter()
.filter(|pkg| pkg["name"].as_str() == Some(pkg_name))
.collect()
},
);
let bin_name = matching_packages
.iter()
.find_map(|pkg| {
pkg["targets"].as_array()?.iter().find_map(|t| {
let is_bin = t["kind"].as_array()?.iter().any(|k| k == "bin");
if is_bin {
t["name"].as_str().map(String::from)
} else {
None
}
})
})
.ok_or_else(|| {
package.map_or_else(
|| "no binary target found in current package".to_owned(),
|pkg_name| format!("no binary target found in package '{pkg_name}'"),
)
})?;
let mut path = PathBuf::from(target_dir);
path.push("debug");
path.push(&bin_name);
if cfg!(target_os = "windows") {
path.set_extension("exe");
}
Ok(path)
}
fn find_binary(package: Option<&str>) -> PathBuf {
let metadata = cargo_metadata();
let cwd = std::env::current_dir().expect("current dir");
resolve_binary_from_metadata(&metadata, package, &cwd).unwrap_or_else(|e| {
eprintln!("\u{2717} {e}");
if package.is_none() {
eprintln!(" Hint: use -p <package> to specify the target package");
}
std::process::exit(1);
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relevant_rust_file() {
assert!(is_relevant_change(
Path::new("src/main.rs"),
DebouncedEventKind::Any,
));
}
#[test]
fn relevant_toml_config() {
assert!(is_relevant_change(
Path::new("autumn.toml"),
DebouncedEventKind::Any,
));
}
#[test]
fn relevant_cargo_toml() {
assert!(is_relevant_change(
Path::new("Cargo.toml"),
DebouncedEventKind::Any,
));
}
#[test]
fn relevant_css_file() {
assert!(is_relevant_change(
Path::new("static/css/style.css"),
DebouncedEventKind::Any,
));
}
#[test]
fn ignores_generated_tailwind_output() {
assert!(!is_relevant_change(
Path::new("static/css/autumn.css"),
DebouncedEventKind::Any,
));
}
#[test]
fn build_rs_change_requires_build_restart() {
assert_eq!(
classify_change(Path::new("build.rs"), DebouncedEventKind::Any),
ChangeEffect::BuildRestart
);
}
#[test]
fn profile_config_change_requires_restart_only() {
assert_eq!(
classify_change(Path::new("autumn-dev.toml"), DebouncedEventKind::Any),
ChangeEffect::RestartOnly
);
}
#[test]
fn css_input_change_runs_tailwind_without_build() {
let events = [notify_debouncer_mini::DebouncedEvent {
path: PathBuf::from("static/css/input.css"),
kind: DebouncedEventKind::Any,
}];
let plan = plan_changes(&events);
assert_eq!(
plan,
ChangePlan {
build: false,
restart: false,
tailwind: true,
reload: ReloadKind::Css,
}
);
}
#[test]
fn static_asset_change_triggers_browser_reload_only() {
let events = [notify_debouncer_mini::DebouncedEvent {
path: PathBuf::from("static/images/logo.png"),
kind: DebouncedEventKind::Any,
}];
let plan = plan_changes(&events);
assert_eq!(
plan,
ChangePlan {
build: false,
restart: false,
tailwind: false,
reload: ReloadKind::Full,
}
);
}
#[test]
fn mixed_config_and_css_changes_restart_and_rebuild_css() {
let events = [
notify_debouncer_mini::DebouncedEvent {
path: PathBuf::from("autumn-dev.toml"),
kind: DebouncedEventKind::Any,
},
notify_debouncer_mini::DebouncedEvent {
path: PathBuf::from("static/css/input.css"),
kind: DebouncedEventKind::Any,
},
];
let plan = plan_changes(&events);
assert_eq!(
plan,
ChangePlan {
build: false,
restart: true,
tailwind: true,
reload: ReloadKind::Full,
}
);
}
#[test]
fn build_restart_overrides_tailwind_only_changes() {
let events = [
notify_debouncer_mini::DebouncedEvent {
path: PathBuf::from("src/main.rs"),
kind: DebouncedEventKind::Any,
},
notify_debouncer_mini::DebouncedEvent {
path: PathBuf::from("static/css/input.css"),
kind: DebouncedEventKind::Any,
},
];
let plan = plan_changes(&events);
assert_eq!(
plan,
ChangePlan {
build: true,
restart: true,
tailwind: false,
reload: ReloadKind::Full,
}
);
}
#[test]
fn ignores_generated_dev_reload_state_file() {
assert_eq!(
classify_change(
Path::new("target/autumn/live-reload.json"),
DebouncedEventKind::Any
),
ChangeEffect::Ignore
);
}
#[test]
fn relevant_html_file() {
assert!(is_relevant_change(
Path::new("templates/index.html"),
DebouncedEventKind::Any,
));
}
#[test]
fn relevant_sql_migration() {
assert!(is_relevant_change(
Path::new("migrations/001_init.sql"),
DebouncedEventKind::Any,
));
}
#[test]
fn relevant_js_file() {
assert!(is_relevant_change(
Path::new("static/js/app.js"),
DebouncedEventKind::Any,
));
}
#[test]
fn relevant_nested_rust_file() {
assert!(is_relevant_change(
Path::new("src/routes/api/handlers.rs"),
DebouncedEventKind::Any,
));
}
#[test]
fn ignores_target_directory() {
assert!(!is_relevant_change(
Path::new("target/debug/build/main.rs"),
DebouncedEventKind::Any,
));
}
#[test]
fn ignores_hidden_files() {
assert!(!is_relevant_change(
Path::new(".git/config"),
DebouncedEventKind::Any,
));
}
#[test]
fn ignores_hidden_directory_nested() {
assert!(!is_relevant_change(
Path::new("src/.hidden/module.rs"),
DebouncedEventKind::Any,
));
}
#[test]
fn ignores_irrelevant_extensions() {
assert!(!is_relevant_change(
Path::new("src/notes.txt"),
DebouncedEventKind::Any,
));
}
#[test]
fn ignores_non_any_events() {
assert!(!is_relevant_change(
Path::new("src/main.rs"),
DebouncedEventKind::AnyContinuous,
));
}
#[test]
fn cargo_lock_triggers_rebuild() {
assert!(is_relevant_change(
Path::new("Cargo.lock"),
DebouncedEventKind::Any,
));
}
#[test]
fn ignores_file_without_extension() {
assert!(!is_relevant_change(
Path::new("src/Makefile"),
DebouncedEventKind::Any,
));
}
#[test]
fn relevant_image_files_trigger_browser_reload() {
assert!(is_relevant_change(
Path::new("static/logo.png"),
DebouncedEventKind::Any,
));
}
#[test]
fn ignores_target_nested_deeply() {
assert!(!is_relevant_change(
Path::new("target/release/deps/libfoo.rs"),
DebouncedEventKind::Any,
));
}
#[test]
fn collect_changes_filters_irrelevant() {
let events = vec![
notify_debouncer_mini::DebouncedEvent {
path: PathBuf::from("src/main.rs"),
kind: DebouncedEventKind::Any,
},
notify_debouncer_mini::DebouncedEvent {
path: PathBuf::from("README.md"),
kind: DebouncedEventKind::Any,
},
notify_debouncer_mini::DebouncedEvent {
path: PathBuf::from("src/lib.rs"),
kind: DebouncedEventKind::Any,
},
];
let changed = collect_relevant_changes(&events);
assert_eq!(changed.len(), 2);
assert!(changed.iter().any(|c| c.contains("main.rs")));
assert!(changed.iter().any(|c| c.contains("lib.rs")));
}
#[test]
fn collect_changes_returns_empty_for_no_relevant() {
let events = vec![
notify_debouncer_mini::DebouncedEvent {
path: PathBuf::from("README.md"),
kind: DebouncedEventKind::Any,
},
notify_debouncer_mini::DebouncedEvent {
path: PathBuf::from("target/debug/app"),
kind: DebouncedEventKind::Any,
},
];
let changed = collect_relevant_changes(&events);
assert!(changed.is_empty());
}
#[test]
fn collect_changes_handles_empty_events() {
let changed = collect_relevant_changes(&[]);
assert!(changed.is_empty());
}
#[test]
fn build_command_without_package() {
let cmd = build_cargo_command(None);
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(cmd.get_program(), "cargo");
assert_eq!(args, &["build"]);
}
#[test]
fn build_command_with_package() {
let cmd = build_cargo_command(Some("my-app"));
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(cmd.get_program(), "cargo");
assert_eq!(args, &["build", "-p", "my-app"]);
}
#[test]
fn build_tailwind_command_for_sets_expected_args() {
let cmd = build_tailwind_command_for(Path::new("tailwindcss"));
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(cmd.get_program(), "tailwindcss");
assert_eq!(
args,
&[
"-i",
"static/css/input.css",
"-o",
"static/css/autumn.css",
"--content",
"src/**/*.rs",
"--minify",
]
);
}
#[test]
fn start_server_returns_none_for_missing_binary() {
let result = start_server(Path::new("/nonexistent/binary/path"), None, false);
assert!(result.is_none());
}
#[cfg(unix)]
#[test]
fn start_server_returns_child_for_valid_binary() {
let child = start_server(Path::new("/bin/sleep"), None, false);
assert!(child.is_some());
let mut child = child.unwrap();
let _ = child.kill();
let _ = child.wait();
}
fn expected_binary(path: &str) -> PathBuf {
let mut p = PathBuf::from(path);
if cfg!(target_os = "windows") {
p.set_extension("exe");
}
p
}
fn sample_metadata(target_dir: &str, pkg_name: &str, manifest_dir: &str) -> serde_json::Value {
serde_json::json!({
"target_directory": target_dir,
"packages": [{
"name": pkg_name,
"manifest_path": format!("{manifest_dir}/Cargo.toml"),
"targets": [{
"name": pkg_name,
"kind": ["bin"],
"src_path": format!("{manifest_dir}/src/main.rs")
}]
}]
})
}
#[test]
fn resolve_binary_by_package_name() {
let metadata = sample_metadata("/tmp/target", "hello", "/projects/hello");
let result =
resolve_binary_from_metadata(&metadata, Some("hello"), Path::new("/projects/hello"));
assert!(result.is_ok());
let path = result.unwrap();
assert_eq!(path, expected_binary("/tmp/target/debug/hello"));
}
#[test]
fn resolve_binary_by_cwd() {
let metadata = sample_metadata("/tmp/target", "hello", "/projects/hello");
let result = resolve_binary_from_metadata(&metadata, None, Path::new("/projects/hello"));
assert!(result.is_ok());
let path = result.unwrap();
assert_eq!(path, expected_binary("/tmp/target/debug/hello"));
}
#[test]
fn resolve_binary_package_not_found() {
let metadata = sample_metadata("/tmp/target", "hello", "/projects/hello");
let result = resolve_binary_from_metadata(
&metadata,
Some("nonexistent"),
Path::new("/projects/hello"),
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("no binary target found in package 'nonexistent'")
);
}
#[test]
fn resolve_binary_no_match_by_cwd() {
let metadata = sample_metadata("/tmp/target", "hello", "/projects/hello");
let result = resolve_binary_from_metadata(&metadata, None, Path::new("/other/directory"));
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("no binary target found in current package")
);
}
#[test]
fn resolve_binary_missing_target_directory() {
let metadata = serde_json::json!({"packages": []});
let result = resolve_binary_from_metadata(&metadata, None, Path::new("/tmp"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("target_directory"));
}
#[test]
fn resolve_binary_missing_packages() {
let metadata = serde_json::json!({"target_directory": "/tmp/target"});
let result = resolve_binary_from_metadata(&metadata, None, Path::new("/tmp"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("packages"));
}
#[test]
fn resolve_binary_skips_lib_targets() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "mylib",
"manifest_path": "/projects/mylib/Cargo.toml",
"targets": [{
"name": "mylib",
"kind": ["lib"],
"src_path": "/projects/mylib/src/lib.rs"
}]
}]
});
let result =
resolve_binary_from_metadata(&metadata, Some("mylib"), Path::new("/projects/mylib"));
assert!(result.is_err());
}
#[test]
fn resolve_binary_picks_first_bin_in_multi_target() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "multi",
"manifest_path": "/projects/multi/Cargo.toml",
"targets": [
{"name": "multi", "kind": ["lib"]},
{"name": "server", "kind": ["bin"]},
{"name": "cli", "kind": ["bin"]}
]
}]
});
let result =
resolve_binary_from_metadata(&metadata, Some("multi"), Path::new("/projects/multi"));
assert!(result.is_ok());
assert_eq!(result.unwrap(), expected_binary("/tmp/target/debug/server"));
}
#[test]
fn resolve_binary_with_multiple_packages() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [
{
"name": "app-a",
"manifest_path": "/projects/a/Cargo.toml",
"targets": [{"name": "app-a", "kind": ["bin"]}]
},
{
"name": "app-b",
"manifest_path": "/projects/b/Cargo.toml",
"targets": [{"name": "app-b", "kind": ["bin"]}]
}
]
});
let result = resolve_binary_from_metadata(&metadata, Some("app-b"), Path::new("/projects"));
assert!(result.is_ok());
assert_eq!(result.unwrap(), expected_binary("/tmp/target/debug/app-b"));
}
#[cfg(unix)]
mod havoc_proptest {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn safe_pid_cast(pid in proptest::num::u32::ANY) {
if let Some(safe_pid) = validate_pid_for_kill(pid) {
assert!(safe_pid > 0, "Safe PID must be strictly positive");
}
}
}
}
#[test]
fn stop_server_with_none_is_noop() {
let mut child: Option<Child> = None;
stop_server(&mut child);
assert!(child.is_none());
}
#[cfg(unix)]
#[test]
fn stop_server_terminates_child() {
let proc = Command::new("sleep")
.arg("60")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn sleep");
let mut child = Some(proc);
stop_server(&mut child);
assert!(child.is_none());
}
#[cfg(unix)]
#[test]
fn wait_with_timeout_succeeds_for_fast_process() {
let mut child = Command::new("true").spawn().expect("spawn true");
std::thread::sleep(Duration::from_millis(50));
let result = wait_with_timeout(&mut child, Duration::from_secs(2));
assert!(result.is_ok());
}
#[cfg(unix)]
#[test]
fn wait_with_timeout_times_out_for_long_process() {
let mut child = Command::new("sleep")
.arg("60")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn sleep");
let result = wait_with_timeout(&mut child, Duration::from_millis(100));
assert!(result.is_err());
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn find_binary_resolves_workspace_package() {
let path = find_binary(Some("hello"));
assert!(path.ends_with("debug/hello") || path.ends_with("debug/hello.exe"));
}
#[test]
fn debounce_interval_is_reasonable() {
const { assert!(DEBOUNCE_MS >= 100, "debounce too short, would thrash") };
const { assert!(DEBOUNCE_MS <= 5000, "debounce too long, sluggish UX") };
}
#[test]
fn watch_dirs_are_non_empty() {
for dir in WATCH_DIRS {
assert!(!dir.is_empty());
}
}
#[test]
fn watch_files_are_non_empty() {
for f in WATCH_FILES {
assert!(!f.is_empty());
}
}
#[test]
fn dev_reload_state_signal_writes_css_and_full_versions() {
let reload_file = tempfile::NamedTempFile::new().expect("reload file");
let path = reload_file.path().to_path_buf();
let mut state = DevReloadState { path, version: 0 };
state.signal(ReloadKind::Css).expect("css signal");
let body = std::fs::read_to_string(state.path()).expect("read css");
assert_eq!(body, r#"{"kind":"css","version":1}"#);
state.signal(ReloadKind::Full).expect("full signal");
let body = std::fs::read_to_string(state.path()).expect("read full");
assert_eq!(body, r#"{"kind":"full","version":2}"#);
}
#[test]
fn dev_reload_state_signal_none_is_noop() {
let reload_file = tempfile::NamedTempFile::new().expect("reload file");
let path = reload_file.path().to_path_buf();
let mut state = DevReloadState { path, version: 41 };
state.signal(ReloadKind::None).expect("noop signal");
assert_eq!(state.version, 41);
assert!(
std::fs::read_to_string(state.path())
.unwrap_or_default()
.is_empty(),
"noop signal should not write a new state file"
);
}
#[test]
fn dev_reload_state_signal_rejects_overflow() {
let reload_file = tempfile::NamedTempFile::new().expect("reload file");
let path = reload_file.path().to_path_buf();
let mut state = DevReloadState {
path,
version: u64::MAX,
};
let error = state
.signal(ReloadKind::Full)
.expect_err("overflow should fail");
assert!(error.contains("overflowed"));
}
#[test]
fn profile_config_file_requires_named_toml_suffix() {
assert!(is_profile_config_file("autumn-dev.toml"));
assert!(is_profile_config_file("autumn-local.TOML"));
assert!(!is_profile_config_file("autumn-.toml"));
assert!(!is_profile_config_file("autumn-dev.txt"));
assert!(!is_profile_config_file("config.toml"));
}
#[test]
fn has_component_matches_exact_path_components() {
assert!(has_component(Path::new("src/routes/main.rs"), "src"));
assert!(has_component(
Path::new("templates/pages/index.html"),
"templates"
));
assert!(!has_component(Path::new("srcs/routes/main.rs"), "src"));
assert!(!has_component(
Path::new("template/index.html"),
"templates"
));
}
#[test]
fn describe_plan_covers_each_user_visible_action() {
assert_eq!(
describe_plan(ChangePlan {
build: true,
restart: true,
tailwind: false,
reload: ReloadKind::Full,
}),
"cargo build + restart + full reload"
);
assert_eq!(
describe_plan(ChangePlan {
build: false,
restart: true,
tailwind: true,
reload: ReloadKind::Full,
}),
"Tailwind rebuild + restart + full reload"
);
assert_eq!(
describe_plan(ChangePlan {
build: false,
restart: true,
tailwind: false,
reload: ReloadKind::Full,
}),
"restart + full reload"
);
assert_eq!(
describe_plan(ChangePlan {
build: false,
restart: false,
tailwind: true,
reload: ReloadKind::Css,
}),
"Tailwind rebuild + CSS reload"
);
assert_eq!(
describe_plan(ChangePlan {
build: false,
restart: false,
tailwind: false,
reload: ReloadKind::Full,
}),
"browser full reload"
);
assert_eq!(describe_plan(ChangePlan::default()), "no-op");
}
#[test]
fn resolve_target_directory_returns_workspace_target() {
let target_dir = resolve_target_directory().expect("target directory");
assert_eq!(
target_dir.file_name().and_then(|name| name.to_str()),
Some("target")
);
}
#[test]
fn resolve_dev_reload_state_path_uses_target_autumn_file() {
let path = resolve_dev_reload_state_path().expect("reload state path");
assert!(
path.ends_with(
Path::new("target")
.join("autumn")
.join(DEV_RELOAD_STATE_FILE)
)
);
}
#[test]
fn cargo_metadata_includes_target_directory_and_packages() {
let metadata = cargo_metadata();
assert!(metadata["target_directory"].is_string());
assert!(metadata["packages"].is_array());
}
#[test]
fn which_finds_binary_on_path() {
let dir = tempfile::tempdir().expect("tempdir");
let binary_name = if cfg!(windows) {
"mocktailwind.exe"
} else {
"mocktailwind"
};
let binary = dir.path().join(binary_name);
std::fs::write(&binary, "echo tailwind").expect("write binary");
let path = std::env::join_paths([dir.path()]).expect("join path");
temp_env::with_vars([("PATH", Some(path.as_os_str()))], || {
let found = which("mocktailwind").expect("binary on PATH");
assert_eq!(found, binary);
});
}
#[test]
fn which_returns_none_when_binary_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let path = std::env::join_paths([dir.path()]).expect("join path");
temp_env::with_vars([("PATH", Some(path.as_os_str()))], || {
assert!(which("definitely-missing-binary").is_none());
});
}
}