use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};
use serde::{Deserialize, Serialize};
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 DEFAULT_WATCH_DIRS: &[&str] = &["src", "static", "templates", "migrations"];
const AUTUMN_TOML: &str = "autumn.toml";
#[derive(Debug, Clone, Default, Deserialize)]
struct DevConfig {
#[serde(default)]
watch_dirs: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
struct AutumnTomlDevSlice {
#[serde(default)]
dev: DevConfig,
}
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";
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
const DEV_SHUTDOWN_SIGNAL_ENV: &str = "AUTUMN_SHUTDOWN_SIGNAL_FILE";
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
const DEV_SHUTDOWN_SIGNAL_PREFIX: &str = "dev-shutdown.";
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
const DEV_SHUTDOWN_SIGNAL_SUFFIX: &str = ".signal";
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
const SERVE_READY_FILE_ENV: &str = "AUTUMN_SERVE_READY_FILE";
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
const DEV_READY_FILE_PREFIX: &str = "dev-ready.";
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
const DEV_READY_FILE_SUFFIX: &str = ".state";
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
const MANAGED_PG_STOP_CEILING: Duration = Duration::from_secs(60);
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
const DELIVERY_AND_EXIT_MARGIN: Duration = Duration::from_secs(15);
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
const COOPERATIVE_STOP_HOOK_HEADROOM: Duration =
MANAGED_PG_STOP_CEILING.saturating_add(DELIVERY_AND_EXIT_MARGIN);
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
const COOPERATIVE_STOP_BOOT_CAP: Duration = Duration::from_mins(15);
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
const fn cooperative_stop_budget(drain_secs: u64) -> Duration {
Duration::from_secs(drain_secs).saturating_add(COOPERATIVE_STOP_HOOK_HEADROOM)
}
static CHILD_STOP_BUDGET_SECS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn record_child_stop_budget(budget: Duration) {
CHILD_STOP_BUDGET_SECS.store(budget.as_secs(), Ordering::SeqCst);
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn recorded_child_stop_budget() -> Option<Duration> {
match CHILD_STOP_BUDGET_SECS.load(Ordering::SeqCst) {
0 => None,
secs => Some(Duration::from_secs(secs)),
}
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn stop_budget_for_running_child(
reported: Option<Duration>,
recorded: Option<Duration>,
resolve_now: impl FnOnce() -> Duration,
) -> Duration {
reported.or(recorded).unwrap_or_else(resolve_now)
}
fn selected_member_dir(package: Option<&str>) -> Option<PathBuf> {
static DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
DIR.get_or_init(|| package.and_then(find_manifest_dir))
.clone()
}
fn package_dir(package: Option<&str>) -> PathBuf {
package_dir_from(selected_member_dir(package))
}
fn package_dir_from(member_dir: Option<PathBuf>) -> PathBuf {
member_dir
.or_else(|| {
std::env::var_os("AUTUMN_MANIFEST_DIR")
.filter(|dir| !dir.is_empty())
.map(PathBuf::from)
})
.unwrap_or_else(|| PathBuf::from("."))
}
static WATCH_ROOTS: std::sync::OnceLock<Vec<PathBuf>> = std::sync::OnceLock::new();
fn register_watch_roots(cwd: &Path, member_dir: Option<&Path>) {
let mut roots = Vec::new();
for dir in std::iter::once(cwd).chain(member_dir) {
roots.push(dir.to_path_buf());
if let Ok(canonical) = dir.canonicalize() {
roots.push(canonical);
}
}
roots.dedup();
let _ = WATCH_ROOTS.set(roots);
}
fn strip_watch_root<'a>(path: &'a Path, roots: &[PathBuf]) -> &'a Path {
roots
.iter()
.filter_map(|root| path.strip_prefix(root).ok())
.min_by_key(|rest| rest.components().count())
.unwrap_or(path)
}
fn member_dir_to_watch(member_dir: &Path, cwd: &Path) -> Option<PathBuf> {
let same = match (member_dir.canonicalize(), cwd.canonicalize()) {
(Ok(member), Ok(root)) => member == root,
_ => false,
};
(!same).then(|| member_dir.to_path_buf())
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn resolve_cooperative_stop_budget(package: Option<&str>) -> Duration {
let base_dir = package_dir(package);
let profile = crate::serve::effective_profile(None, false);
stop_budget_in(&base_dir, &profile)
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn stop_budget_in(base_dir: &Path, profile: &str) -> Duration {
let profile =
autumn_web::config::normalize_profile_name(profile).unwrap_or_else(|| "dev".to_owned());
let profile = profile.as_str();
let denv: Box<dyn autumn_web::config::Env> =
match autumn_web::dotenv::os_env_with_dotenv_in(base_dir, profile) {
Ok(env) => Box::new(env),
Err(_) => Box::new(autumn_web::config::OsEnv),
};
let (prestop, shutdown) =
crate::serve::resolve_shutdown_budget_from(base_dir, Some(profile), &|key| {
denv.var(key).ok().filter(|value| !value.trim().is_empty())
});
cooperative_stop_budget(prestop.saturating_add(shutdown))
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StopOutcome {
Graceful,
Escalated,
Unreachable,
Unsupported,
NotRunning,
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ShutdownSupport {
Supported,
Unsupported,
Unknown,
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn shutdown_support_from_ready(contents: Option<&str>) -> ShutdownSupport {
let Some(text) = contents else {
return ShutdownSupport::Unknown;
};
if autumn_web::app::serve_ready_advertises_shutdown_signal(text) {
return ShutdownSupport::Supported;
}
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
let budget_only =
lines.next().is_some_and(|l| l.parse::<u64>().is_ok()) && lines.next().is_none();
if budget_only {
ShutdownSupport::Unsupported
} else {
ShutdownSupport::Unknown
}
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn child_shutdown_support(ready_file: &Path) -> ShutdownSupport {
shutdown_support_from_ready(std::fs::read_to_string(ready_file).ok().as_deref())
}
#[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,
active_build_error: Option<(Vec<BuildDiagnostic>, bool)>,
}
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,
active_build_error: None,
};
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")?;
if let Some((diagnostics, stale)) = self.active_build_error.as_ref() {
let (diagnostics, stale) = (diagnostics.clone(), *stale);
self.write_build_error(&diagnostics, stale)
} else {
self.write(kind)
}
}
fn signal_build_error(
&mut self,
diagnostics: &[BuildDiagnostic],
stale: bool,
) -> Result<(), String> {
self.version = self
.version
.checked_add(1)
.ok_or("live reload version overflowed")?;
self.active_build_error = Some((diagnostics.to_vec(), stale));
self.write_build_error(diagnostics, stale)
}
fn signal_build_success(&mut self, kind: ReloadKind) -> Result<(), String> {
self.active_build_error = None;
let kind = if kind == ReloadKind::None {
ReloadKind::Full
} else {
kind
};
self.version = self
.version
.checked_add(1)
.ok_or("live reload version overflowed")?;
self.write(kind)
}
fn write_build_error(
&self,
diagnostics: &[BuildDiagnostic],
stale: bool,
) -> Result<(), String> {
let body = serde_json::json!({
"version": self.version,
"kind": "full",
"build_error": {
"diagnostics": diagnostics,
"stale": stale,
},
});
std::fs::write(&self.path, body.to_string())
.map_err(|e| format!("failed to write {}: {e}", self.path.display()))
}
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()))
}
}
const DEPENDENCY_AUDIT_DEADLINE: Duration = Duration::from_secs(30);
fn dependency_startup_lines(eval: Option<&crate::deps::Evaluation>) -> Vec<String> {
eval.map(crate::deps::dev_lines).unwrap_or_default()
}
struct DependencyAudit {
receiver: mpsc::Receiver<crate::deps::Evaluation>,
stop: crate::deps::StopHandle,
deadline: std::time::Instant,
done: bool,
}
impl DependencyAudit {
fn start(root: &Path) -> Self {
let running = crate::deps::spawn_evaluation_with_deadline(root, DEPENDENCY_AUDIT_DEADLINE);
Self {
receiver: running.receiver,
stop: running.stop,
deadline: std::time::Instant::now() + DEPENDENCY_AUDIT_DEADLINE,
done: false,
}
}
fn report(&mut self) {
if self.done {
return;
}
match self.receiver.try_recv() {
Ok(evaluation) => {
self.done = true;
for line in dependency_startup_lines(Some(&evaluation)) {
eprintln!("{line}");
}
}
Err(mpsc::TryRecvError::Disconnected) => self.done = true,
Err(mpsc::TryRecvError::Empty) => {
if std::time::Instant::now() >= self.deadline {
self.done = true;
self.stop.stop();
}
}
}
}
}
impl Drop for DependencyAudit {
fn drop(&mut self) {
if !self.done {
self.stop.stop();
}
}
}
pub fn run(package: Option<&str>, show_config: bool) {
eprintln!("\u{1F342} autumn dev\n");
if let Some(config) = crate::maintenance::check_status(None) {
eprintln!(" \u{26A0}\u{FE0F} MAINTENANCE MODE IS ON");
if let Some(msg) = &config.message {
eprintln!(" Message: {msg}");
}
eprintln!(" Run `autumn maintenance off` to disable.");
eprintln!();
}
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
}
};
let (built, diagnostics) = cargo_build_capturing(package);
if !built {
eprintln!("\u{2717} Initial build failed. Fix errors and save to retry.\n");
if let Some(state) = reload_state.as_mut()
&& let Err(error) = state.signal_build_error(&diagnostics, false)
{
eprintln!(" Warning: live reload signal failed: {error}");
}
}
let binary = find_binary(package, false);
let mut child = start_server(
&binary,
package,
reload_state.as_ref().map(DevReloadState::path),
show_config,
);
let member_dir = selected_member_dir(package);
let mut dependency_audit = DependencyAudit::start(Path::new("."));
let normalized_dirs = sanitize_custom_watch_dirs(load_dev_config(Path::new(AUTUMN_TOML)));
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let custom_watch_dirs = resolve_custom_watch_dirs(&normalized_dirs, &cwd);
register_watch_roots(&cwd, member_dir.as_deref());
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 DEFAULT_WATCH_DIRS {
let path = Path::new(dir);
if path.exists()
&& let Err(e) = watcher.watch(path, notify::RecursiveMode::Recursive)
{
eprintln!(" Warning: could not watch {dir}/: {e}");
}
}
for dir in &custom_watch_dirs {
let display = dir.relative.display();
if let Err(e) = watcher.watch(&dir.relative, notify::RecursiveMode::Recursive) {
eprintln!(" Warning: could not watch {display}/: {e}");
} else {
eprintln!(" Watching custom directory: {display}/");
}
}
if let Err(e) = watcher.watch(Path::new("."), notify::RecursiveMode::NonRecursive) {
eprintln!(" Warning: could not watch project root: {e}");
}
if let Some(dir) = member_dir.and_then(|dir| member_dir_to_watch(&dir, &cwd))
&& let Err(e) = watcher.watch(&dir, notify::RecursiveMode::NonRecursive)
{
eprintln!(" Warning: could not watch {}: {e}", dir.display());
}
eprintln!(" Watching for changes... (press Ctrl+C to stop)\n");
loop {
if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
eprintln!("\n Shutting down...");
break;
}
dependency_audit.report();
if !process_events(
&rx,
&custom_watch_dirs,
package,
&mut child,
reload_state.as_mut(),
show_config,
) {
break;
}
}
stop_server(&mut child, package);
}
fn load_dev_config(path: &Path) -> DevConfig {
let Ok(contents) = std::fs::read_to_string(path) else {
return DevConfig::default();
};
parse_dev_config(&contents).unwrap_or_else(|err| {
eprintln!(
" Warning: failed to parse [dev] section in {}: {err}",
path.display()
);
DevConfig::default()
})
}
fn parse_dev_config(toml_str: &str) -> Result<DevConfig, toml::de::Error> {
let parsed: AutumnTomlDevSlice = toml::from_str(toml_str)?;
Ok(parsed.dev)
}
fn normalize_watch_dir(raw: &str) -> Result<String, &'static str> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("entry is empty");
}
let path = Path::new(trimmed);
if path.is_absolute() {
return Err("absolute paths are not allowed; use a project-relative path");
}
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::Normal(part) => {
if part == std::ffi::OsStr::new("target") {
return Err("`target` is reserved for cargo build artifacts");
}
if part.to_string_lossy().starts_with('.') {
return Err("dotted directories (e.g. `.git`) are not allowed; \
the watcher would still pump their events");
}
normalized.push(part);
}
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
return Err("parent traversal (`..`) is not allowed");
}
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
return Err("absolute paths are not allowed; use a project-relative path");
}
}
}
if normalized.as_os_str().is_empty() {
return Err("entry resolves to an empty path");
}
Ok(normalized.to_string_lossy().into_owned())
}
#[derive(Debug, Clone)]
struct CustomWatchDir {
relative: PathBuf,
absolute: PathBuf,
}
impl CustomWatchDir {
fn matches(&self, event_path: &Path) -> bool {
event_path.starts_with(&self.absolute) || event_path.starts_with(&self.relative)
}
}
fn resolve_custom_watch_dirs(normalized: &[String], cwd: &Path) -> Vec<CustomWatchDir> {
normalized
.iter()
.filter_map(|rel| {
let relative = PathBuf::from(rel);
let cwd_joined = cwd.join(&relative);
if !cwd_joined.exists() {
eprintln!(" Warning: configured watch directory {rel}/ does not exist; skipping");
return None;
}
let absolute = std::fs::canonicalize(&cwd_joined).unwrap_or(cwd_joined);
Some(CustomWatchDir { relative, absolute })
})
.collect()
}
fn sanitize_custom_watch_dirs(config: DevConfig) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for raw in config.watch_dirs {
let normalized = match normalize_watch_dir(&raw) {
Ok(value) => value,
Err(reason) => {
eprintln!(" Warning: ignoring [dev].watch_dirs entry {raw:?}: {reason}");
continue;
}
};
if DEFAULT_WATCH_DIRS.contains(&normalized.as_str()) {
continue;
}
if !seen.contains(&normalized) {
seen.push(normalized);
}
}
seen
}
fn process_events(
rx: &mpsc::Receiver<Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>>,
custom_watch_dirs: &[CustomWatchDir],
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, custom_watch_dirs);
if plan.is_empty() {
return true;
}
let changed = collect_relevant_changes(&events, custom_watch_dirs);
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,
) {
if plan.build {
let stop_before_build = cfg!(windows);
if stop_before_build {
stop_server(child, package);
}
let (built, diagnostics) = cargo_build_capturing(package);
if built {
if !stop_before_build {
stop_server(child, package);
}
if restart_server(
package,
child,
reload_state.as_ref().map(|s| s.path()),
show_config,
) && let Some(reload_state) = reload_state.as_mut()
&& let Err(error) = reload_state.signal_build_success(ReloadKind::Full)
{
eprintln!(" Warning: live reload signal failed: {error}");
}
} else {
eprintln!(" \u{2717} Build failed. Waiting for changes...\n");
let stale = child.is_some();
if let Some(reload_state) = reload_state.as_mut()
&& let Err(error) = reload_state.signal_build_error(&diagnostics, stale)
{
eprintln!(" Warning: live reload signal failed: {error}");
}
}
return;
}
let mut applied_reload = ReloadKind::None;
if plan.tailwind && tailwind_build() {
applied_reload = applied_reload.max(ReloadKind::Css);
}
if plan.restart {
stop_server(child, package);
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()
&& let Err(error) = reload_state.signal(applied_reload)
{
eprintln!(" Warning: live reload signal failed: {error}");
}
}
fn collect_relevant_changes(
events: &[notify_debouncer_mini::DebouncedEvent],
custom_watch_dirs: &[CustomWatchDir],
) -> Vec<String> {
events
.iter()
.filter(|e| is_relevant_change(&e.path, e.kind, custom_watch_dirs))
.map(|e| e.path.display().to_string())
.collect()
}
fn plan_changes(
events: &[notify_debouncer_mini::DebouncedEvent],
custom_watch_dirs: &[CustomWatchDir],
) -> ChangePlan {
let mut plan = ChangePlan::default();
for event in events {
plan.register(classify_change(&event.path, event.kind, custom_watch_dirs));
}
plan.finalize()
}
pub fn build_cargo_command(package: Option<&str>, release: bool) -> Command {
let mut cmd = Command::new("cargo");
cmd.arg("build");
if release {
cmd.arg("--release");
}
if let Some(pkg) = package {
cmd.args(["-p", pkg]);
}
cmd
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct BuildDiagnostic {
code: Option<String>,
message: String,
file: String,
line: u32,
column: u32,
rendered: String,
}
fn parse_build_diagnostics(stdout: &[u8]) -> Vec<BuildDiagnostic> {
let text = String::from_utf8_lossy(stdout);
let mut diagnostics = Vec::new();
for line in text.lines() {
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if value.get("reason").and_then(serde_json::Value::as_str) != Some("compiler-message") {
continue;
}
let Some(message) = value.get("message") else {
continue;
};
if message.get("level").and_then(serde_json::Value::as_str) != Some("error") {
continue;
}
let code = message
.get("code")
.and_then(|code| code.get("code"))
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
let text_message = message
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_owned();
let rendered = message
.get("rendered")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_owned();
let primary = message
.get("spans")
.and_then(serde_json::Value::as_array)
.and_then(|spans| {
spans.iter().find(|span| {
span.get("is_primary").and_then(serde_json::Value::as_bool) == Some(true)
})
});
let (file, line, column) = primary.map_or_else(
|| (String::new(), 0, 0),
|span| {
let file = span
.get("file_name")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_owned();
let line = span
.get("line_start")
.and_then(serde_json::Value::as_u64)
.and_then(|n| u32::try_from(n).ok())
.unwrap_or(0);
let column = span
.get("column_start")
.and_then(serde_json::Value::as_u64)
.and_then(|n| u32::try_from(n).ok())
.unwrap_or(0);
(file, line, column)
},
);
diagnostics.push(BuildDiagnostic {
code,
message: text_message,
file,
line,
column,
rendered,
});
}
diagnostics
}
fn cargo_build_capturing(package: Option<&str>) -> (bool, Vec<BuildDiagnostic>) {
use std::io::{BufRead, BufReader};
let mut cmd = build_cargo_command(package, false);
cmd.arg("--message-format=json");
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::inherit());
eprintln!(" Compiling...");
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(e) => {
eprintln!(" \u{2717} Failed to run cargo build: {e}");
return (false, Vec::new());
}
};
let mut buffer = String::new();
if let Some(stdout) = child.stdout.take() {
let reader = BufReader::new(stdout);
for line in reader.lines() {
let Ok(line) = line else {
break;
};
if let Some(rendered) = compiler_message_rendered(&line) {
eprint!("{rendered}");
}
buffer.push_str(&line);
buffer.push('\n');
}
}
let diagnostics = parse_build_diagnostics(buffer.as_bytes());
match child.wait() {
Ok(status) if status.success() => {
crate::built_artifact::record(&crate::built_artifact::parse_executables(&buffer));
eprintln!(" \u{2713} Build succeeded");
(true, diagnostics)
}
Ok(_) => (false, diagnostics),
Err(e) => {
eprintln!(" \u{2717} cargo build failed: {e}");
(false, diagnostics)
}
}
}
fn compiler_message_rendered(line: &str) -> Option<String> {
let value = serde_json::from_str::<serde_json::Value>(line).ok()?;
if value.get("reason").and_then(serde_json::Value::as_str) != Some("compiler-message") {
return None;
}
let rendered = value
.get("message")?
.get("rendered")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if rendered.is_empty() {
return None;
}
Some(rendered.to_owned())
}
pub fn cargo_build(package: Option<&str>, release: bool) -> bool {
let mut cmd = build_cargo_command(package, release);
eprintln!(" Compiling...");
match crate::built_artifact::run_build(&mut cmd) {
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,
package: Option<&str>,
reload_state_path: Option<&Path>,
show_config: bool,
) -> Option<Child> {
eprintln!(" Starting server...\n");
let member_dir = selected_member_dir(package);
let dotenv_vars = match autumn_web::dotenv::resolve_process_dotenv_in(&package_dir_from(
member_dir.clone(),
)) {
Ok(v) => v,
Err(e) => {
eprintln!(" \u{274C} .env: {e}");
std::process::exit(1);
}
};
let mut command = Command::new(binary);
command.envs(dotenv_vars);
if let Some(dir) = &member_dir {
command.env("AUTUMN_MANIFEST_DIR", dir);
}
command.env_remove(crate::data_flow::DUMP_ENV);
command.env_remove(crate::agents::DUMP_ENV);
command.env_remove(crate::graph::DUMP_ENV);
crate::db::retention::clear_inherited_one_shot_env(&mut command);
crate::data_capsule::clear_inherited_one_shot_env(&mut command);
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");
}
#[cfg(not(unix))]
if let Some(paths) = begin_child_paths() {
command.env(DEV_SHUTDOWN_SIGNAL_ENV, &paths.signal);
command.env(SERVE_READY_FILE_ENV, &paths.ready);
record_child_stop_budget(resolve_cooperative_stop_budget(package));
}
match command.spawn() {
Ok(child) => Some(child),
Err(e) => {
eprintln!(" \u{2717} Failed to start {}: {e}", binary.display());
None
}
}
}
fn stop_server(child: &mut Option<Child>, package: Option<&str>) {
#[cfg(unix)]
{
let _ = package;
stop_server_unix(child);
}
#[cfg(not(unix))]
stop_server_cooperative(child, package);
}
#[cfg(unix)]
fn stop_server_unix(child: &mut Option<Child>) {
if let Some(proc) = child {
if let Some(pid) = crate::process::validate_pid_for_kill(proc.id())
&& 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 crate::process::wait_with_timeout(proc, Duration::from_secs(5)).is_err() {
let _ = proc.kill();
let _ = proc.wait();
}
}
*child = None;
}
#[cfg(not(unix))]
fn stop_server_cooperative(child: &mut Option<Child>, package: Option<&str>) {
let Some(paths) = current_child_paths() else {
if let Some(proc) = child.as_mut() {
let _ = proc.kill();
let _ = proc.wait();
eprintln!(
"{}",
unreachable_warning(
"cargo metadata is unreadable, or the target directory is not writable"
)
);
}
*child = None;
return;
};
let budget = std::cell::Cell::new(Duration::ZERO);
let outcome = stop_booted_child(child, &paths, COOPERATIVE_STOP_BOOT_CAP, || {
let resolved = stop_budget_for_running_child(
child_reported_stop_budget(&paths.ready),
recorded_child_stop_budget(),
|| resolve_cooperative_stop_budget(package),
);
budget.set(resolved);
resolved
});
match outcome {
StopOutcome::Unsupported => eprintln!("{}", unsupported_warning()),
StopOutcome::Escalated => eprintln!("{}", escalation_warning(budget.get())),
StopOutcome::Unreachable => eprintln!(
"{}",
unreachable_warning("the target directory is not writable")
),
StopOutcome::Graceful | StopOutcome::NotRunning => {}
}
}
fn is_relevant_change(
path: &Path,
kind: DebouncedEventKind,
custom_watch_dirs: &[CustomWatchDir],
) -> bool {
classify_change(path, kind, custom_watch_dirs) != ChangeEffect::Ignore
}
fn classify_change(
path: &Path,
kind: DebouncedEventKind,
custom_watch_dirs: &[CustomWatchDir],
) -> ChangeEffect {
let roots = WATCH_ROOTS.get().map_or(&[][..], Vec::as_slice);
classify_change_in(path, kind, custom_watch_dirs, roots)
}
fn classify_change_in(
path: &Path,
kind: DebouncedEventKind,
custom_watch_dirs: &[CustomWatchDir],
watch_roots: &[PathBuf],
) -> ChangeEffect {
if !matches!(kind, DebouncedEventKind::Any) {
return ChangeEffect::Ignore;
}
let original = path;
let path = strip_watch_root(path, watch_roots);
if is_watched_dotenv_file(path) {
return ChangeEffect::RestartOnly;
}
if 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;
}
for dir in custom_watch_dirs {
if dir.matches(original) {
return ChangeEffect::RestartOnly;
}
}
ChangeEffect::Ignore
}
const DOTENV_NON_LOADED_SUFFIXES: &[&str] =
&["swp", "swo", "swx", "swpx", "tmp", "bak", "orig", "example"];
fn is_dotenv_loader_filename(name: &str) -> bool {
if name == ".env" {
return true;
}
let Some(rest) = name.strip_prefix(".env.") else {
return false;
};
if rest.is_empty() || name.ends_with('~') {
return false;
}
let last_segment = rest.rsplit('.').next().unwrap_or(rest);
!DOTENV_NON_LOADED_SUFFIXES.contains(&last_segment)
}
fn is_watched_dotenv_file(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
if !is_dotenv_loader_filename(name) {
return false;
}
path.parent().is_none_or(|parent| {
parent.components().all(|component| {
if let std::path::Component::Normal(part) = component {
let part = part.to_string_lossy();
part != "target" && !part.starts_with('.')
} else {
true
}
})
})
}
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, false);
*child = start_server(&binary, package, 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))
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn dev_state_dir() -> Option<&'static Path> {
static DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
let metadata = try_cargo_metadata()?;
let target = metadata["target_directory"].as_str()?;
Some(Path::new(target).join("autumn"))
})
.as_deref()
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
#[derive(Debug, Clone, PartialEq, Eq)]
struct ChildPaths {
signal: PathBuf,
ready: PathBuf,
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn child_paths_in(dir: &Path, pid: u32, nonce: u128, generation: u64) -> ChildPaths {
ChildPaths {
signal: dir.join(format!(
"{DEV_SHUTDOWN_SIGNAL_PREFIX}{pid}.{nonce}.{generation}{DEV_SHUTDOWN_SIGNAL_SUFFIX}"
)),
ready: dir.join(format!(
"{DEV_READY_FILE_PREFIX}{pid}.{nonce}.{generation}{DEV_READY_FILE_SUFFIX}"
)),
}
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn run_nonce() -> u128 {
static NONCE: std::sync::OnceLock<u128> = std::sync::OnceLock::new();
*NONCE.get_or_init(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos())
})
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn state_dir_is_usable(dir: &Path, pid: u32, nonce: u128) -> bool {
if std::fs::create_dir_all(dir).is_err() {
return false;
}
let temporary = dir.join(format!("dev-probe.{pid}.{nonce}.tmp"));
let renamed = dir.join(format!("dev-probe.{pid}.{nonce}"));
let usable = std::fs::File::create(&temporary).is_ok()
&& std::fs::rename(&temporary, &renamed).is_ok()
&& std::fs::remove_file(&renamed).is_ok();
let _ = std::fs::remove_file(&temporary);
let _ = std::fs::remove_file(&renamed);
usable
}
static CHILD_PATHS: std::sync::Mutex<Option<ChildPaths>> = std::sync::Mutex::new(None);
static CHILD_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn begin_child_paths() -> Option<ChildPaths> {
next_child_paths(
&CHILD_PATHS,
&CHILD_GENERATION,
dev_state_dir(),
std::process::id(),
run_nonce(),
)
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn next_child_paths(
slot: &std::sync::Mutex<Option<ChildPaths>>,
generation: &std::sync::atomic::AtomicU64,
dir: Option<&Path>,
pid: u32,
nonce: u128,
) -> Option<ChildPaths> {
let previous = slot.lock().ok()?.take();
if let Some(previous) = previous {
let _ = std::fs::remove_file(previous.signal);
let _ = std::fs::remove_file(previous.ready);
}
let dir = dir?;
if !state_dir_is_usable(dir, pid, nonce) {
return None;
}
let number = generation.fetch_add(1, Ordering::SeqCst) + 1;
let paths = child_paths_in(dir, pid, nonce, number);
*slot.lock().ok()? = Some(paths.clone());
Some(paths)
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn current_child_paths() -> Option<ChildPaths> {
CHILD_PATHS.lock().ok()?.clone()
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn child_reported_stop_budget(ready_file: &Path) -> Option<Duration> {
let secs = std::fs::read_to_string(ready_file)
.ok()?
.lines()
.next()?
.trim()
.parse::<u64>()
.ok()?;
Some(cooperative_stop_budget(secs))
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn request_cooperative_shutdown(path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
std::fs::File::create(path).map(|_| ())
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn clear_cooperative_shutdown(path: &Path) {
let _ = std::fs::remove_file(path);
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn escalation_warning(timeout: Duration) -> String {
format!(
" Warning: the app did not shut down within {}s and was force-stopped. \
Its shutdown hooks may not have run, so a managed Postgres cluster may \
still be running and holding its data dir; the next start recovers the \
cluster through crash recovery, which is slower than a clean stop.",
timeout.as_secs()
)
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn unreachable_warning(error: &str) -> String {
format!(
" Warning: could not write the shutdown-signal file ({error}), so the app \
was force-stopped without being asked to drain. Its shutdown hooks did \
not run, so a managed Postgres cluster may still be running and holding \
its data dir; the next start recovers the cluster through crash \
recovery, which is slower than a clean stop."
)
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BootWait {
Ready,
Exited,
TimedOut,
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn wait_until_booted(proc: &mut Child, ready_file: Option<&Path>, cap: Duration) -> BootWait {
let Some(ready_file) = ready_file else {
return BootWait::Ready;
};
let start = std::time::Instant::now();
let mut told = false;
loop {
if ready_file.exists() {
return BootWait::Ready;
}
match proc.try_wait() {
Ok(Some(_)) => return BootWait::Exited,
Ok(None) if start.elapsed() < cap => {
if !told && start.elapsed() >= BOOT_WAIT_NOTICE_AFTER {
eprintln!("{}", boot_wait_notice(cap));
told = true;
}
std::thread::sleep(Duration::from_millis(50));
}
Ok(None) | Err(_) => return BootWait::TimedOut,
}
}
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
const BOOT_WAIT_NOTICE_AFTER: Duration = Duration::from_secs(10);
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn boot_wait_notice(cap: Duration) -> String {
format!(
" The app is still starting. Waiting for it before it is stopped, up to {} \
minutes. If this app uses an old autumn-web that never reports it is up, \
run `cargo update -p autumn-web`.",
cap.as_secs().div_ceil(60)
)
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn unsupported_warning() -> String {
" Warning: this app's autumn-web is older than the CLI and cannot drain on \
request, so it was force-stopped. Its shutdown hooks did not run. A managed \
Postgres cluster may still be running. The next start recovers it through \
crash recovery. To fix this, run `cargo update -p autumn-web`."
.to_owned()
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn stop_unsupported_child(child: &mut Option<Child>, signal_path: &Path) -> StopOutcome {
let Some(proc) = child.as_mut() else {
return StopOutcome::NotRunning;
};
let _ = proc.kill();
let _ = proc.wait();
clear_cooperative_shutdown(signal_path);
*child = None;
StopOutcome::Unsupported
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn stop_with_support(
child: &mut Option<Child>,
signal_path: &Path,
support: ShutdownSupport,
budget: Duration,
) -> StopOutcome {
match support {
ShutdownSupport::Unsupported => stop_unsupported_child(child, signal_path),
ShutdownSupport::Supported | ShutdownSupport::Unknown => {
stop_child_cooperatively(child, signal_path, budget)
}
}
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn stop_child_cooperatively(
child: &mut Option<Child>,
signal_path: &Path,
timeout: Duration,
) -> StopOutcome {
let Some(proc) = child.as_mut() else {
return StopOutcome::NotRunning;
};
let outcome = match request_cooperative_shutdown(signal_path) {
Err(_) => {
let _ = proc.kill();
let _ = proc.wait();
StopOutcome::Unreachable
}
Ok(()) if crate::process::wait_with_timeout(proc, timeout).is_ok() => StopOutcome::Graceful,
Ok(()) => {
let _ = proc.kill();
let _ = proc.wait();
StopOutcome::Escalated
}
};
clear_cooperative_shutdown(signal_path);
*child = None;
outcome
}
#[cfg_attr(
unix,
allow(
dead_code,
reason = "the non-Unix stop path, compiled and tested everywhere"
)
)]
fn stop_booted_child(
child: &mut Option<Child>,
paths: &ChildPaths,
boot_cap: Duration,
budget: impl FnOnce() -> Duration,
) -> StopOutcome {
let Some(proc) = child.as_mut() else {
return StopOutcome::NotRunning;
};
let outcome = if wait_until_booted(proc, Some(&paths.ready), boot_cap) == BootWait::Exited {
clear_cooperative_shutdown(&paths.signal);
*child = None;
StopOutcome::Graceful
} else {
let support = child_shutdown_support(&paths.ready);
stop_with_support(child, &paths.signal, support, budget())
};
let _ = std::fs::remove_file(&paths.ready);
outcome
}
pub 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())
}
pub fn try_resolve_target_directory() -> Option<PathBuf> {
let mut cargo = Command::new("cargo");
cargo.args(["metadata", "--format-version=1", "--no-deps"]);
crate::deps::no_toolchain_installs(&mut cargo);
let output = cargo.output().ok()?;
if !output.status.success() {
return None;
}
serde_json::from_slice::<serde_json::Value>(&output.stdout)
.ok()?
.get("target_directory")?
.as_str()
.map(PathBuf::from)
}
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");
eprintln!("{}", String::from_utf8_lossy(&output.stderr));
std::process::exit(1);
}
serde_json::from_slice(&output.stdout).expect("parse cargo metadata")
}
fn try_cargo_metadata() -> Option<serde_json::Value> {
let output = Command::new("cargo")
.args(["metadata", "--format-version=1", "--no-deps"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
serde_json::from_slice(&output.stdout).ok()
}
#[must_use]
pub fn find_manifest_dir(package: &str) -> Option<PathBuf> {
let metadata = try_cargo_metadata()?;
metadata["packages"].as_array()?.iter().find_map(|pkg| {
if pkg["name"].as_str() == Some(package) {
Path::new(pkg["manifest_path"].as_str()?)
.parent()
.map(Path::to_path_buf)
} else {
None
}
})
}
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| {
if let Some(name) = pkg["default_run"].as_str() {
return Some(name.to_owned());
}
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)
}
pub fn find_binary(package: Option<&str>, release: bool) -> PathBuf {
let metadata = cargo_metadata();
let cwd = std::env::current_dir().expect("current dir");
let path = 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);
});
if release {
if let (Some(target_dir), Some(bin)) =
(path.parent().and_then(Path::parent), path.file_name())
{
return crate::built_artifact::prefer_built(target_dir.join("release").join(bin));
}
}
crate::built_artifact::prefer_built(path)
}
#[cfg(test)]
mod tests {
fn signal_path(dir: &Path) -> PathBuf {
dir.join("dev-shutdown.signal")
}
#[test]
fn request_cooperative_shutdown_creates_the_file_the_runtime_watches() {
let tmp = tempfile::TempDir::new().unwrap();
let path = signal_path(tmp.path());
request_cooperative_shutdown(&path).expect("signal file should be creatable");
assert!(path.exists(), "the runtime keys off existence alone");
}
#[test]
fn request_cooperative_shutdown_creates_missing_parent_directories() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("nested").join("autumn").join("stop.signal");
request_cooperative_shutdown(&path).expect("parents should be created");
assert!(path.exists());
}
#[test]
fn clear_cooperative_shutdown_removes_a_stale_signal() {
let tmp = tempfile::TempDir::new().unwrap();
let path = signal_path(tmp.path());
std::fs::write(&path, b"stop").unwrap();
clear_cooperative_shutdown(&path);
assert!(!path.exists());
}
#[test]
fn clear_cooperative_shutdown_is_a_no_op_when_absent() {
let tmp = tempfile::TempDir::new().unwrap();
clear_cooperative_shutdown(&signal_path(tmp.path()));
}
#[cfg(unix)]
#[test]
fn cooperative_stop_lets_a_child_that_honours_the_signal_exit_on_its_own() {
let tmp = tempfile::TempDir::new().unwrap();
let path = signal_path(tmp.path());
let script = format!(
"while [ ! -f {} ]; do sleep 0.05; done; exit 0",
path.display()
);
let mut child = Some(
Command::new("/bin/sh")
.arg("-c")
.arg(script)
.spawn()
.expect("spawn stand-in app"),
);
let outcome = stop_child_cooperatively(&mut child, &path, Duration::from_secs(10));
assert_eq!(outcome, StopOutcome::Graceful);
assert!(child.is_none(), "a stopped child must be reaped");
assert!(
!path.exists(),
"the signal must be cleared so the next child does not drain at boot"
);
}
#[cfg(unix)]
#[test]
fn cooperative_stop_escalates_when_the_child_ignores_the_signal() {
let tmp = tempfile::TempDir::new().unwrap();
let path = signal_path(tmp.path());
let mut child = Some(
Command::new("/bin/sh")
.arg("-c")
.arg("trap '' TERM; sleep 30")
.spawn()
.expect("spawn unresponsive app"),
);
let outcome = stop_child_cooperatively(&mut child, &path, Duration::from_millis(300));
assert_eq!(outcome, StopOutcome::Escalated);
assert!(child.is_none(), "escalation must still reap the child");
assert!(!path.exists());
}
#[cfg(unix)]
#[test]
fn cooperative_stop_reports_unreachable_when_the_request_cannot_be_written() {
let tmp = tempfile::TempDir::new().unwrap();
let blocker = tmp.path().join("blocker");
std::fs::write(&blocker, b"not a directory").unwrap();
let path = blocker.join("nested").join("stop.signal");
let mut child = Some(
Command::new("/bin/sh")
.arg("-c")
.arg("sleep 30")
.spawn()
.expect("spawn app"),
);
let outcome = stop_child_cooperatively(&mut child, &path, Duration::from_millis(200));
assert_eq!(outcome, StopOutcome::Unreachable);
assert!(child.is_none(), "the child must still be stopped");
}
const BOOT_CAP: Duration = Duration::from_secs(30);
fn capable_ready_payload() -> String {
autumn_web::app::serve_ready_payload(30, "tcp 127.0.0.1:3000")
}
fn child_paths(dir: &Path) -> ChildPaths {
ChildPaths {
signal: signal_path(dir),
ready: dir.join("dev-ready.state"),
}
}
#[cfg(unix)]
#[test]
fn a_stop_during_boot_waits_for_ready_before_it_starts_the_deadline() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = child_paths(tmp.path());
let payload = tmp.path().join("payload");
std::fs::write(&payload, capable_ready_payload()).unwrap();
let script = format!(
"sleep 1; cp {p} {r}; while [ ! -f {s} ]; do sleep 0.05; done; exit 0",
p = payload.display(),
r = paths.ready.display(),
s = paths.signal.display()
);
let mut child = Some(
Command::new("/bin/sh")
.arg("-c")
.arg(script)
.spawn()
.expect("spawn booting app"),
);
let outcome = stop_booted_child(&mut child, &paths, BOOT_CAP, || Duration::from_secs(5));
assert_eq!(outcome, StopOutcome::Graceful);
}
#[cfg(unix)]
#[test]
fn a_stop_during_boot_is_graceful_when_the_app_exits_before_ready() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = child_paths(tmp.path());
let mut child = Some(
Command::new("/bin/sh")
.arg("-c")
.arg("sleep 0.5; exit 0")
.spawn()
.expect("spawn app"),
);
let outcome =
stop_booted_child(&mut child, &paths, BOOT_CAP, || Duration::from_millis(100));
assert_eq!(outcome, StopOutcome::Graceful);
assert!(child.is_none(), "a stopped child must be dropped");
}
#[cfg(unix)]
#[test]
fn an_old_app_is_stopped_at_once_when_it_finishes_booting() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = child_paths(tmp.path());
let script = format!("sleep 0.5; echo 30 > {}; sleep 30", paths.ready.display());
let mut child = Some(
Command::new("/bin/sh")
.arg("-c")
.arg(script)
.spawn()
.expect("spawn old app"),
);
let started = std::time::Instant::now();
let outcome = stop_booted_child(&mut child, &paths, BOOT_CAP, || Duration::from_secs(20));
assert_eq!(outcome, StopOutcome::Unsupported);
assert!(started.elapsed() < Duration::from_secs(10));
assert!(child.is_none(), "the child must be reaped");
}
#[cfg(unix)]
#[test]
fn a_missing_ready_file_does_not_stall_a_stop_past_cap_plus_budget() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = child_paths(tmp.path());
let mut child = Some(
Command::new("/bin/sh")
.arg("-c")
.arg("trap '' TERM; sleep 30")
.spawn()
.expect("spawn app"),
);
let started = std::time::Instant::now();
let outcome = stop_booted_child(&mut child, &paths, Duration::from_millis(300), || {
Duration::from_millis(300)
});
assert_eq!(outcome, StopOutcome::Escalated);
assert!(started.elapsed() < Duration::from_secs(10));
assert!(child.is_none(), "the child must be reaped");
}
#[cfg(unix)]
#[test]
fn a_missing_ready_file_is_fine_when_the_app_obeys_the_signal() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = child_paths(tmp.path());
let script = format!(
"while [ ! -f {} ]; do sleep 0.05; done; exit 0",
paths.signal.display()
);
let mut child = Some(
Command::new("/bin/sh")
.arg("-c")
.arg(script)
.spawn()
.expect("spawn app"),
);
let outcome = stop_booted_child(&mut child, &paths, Duration::from_millis(200), || {
Duration::from_secs(10)
});
assert_eq!(outcome, StopOutcome::Graceful);
}
#[cfg(unix)]
#[test]
fn a_stop_clears_the_ready_file_for_the_next_child() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = child_paths(tmp.path());
std::fs::write(&paths.ready, capable_ready_payload()).unwrap();
let mut child = Some(
Command::new("/bin/sh")
.arg("-c")
.arg("exit 0")
.spawn()
.expect("spawn app"),
);
stop_booted_child(&mut child, &paths, BOOT_CAP, || Duration::from_secs(5));
assert!(!paths.ready.exists());
}
fn the_shell_hides_dotenv_effects() -> bool {
["AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "AUTUMN_DOTENV"]
.iter()
.any(|key| std::env::var_os(key).is_some())
}
fn dotenv_dir_with_a_300s_timeout() -> tempfile::TempDir {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join(".env"),
"AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS=300\n",
)
.unwrap();
tmp
}
#[test]
fn the_stop_budget_reads_dotenv_from_the_package_dir_not_the_cli_cwd() {
if the_shell_hides_dotenv_effects() {
return;
}
let tmp = dotenv_dir_with_a_300s_timeout();
let budget = stop_budget_in(tmp.path(), "dev");
assert!(
budget >= cooperative_stop_budget(300),
"package .env must set the budget, got {budget:?}"
);
}
#[test]
fn the_stop_budget_loads_dotenv_for_a_profile_alias() {
if the_shell_hides_dotenv_effects() {
return;
}
let tmp = dotenv_dir_with_a_300s_timeout();
for alias in ["development", "DEV", " dev "] {
let budget = stop_budget_in(tmp.path(), alias);
assert!(
budget >= cooperative_stop_budget(300),
"`{alias}` must load .env, got {budget:?}"
);
}
}
#[test]
fn the_boot_wait_notice_names_the_limit_and_the_fix() {
let notice = boot_wait_notice(COOPERATIVE_STOP_BOOT_CAP);
assert!(notice.contains("15 minutes"), "{notice}");
assert!(notice.contains("cargo update -p autumn-web"), "{notice}");
}
#[test]
fn unreachable_warning_blames_the_signal_file_not_the_app() {
let warning = unreachable_warning("permission denied");
assert!(warning.contains("permission denied"), "{warning}");
assert!(warning.contains("shutdown hooks"), "{warning}");
assert!(!warning.contains("did not shut down within"), "{warning}");
}
#[test]
fn cooperative_stop_reports_not_running_without_a_child() {
let tmp = tempfile::TempDir::new().unwrap();
let path = signal_path(tmp.path());
let mut child = None;
assert_eq!(
stop_child_cooperatively(&mut child, &path, Duration::from_secs(1)),
StopOutcome::NotRunning
);
}
#[test]
fn the_stop_budget_never_undercuts_the_apps_configured_drain() {
for drain_secs in [0, 1, 10, 35, 120, 3600] {
let budget = cooperative_stop_budget(drain_secs);
assert!(
budget > Duration::from_secs(drain_secs),
"a {drain_secs}s drain must not be cut short by a {budget:?} budget"
);
}
}
#[test]
fn the_stop_budget_covers_the_managed_postgres_stop_ceiling() {
assert!(
cooperative_stop_budget(0) >= Duration::from_secs(60),
"the headroom must cover a managed-Postgres stop"
);
}
#[test]
fn the_stop_budget_grows_with_a_longer_configured_drain() {
assert!(cooperative_stop_budget(35) > cooperative_stop_budget(1));
}
#[test]
fn the_stop_budget_saturates_instead_of_overflowing() {
let budget = cooperative_stop_budget(u64::MAX);
assert!(budget >= Duration::from_secs(60), "{budget:?}");
}
#[test]
fn a_child_reported_budget_beats_everything_the_cli_can_reconstruct() {
let budget = stop_budget_for_running_child(
Some(Duration::from_secs(360)),
Some(Duration::from_secs(61)),
|| Duration::from_secs(95),
);
assert_eq!(budget, Duration::from_secs(360));
}
#[test]
fn the_recorded_budget_is_used_when_the_child_reported_nothing() {
let budget = stop_budget_for_running_child(None, Some(Duration::from_secs(61)), || {
Duration::from_secs(95)
});
assert_eq!(budget, Duration::from_secs(61));
}
#[test]
fn resolving_is_the_last_resort() {
let budget = stop_budget_for_running_child(None, None, || Duration::from_secs(95));
assert_eq!(budget, Duration::from_secs(95));
}
#[test]
fn the_reported_budget_gets_the_same_hook_headroom() {
let tmp = tempfile::TempDir::new().unwrap();
let ready = tmp.path().join("dev-ready.state");
std::fs::write(&ready, "300\n").unwrap();
assert_eq!(
child_reported_stop_budget(&ready),
Some(cooperative_stop_budget(300))
);
}
#[test]
fn the_budget_survives_the_readiness_file_gaining_an_address_line() {
let tmp = tempfile::TempDir::new().unwrap();
let ready = tmp.path().join("dev-ready.state");
std::fs::write(
&ready,
autumn_web::app::serve_ready_payload(300, "tcp 127.0.0.1:3000"),
)
.unwrap();
assert_eq!(
child_reported_stop_budget(&ready),
Some(cooperative_stop_budget(300))
);
}
#[test]
fn a_missing_or_unparseable_report_is_ignored_rather_than_misread() {
let tmp = tempfile::TempDir::new().unwrap();
let ready = tmp.path().join("dev-ready.state");
assert_eq!(child_reported_stop_budget(&ready), None, "absent");
std::fs::write(&ready, "not-a-number").unwrap();
assert_eq!(child_reported_stop_budget(&ready), None, "junk");
std::fs::write(&ready, " ").unwrap();
assert_eq!(child_reported_stop_budget(&ready), None, "blank");
}
#[test]
fn the_ready_file_env_var_is_the_one_the_runtime_reads() {
assert_eq!(SERVE_READY_FILE_ENV, "AUTUMN_SERVE_READY_FILE");
assert_eq!(SERVE_READY_FILE_ENV, crate::serve::SERVE_READY_FILE_ENV);
}
#[test]
fn the_stop_budget_uses_the_value_recorded_when_the_child_was_spawned() {
let recorded = Some(Duration::from_secs(300));
let budget = stop_budget_for_running_child(None, recorded, || Duration::from_secs(95));
assert_eq!(budget, Duration::from_secs(300));
}
#[test]
fn the_stop_budget_does_not_re_read_config_when_one_was_recorded() {
let consulted = std::cell::Cell::new(false);
let _ = stop_budget_for_running_child(None, Some(Duration::from_secs(300)), || {
consulted.set(true);
Duration::from_secs(95)
});
assert!(
!consulted.get(),
"a recorded budget must not re-read the (possibly just-edited) config"
);
}
#[test]
fn the_stop_budget_falls_back_when_nothing_was_recorded() {
let budget = stop_budget_for_running_child(None, None, || Duration::from_secs(95));
assert_eq!(budget, Duration::from_secs(95));
}
#[test]
fn the_resolved_budget_covers_an_unconfigured_project() {
assert!(resolve_cooperative_stop_budget(None) >= COOPERATIVE_STOP_HOOK_HEADROOM);
}
fn managed_pg_stop_ceiling_from_source() -> Duration {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../autumn/src/managed_pg.rs");
let source = std::fs::read_to_string(&path).expect("read managed_pg.rs");
let secs = source
.lines()
.find(|l| l.contains("const STARTUP_TIMEOUT: Duration"))
.and_then(|l| l.split("from_secs(").nth(1))
.and_then(|l| l.split(')').next())
.and_then(|n| n.trim().parse::<u64>().ok())
.expect("parse STARTUP_TIMEOUT");
Duration::from_secs(secs)
}
#[test]
fn the_ceiling_constant_matches_the_framework() {
assert_eq!(
MANAGED_PG_STOP_CEILING,
managed_pg_stop_ceiling_from_source()
);
}
#[test]
fn the_headroom_exceeds_the_managed_pg_stop_ceiling() {
assert!(COOPERATIVE_STOP_HOOK_HEADROOM > managed_pg_stop_ceiling_from_source());
}
#[test]
fn escalation_warning_names_the_consequence_not_just_the_timeout() {
let warning = escalation_warning(Duration::from_secs(10));
assert!(warning.contains("10s"), "{warning}");
assert!(warning.contains("shutdown hooks"), "{warning}");
assert!(warning.contains("Postgres"), "{warning}");
}
#[test]
fn child_paths_sit_in_the_given_directory() {
let dir = Path::new("target").join("autumn");
let paths = child_paths_in(&dir, 7, 1, 1);
assert_eq!(paths.signal.parent(), Some(dir.as_path()));
assert_eq!(paths.ready.parent(), Some(dir.as_path()));
}
#[test]
fn child_paths_carry_the_process_id_and_the_generation() {
let dir = Path::new("d");
let first = child_paths_in(dir, 42, 5, 1);
let name = first.signal.file_name().and_then(|n| n.to_str()).unwrap();
assert!(name.starts_with(DEV_SHUTDOWN_SIGNAL_PREFIX), "{name}");
assert!(name.ends_with(DEV_SHUTDOWN_SIGNAL_SUFFIX), "{name}");
assert!(name.contains("42"), "{name}");
assert_ne!(first, child_paths_in(dir, 43, 5, 1), "other process");
assert_ne!(first, child_paths_in(dir, 42, 6, 1), "same id, later run");
}
#[test]
fn a_writable_state_dir_is_usable_and_is_created_when_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("target").join("autumn");
assert!(state_dir_is_usable(&dir, 7, 1));
assert!(dir.is_dir());
assert_eq!(
std::fs::read_dir(&dir).unwrap().count(),
0,
"the probe file must not stay"
);
}
#[test]
fn a_probe_file_left_by_an_earlier_run_does_not_block_this_one() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("dev-probe.7.1"), b"left behind").unwrap();
assert!(state_dir_is_usable(tmp.path(), 7, 2));
assert!(
tmp.path().join("dev-probe.7.1").exists(),
"the other run's file is not touched"
);
}
#[test]
fn a_state_dir_that_cannot_exist_is_not_usable() {
let tmp = tempfile::TempDir::new().unwrap();
let blocker = tmp.path().join("autumn");
std::fs::write(&blocker, b"not a directory").unwrap();
assert!(!state_dir_is_usable(&blocker, 7, 1));
assert!(!state_dir_is_usable(&blocker.join("nested"), 7, 1));
}
#[test]
fn a_failed_setup_forgets_the_last_childs_paths() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("autumn");
let slot = std::sync::Mutex::new(None);
let generation = std::sync::atomic::AtomicU64::new(0);
let first = next_child_paths(&slot, &generation, Some(&dir), 7, 1).expect("first child");
assert_eq!(*slot.lock().unwrap(), Some(first.clone()));
std::fs::write(&first.signal, b"").unwrap();
std::fs::write(&first.ready, b"30\n").unwrap();
std::fs::remove_dir_all(&dir).unwrap();
std::fs::write(&dir, b"not a directory").unwrap();
assert_eq!(next_child_paths(&slot, &generation, Some(&dir), 7, 1), None);
assert_eq!(*slot.lock().unwrap(), None, "the old paths must be gone");
assert_eq!(next_child_paths(&slot, &generation, None, 7, 1), None);
assert_eq!(*slot.lock().unwrap(), None);
}
#[test]
fn a_new_child_removes_the_last_childs_files() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("autumn");
let slot = std::sync::Mutex::new(None);
let generation = std::sync::atomic::AtomicU64::new(0);
let first = next_child_paths(&slot, &generation, Some(&dir), 7, 1).unwrap();
std::fs::write(&first.signal, b"").unwrap();
std::fs::write(&first.ready, b"30\n").unwrap();
let second = next_child_paths(&slot, &generation, Some(&dir), 7, 1).unwrap();
assert_ne!(first, second);
assert!(!first.signal.exists() && !first.ready.exists());
assert_eq!(*slot.lock().unwrap(), Some(second));
}
#[test]
fn each_child_gets_paths_no_earlier_child_used() {
let dir = Path::new("d");
let first = child_paths_in(dir, 42, 5, 1);
let second = child_paths_in(dir, 42, 5, 2);
assert_ne!(first.signal, second.signal);
assert_ne!(first.ready, second.ready);
}
#[test]
fn the_state_dir_is_cached_across_calls() {
assert_eq!(dev_state_dir(), dev_state_dir());
}
#[test]
fn dev_shutdown_env_var_matches_the_runtime_constant() {
assert_eq!(
DEV_SHUTDOWN_SIGNAL_ENV,
autumn_web::app::SHUTDOWN_SIGNAL_FILE_ENV,
"the CLI and the runtime must agree on the wire name"
);
}
#[test]
fn a_ready_file_with_the_marker_means_the_child_will_drain() {
let payload = autumn_web::app::serve_ready_payload(1, "tcp 127.0.0.1:3000");
assert_eq!(
shutdown_support_from_ready(Some(&payload)),
ShutdownSupport::Supported
);
}
#[test]
fn a_budget_only_ready_file_means_the_child_cannot_drain() {
for old in ["1", "1\n", " 1 \n\n"] {
assert_eq!(
shutdown_support_from_ready(Some(old)),
ShutdownSupport::Unsupported,
"{old:?}"
);
}
}
#[test]
fn a_release_with_an_endpoint_line_but_no_marker_still_drains() {
assert_eq!(
shutdown_support_from_ready(Some("1\ntcp 127.0.0.1:3000")),
ShutdownSupport::Unknown
);
}
#[test]
fn an_unreadable_ready_file_is_not_proof_of_an_old_child() {
for junk in ["garbage", "x\ny", "1 2"] {
assert_eq!(
shutdown_support_from_ready(Some(junk)),
ShutdownSupport::Unknown,
"{junk:?}"
);
}
}
#[test]
fn no_ready_file_means_the_child_is_still_booting() {
assert_eq!(shutdown_support_from_ready(None), ShutdownSupport::Unknown);
}
#[test]
fn a_blank_ready_file_is_not_proof_of_an_old_child() {
assert_eq!(
shutdown_support_from_ready(Some(" \n")),
ShutdownSupport::Unknown
);
}
#[test]
fn the_ready_file_support_is_read_from_disk() {
let tmp = tempfile::TempDir::new().unwrap();
let ready = tmp.path().join("dev-ready.state");
assert_eq!(child_shutdown_support(&ready), ShutdownSupport::Unknown);
std::fs::write(&ready, "1\n").unwrap();
assert_eq!(child_shutdown_support(&ready), ShutdownSupport::Unsupported);
std::fs::write(&ready, autumn_web::app::serve_ready_payload(1, "")).unwrap();
assert_eq!(child_shutdown_support(&ready), ShutdownSupport::Supported);
}
#[cfg(unix)]
#[test]
fn an_unsupported_child_is_killed_at_once_without_a_signal_file() {
let tmp = tempfile::TempDir::new().unwrap();
let path = signal_path(tmp.path());
let mut child = Some(
Command::new("/bin/sh")
.arg("-c")
.arg("trap '' TERM; sleep 30")
.spawn()
.expect("spawn old app"),
);
let start = std::time::Instant::now();
let outcome = stop_unsupported_child(&mut child, &path);
assert_eq!(outcome, StopOutcome::Unsupported);
assert!(start.elapsed() < Duration::from_secs(10), "must not wait");
assert!(child.is_none(), "the child must be reaped");
assert!(
!path.exists(),
"no request is sent to a child that ignores it"
);
}
#[cfg(unix)]
#[test]
fn an_unsupported_child_is_never_sent_a_request() {
let tmp = tempfile::TempDir::new().unwrap();
let blocker = tmp.path().join("blocker");
std::fs::write(&blocker, b"not a directory").unwrap();
let path = blocker.join("nested").join("stop.signal");
let mut child = Some(
Command::new("/bin/sh")
.arg("-c")
.arg("sleep 30")
.spawn()
.expect("spawn old app"),
);
assert_eq!(
stop_unsupported_child(&mut child, &path),
StopOutcome::Unsupported
);
}
#[cfg(unix)]
#[test]
fn the_stop_plan_kills_an_unsupported_child_without_waiting_out_the_budget() {
let tmp = tempfile::TempDir::new().unwrap();
let path = signal_path(tmp.path());
let mut child = Some(
Command::new("/bin/sh")
.arg("-c")
.arg("trap '' TERM; sleep 30")
.spawn()
.expect("spawn old app"),
);
let start = std::time::Instant::now();
let outcome = stop_with_support(
&mut child,
&path,
ShutdownSupport::Unsupported,
Duration::from_secs(300),
);
assert_eq!(outcome, StopOutcome::Unsupported);
assert!(start.elapsed() < Duration::from_secs(10), "must not wait");
}
#[cfg(unix)]
#[test]
fn the_stop_plan_keeps_the_cooperative_wait_when_support_is_unknown() {
let tmp = tempfile::TempDir::new().unwrap();
let path = signal_path(tmp.path());
let script = format!(
"while [ ! -f {} ]; do sleep 0.05; done; exit 0",
path.display()
);
for support in [ShutdownSupport::Unknown, ShutdownSupport::Supported] {
let mut child = Some(
Command::new("/bin/sh")
.arg("-c")
.arg(&script)
.spawn()
.expect("spawn app"),
);
let outcome = stop_with_support(&mut child, &path, support, Duration::from_secs(10));
assert_eq!(outcome, StopOutcome::Graceful, "{support:?}");
}
}
#[test]
fn an_unsupported_stop_with_no_child_is_not_running() {
let tmp = tempfile::TempDir::new().unwrap();
let mut child = None;
assert_eq!(
stop_unsupported_child(&mut child, &signal_path(tmp.path())),
StopOutcome::NotRunning
);
}
#[test]
fn the_unsupported_warning_names_the_cause_and_the_fix() {
let warning = unsupported_warning();
assert!(warning.contains("autumn-web"), "{warning}");
assert!(warning.contains("cargo update"), "{warning}");
assert!(warning.contains("Postgres"), "{warning}");
assert!(!warning.contains("did not shut down within"), "{warning}");
}
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 dotenv_change_requires_restart_only() {
for name in [".env", ".env.local", ".env.dev", ".env.production.local"] {
assert_eq!(
classify_change(Path::new(name), DebouncedEventKind::Any, &[]),
ChangeEffect::RestartOnly,
"{name} should restart the dev server",
);
}
}
#[test]
fn dotenv_editor_temp_files_are_ignored() {
for name in [
".env.swp",
".env.swo",
".env~",
".env.local~",
".env.tmp",
".env.bak",
] {
assert_eq!(
classify_change(Path::new(name), DebouncedEventKind::Any, &[]),
ChangeEffect::Ignore,
"{name} should not restart the dev server",
);
}
}
#[test]
fn dotenv_change_ignored_for_non_any_event() {
assert_eq!(
classify_change(Path::new(".env"), DebouncedEventKind::AnyContinuous, &[]),
ChangeEffect::Ignore
);
}
#[test]
fn nested_dotenv_under_hidden_dir_is_ignored() {
assert_eq!(
classify_change(Path::new(".git/.env"), DebouncedEventKind::Any, &[]),
ChangeEffect::Ignore
);
}
#[test]
fn a_member_dir_outside_the_root_is_watched() {
let root = tempfile::TempDir::new().unwrap();
let member = root.path().join("api");
std::fs::create_dir(&member).unwrap();
assert_eq!(member_dir_to_watch(&member, root.path()), Some(member));
}
#[test]
fn a_dotenv_under_a_hidden_ancestor_is_judged_inside_the_watch_root() {
let member = PathBuf::from("/home/alice/.worktrees/project/api");
let roots = vec![member.clone()];
let event = member.join(".env");
assert!(
!is_watched_dotenv_file(&event),
"the full path is rejected, which is the bug"
);
let inside = strip_watch_root(&event, &roots);
assert_eq!(inside, Path::new(".env"));
assert!(is_watched_dotenv_file(inside));
}
#[test]
fn the_most_specific_watch_root_wins_when_roots_nest() {
let workspace = PathBuf::from("/work");
let member = workspace.join(".apps").join("api");
let event = member.join(".env");
for roots in [
vec![workspace.clone(), member.clone()],
vec![member, workspace],
] {
let inside = strip_watch_root(&event, &roots);
assert_eq!(inside, Path::new(".env"), "roots: {roots:?}");
assert!(is_watched_dotenv_file(inside));
}
}
#[test]
fn a_custom_watch_dir_still_matches_by_the_full_event_path() {
let workspace = PathBuf::from("/work");
let member = workspace.join("crates").join("api");
let views = member.join("views");
let custom = vec![CustomWatchDir {
relative: PathBuf::from("crates/api/views"),
absolute: views.clone(),
}];
let roots = vec![workspace, member];
let effect = classify_change_in(
&views.join("page.html"),
DebouncedEventKind::Any,
&custom,
&roots,
);
assert_eq!(effect, ChangeEffect::RestartOnly);
}
#[test]
fn a_hidden_directory_inside_the_watch_root_is_still_ignored() {
let root = PathBuf::from("/home/alice/.worktrees/project");
let event = root.join(".git").join(".env");
assert!(!is_watched_dotenv_file(strip_watch_root(&event, &[root])));
}
#[test]
fn a_path_outside_every_watch_root_is_left_alone() {
let roots = vec![PathBuf::from("/work/api")];
let event = Path::new("/elsewhere/.env");
assert_eq!(strip_watch_root(event, &roots), event);
assert_eq!(strip_watch_root(event, &[]), event);
}
#[test]
fn the_project_root_is_not_watched_twice() {
let root = tempfile::TempDir::new().unwrap();
assert_eq!(member_dir_to_watch(root.path(), root.path()), None);
}
#[test]
fn is_watched_dotenv_file_classifies_variants() {
assert!(is_watched_dotenv_file(Path::new(".env")));
assert!(is_watched_dotenv_file(Path::new(".env.local")));
assert!(is_watched_dotenv_file(Path::new(".env.dev")));
assert!(is_watched_dotenv_file(Path::new(".env.dev.local")));
assert!(is_watched_dotenv_file(Path::new("/home/alice/app/.env")));
assert!(!is_watched_dotenv_file(Path::new(".env.swp")));
assert!(!is_watched_dotenv_file(Path::new(".env.example")));
assert!(!is_watched_dotenv_file(Path::new("env")));
assert!(!is_watched_dotenv_file(Path::new(".git/.env")));
}
#[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, false);
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"), false);
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(cmd.get_program(), "cargo");
assert_eq!(args, &["build", "-p", "my-app"]);
}
#[test]
fn build_command_release_adds_flag() {
let cmd = build_cargo_command(None, true);
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(args, &["build", "--release"]);
}
#[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, 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, 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_prefers_default_run_over_first_target() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "todo-app",
"manifest_path": "/projects/todo-app/Cargo.toml",
"default_run": "todo-app",
"targets": [
{"name": "seed", "kind": ["bin"]},
{"name": "todo-app", "kind": ["bin"]}
]
}]
});
let result = resolve_binary_from_metadata(
&metadata,
Some("todo-app"),
Path::new("/projects/todo-app"),
);
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
expected_binary("/tmp/target/debug/todo-app")
);
}
#[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"));
}
#[test]
fn stop_server_with_none_is_noop() {
let mut child: Option<Child> = None;
stop_server(&mut child, None);
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, None);
assert!(child.is_none());
}
#[test]
fn find_binary_resolves_workspace_package() {
let path = find_binary(Some("hello"), false);
assert!(path.ends_with("debug/hello") || path.ends_with("debug/hello.exe"));
}
#[test]
fn find_binary_release_resolves_release_dir() {
let path = find_binary(Some("hello"), true);
assert!(path.ends_with("release/hello") || path.ends_with("release/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 DEFAULT_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,
active_build_error: None,
};
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 parse_build_diagnostics_extracts_errors_in_order_excluding_warnings() {
let stdout = concat!(
r#"{"reason":"compiler-artifact","target":{"name":"app"}}"#,
"\n",
r#"{"reason":"compiler-message","message":{"code":{"code":"E0425"},"level":"error","message":"cannot find value `foo` in this scope","rendered":"error[E0425]: cannot find value `foo`\n --> src/main.rs:3:5\n","spans":[{"file_name":"src/main.rs","line_start":3,"column_start":5,"is_primary":true}]}}"#,
"\n",
r#"{"reason":"compiler-message","message":{"code":null,"level":"warning","message":"unused variable: `x`","rendered":"warning: unused variable\n","spans":[{"file_name":"src/main.rs","line_start":9,"column_start":1,"is_primary":true}]}}"#,
"\n",
" Compiling app v0.1.0 (not json)\n",
r#"{"reason":"compiler-message","message":{"code":{"code":"E0308"},"level":"error","message":"mismatched types","rendered":"error[E0308]: mismatched types\n --> src/lib.rs:12:9\n","spans":[{"file_name":"src/other.rs","line_start":1,"column_start":1,"is_primary":false},{"file_name":"src/lib.rs","line_start":12,"column_start":9,"is_primary":true}]}}"#,
"\n",
r#"{"reason":"build-finished","success":false}"#,
"\n",
);
let diags = parse_build_diagnostics(stdout.as_bytes());
assert_eq!(diags.len(), 2, "warnings and non-error lines excluded");
assert_eq!(
diags[0],
BuildDiagnostic {
code: Some("E0425".to_owned()),
message: "cannot find value `foo` in this scope".to_owned(),
file: "src/main.rs".to_owned(),
line: 3,
column: 5,
rendered: "error[E0425]: cannot find value `foo`\n --> src/main.rs:3:5\n"
.to_owned(),
}
);
assert_eq!(diags[1].code, Some("E0308".to_owned()));
assert_eq!(diags[1].message, "mismatched types");
assert_eq!(diags[1].file, "src/lib.rs");
assert_eq!(diags[1].line, 12);
assert_eq!(diags[1].column, 9);
}
#[test]
fn compiler_message_rendered_echoes_all_levels_but_not_other_records() {
let warning = r#"{"reason":"compiler-message","message":{"code":null,"level":"warning","message":"unused variable: `x`","rendered":"warning: unused variable\n"}}"#;
assert_eq!(
compiler_message_rendered(warning).as_deref(),
Some("warning: unused variable\n"),
);
let error = r#"{"reason":"compiler-message","message":{"code":{"code":"E0425"},"level":"error","message":"cannot find value `foo`","rendered":"error[E0425]: cannot find value `foo`\n"}}"#;
assert_eq!(
compiler_message_rendered(error).as_deref(),
Some("error[E0425]: cannot find value `foo`\n"),
);
assert_eq!(compiler_message_rendered(" Compiling app v0.1.0"), None);
assert_eq!(
compiler_message_rendered(r#"{"reason":"compiler-artifact","target":{"name":"app"}}"#),
None,
);
assert_eq!(
compiler_message_rendered(
r#"{"reason":"compiler-message","message":{"level":"warning","message":"x"}}"#
),
None,
);
}
fn sample_build_diagnostics() -> Vec<BuildDiagnostic> {
vec![BuildDiagnostic {
code: Some("E0425".to_owned()),
message: "cannot find value `foo`".to_owned(),
file: "src/main.rs".to_owned(),
line: 3,
column: 5,
rendered: "error[E0425]: cannot find value `foo`".to_owned(),
}]
}
#[test]
fn dev_reload_state_signal_build_error_writes_payload() {
let reload_file = tempfile::NamedTempFile::new().expect("reload file");
let path = reload_file.path().to_path_buf();
let mut state = DevReloadState {
path,
version: 0,
active_build_error: None,
};
let diags = sample_build_diagnostics();
state
.signal_build_error(&diags, true)
.expect("build error signal");
assert_eq!(state.version, 1, "build error bumps the version");
let body = std::fs::read_to_string(state.path()).expect("read build error");
let value: serde_json::Value = serde_json::from_str(&body).expect("valid json");
assert_eq!(value["version"], 1);
assert_eq!(value["kind"], "full");
assert_eq!(value["build_error"]["stale"], true);
assert_eq!(value["build_error"]["diagnostics"][0]["code"], "E0425");
assert_eq!(
value["build_error"]["diagnostics"][0]["file"],
"src/main.rs"
);
assert_eq!(value["build_error"]["diagnostics"][0]["line"], 3);
assert_eq!(value["build_error"]["diagnostics"][0]["column"], 5);
assert_eq!(
value["build_error"]["diagnostics"][0]["message"],
"cannot find value `foo`"
);
}
#[test]
fn dev_reload_state_signal_carries_build_error_across_non_build_reloads() {
let reload_file = tempfile::NamedTempFile::new().expect("reload file");
let path = reload_file.path().to_path_buf();
let mut state = DevReloadState {
path,
version: 0,
active_build_error: None,
};
let diags = sample_build_diagnostics();
state
.signal_build_error(&diags, true)
.expect("build error signal");
assert_eq!(state.version, 1);
state.signal(ReloadKind::Css).expect("css signal");
assert_eq!(state.version, 2, "carrying forward still bumps the version");
let body = std::fs::read_to_string(state.path()).expect("read carried");
let value: serde_json::Value = serde_json::from_str(&body).expect("valid json");
assert_eq!(value["version"], 2);
assert_eq!(value["kind"], "full", "build error state stays a full kind");
assert!(
value.get("build_error").is_some(),
"a non-build reload must carry the build_error forward"
);
assert_eq!(value["build_error"]["stale"], true);
assert_eq!(value["build_error"]["diagnostics"][0]["code"], "E0425");
state.signal(ReloadKind::Full).expect("full signal");
assert_eq!(state.version, 3);
let body = std::fs::read_to_string(state.path()).expect("read carried again");
let value: serde_json::Value = serde_json::from_str(&body).expect("valid json");
assert!(
value.get("build_error").is_some(),
"a plain full reload must also carry the build_error forward"
);
}
#[test]
fn dev_reload_state_signal_build_success_clears_overlay() {
let reload_file = tempfile::NamedTempFile::new().expect("reload file");
let path = reload_file.path().to_path_buf();
let mut state = DevReloadState {
path,
version: 0,
active_build_error: None,
};
let diags = sample_build_diagnostics();
state
.signal_build_error(&diags, true)
.expect("build error signal");
assert_eq!(state.version, 1);
state
.signal_build_success(ReloadKind::Full)
.expect("success signal");
assert_eq!(state.version, 2);
let body = std::fs::read_to_string(state.path()).expect("read cleared");
let value: serde_json::Value = serde_json::from_str(&body).expect("valid json");
assert!(
value.get("build_error").is_none(),
"a green build must clear the build_error field"
);
assert_eq!(value["version"], 2);
assert_eq!(value["kind"], "full");
state.signal(ReloadKind::Css).expect("css signal");
let body = std::fs::read_to_string(state.path()).expect("read post-clear");
let value: serde_json::Value = serde_json::from_str(&body).expect("valid json");
assert!(
value.get("build_error").is_none(),
"no overlay should persist once cleared by a green build"
);
assert_eq!(value["kind"], "css");
assert_eq!(value["version"], 3);
}
#[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,
active_build_error: None,
};
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,
active_build_error: None,
};
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());
});
}
#[test]
fn parse_dev_config_returns_default_when_section_missing() {
let config = parse_dev_config("[server]\nport = 3000\n").expect("parse");
assert!(config.watch_dirs.is_empty());
}
#[test]
fn parse_dev_config_reads_watch_dirs() {
let config = parse_dev_config(
r#"
[dev]
watch_dirs = ["views", "locales"]
"#,
)
.expect("parse");
assert_eq!(config.watch_dirs, vec!["views", "locales"]);
}
#[test]
fn parse_dev_config_treats_empty_dev_section_as_default() {
let config = parse_dev_config("[dev]\n").expect("parse");
assert!(config.watch_dirs.is_empty());
}
#[test]
fn parse_dev_config_rejects_non_string_watch_dirs() {
let result = parse_dev_config("[dev]\nwatch_dirs = [42]\n");
assert!(result.is_err());
}
#[test]
fn load_dev_config_returns_default_for_missing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("does-not-exist.toml");
let config = load_dev_config(&path);
assert!(config.watch_dirs.is_empty());
}
#[test]
fn load_dev_config_reads_from_disk() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("autumn.toml");
std::fs::write(&path, "[dev]\nwatch_dirs = [\"views\", \"locales\"]\n")
.expect("write toml");
let config = load_dev_config(&path);
assert_eq!(config.watch_dirs, vec!["views", "locales"]);
}
#[test]
fn sanitize_drops_default_dirs() {
let dirs = sanitize_custom_watch_dirs(DevConfig {
watch_dirs: vec!["src".into(), "static".into(), "views".into()],
});
assert_eq!(dirs, vec!["views"]);
}
#[test]
fn sanitize_drops_blanks_and_dedupes() {
let dirs = sanitize_custom_watch_dirs(DevConfig {
watch_dirs: vec![
" ".into(),
"views".into(),
"views".into(),
" locales ".into(),
String::new(),
],
});
assert_eq!(dirs, vec!["views", "locales"]);
}
fn test_dir(rel: &str) -> CustomWatchDir {
CustomWatchDir {
relative: PathBuf::from(rel),
absolute: PathBuf::from("/repo").join(rel),
}
}
#[test]
fn custom_watch_dir_change_triggers_restart() {
let custom = vec![test_dir("views")];
assert_eq!(
classify_change(
Path::new("views/landing.html"),
DebouncedEventKind::Any,
&custom,
),
ChangeEffect::RestartOnly,
);
}
#[test]
fn custom_watch_dir_nested_change_triggers_restart() {
let custom = vec![test_dir("locales")];
assert_eq!(
classify_change(
Path::new("locales/en/messages.json"),
DebouncedEventKind::Any,
&custom,
),
ChangeEffect::RestartOnly,
);
}
#[test]
fn custom_watch_dir_does_not_override_known_dirs() {
let custom = vec![test_dir("views")];
assert_eq!(
classify_change(Path::new("src/main.rs"), DebouncedEventKind::Any, &custom),
ChangeEffect::BuildRestart,
);
}
#[test]
fn custom_watch_dir_respects_target_ignore() {
let custom = vec![test_dir("views")];
assert_eq!(
classify_change(
Path::new("target/views/cached.html"),
DebouncedEventKind::Any,
&custom,
),
ChangeEffect::Ignore,
);
}
#[test]
fn unknown_path_with_no_custom_dirs_is_ignored() {
assert_eq!(
classify_change(
Path::new("views/landing.html"),
DebouncedEventKind::Any,
&[],
),
ChangeEffect::Ignore,
);
}
#[test]
fn custom_dir_change_plans_a_restart() {
let events = [notify_debouncer_mini::DebouncedEvent {
path: PathBuf::from("views/landing.html"),
kind: DebouncedEventKind::Any,
}];
let custom = vec![test_dir("views")];
let plan = plan_changes(&events, &custom);
assert_eq!(
plan,
ChangePlan {
build: false,
restart: true,
tailwind: false,
reload: ReloadKind::Full,
}
);
}
#[test]
fn custom_watch_dir_with_multi_segment_path_matches() {
let custom = vec![test_dir("content/locales")];
assert_eq!(
classify_change(
Path::new("content/locales/en/messages.json"),
DebouncedEventKind::Any,
&custom,
),
ChangeEffect::RestartOnly,
);
}
#[test]
fn custom_watch_dir_does_not_match_unrelated_prefix() {
let custom = vec![test_dir("views")];
assert_eq!(
classify_change(
Path::new("views2/foo.html"),
DebouncedEventKind::Any,
&custom,
),
ChangeEffect::Ignore,
);
}
#[cfg(unix)]
#[test]
fn custom_watch_dir_matches_absolute_event_path() {
let custom = vec![CustomWatchDir {
relative: PathBuf::from("views"),
absolute: PathBuf::from("/home/user/project/views"),
}];
assert_eq!(
classify_change(
Path::new("/home/user/project/views/landing.html"),
DebouncedEventKind::Any,
&custom,
),
ChangeEffect::RestartOnly,
);
}
#[cfg(unix)]
#[test]
fn custom_watch_dir_matches_absolute_multi_segment_event_path() {
let custom = vec![CustomWatchDir {
relative: PathBuf::from("content/locales"),
absolute: PathBuf::from("/home/user/project/content/locales"),
}];
assert_eq!(
classify_change(
Path::new("/home/user/project/content/locales/en/messages.json"),
DebouncedEventKind::Any,
&custom,
),
ChangeEffect::RestartOnly,
);
}
#[cfg(unix)]
#[test]
fn custom_watch_dir_does_not_match_ancestor_directory() {
let custom = vec![CustomWatchDir {
relative: PathBuf::from("views"),
absolute: PathBuf::from("/home/alice/views/app/views"),
}];
assert_eq!(
classify_change(
Path::new("/home/alice/views/app/README.md"),
DebouncedEventKind::Any,
&custom,
),
ChangeEffect::Ignore,
);
}
#[test]
fn custom_watch_dir_matches_relative_event_path() {
let dir = test_dir("views");
assert!(dir.matches(Path::new("views/file.html")));
assert!(dir.matches(Path::new("views")));
assert!(!dir.matches(Path::new("other/file.html")));
}
#[cfg(unix)]
#[test]
fn custom_watch_dir_matches_absolute_event_path_via_helper() {
let dir = CustomWatchDir {
relative: PathBuf::from("views"),
absolute: PathBuf::from("/repo/views"),
};
assert!(dir.matches(Path::new("/repo/views/file.html")));
assert!(!dir.matches(Path::new("/elsewhere/views/file.html")));
}
#[test]
fn resolve_skips_missing_dirs() {
let cwd = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(cwd.path().join("views")).expect("mkdir views");
let resolved =
resolve_custom_watch_dirs(&["views".to_owned(), "missing".to_owned()], cwd.path());
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].relative, PathBuf::from("views"));
assert!(resolved[0].absolute.ends_with("views"));
assert!(resolved[0].absolute.is_absolute());
}
#[test]
fn normalize_strips_curdir_prefix() {
assert_eq!(normalize_watch_dir("./views"), Ok("views".to_owned()));
}
#[test]
fn normalize_preserves_multi_segment_paths() {
assert_eq!(
normalize_watch_dir("content/locales"),
Ok("content/locales".replace('/', std::path::MAIN_SEPARATOR_STR)),
);
}
#[test]
fn normalize_rejects_empty_input() {
assert!(normalize_watch_dir(" ").is_err());
assert!(normalize_watch_dir("").is_err());
}
#[test]
fn normalize_rejects_parent_traversal() {
assert!(normalize_watch_dir("../up").is_err());
assert!(normalize_watch_dir("views/../etc").is_err());
}
#[cfg(unix)]
#[test]
fn normalize_rejects_absolute_paths_unix() {
assert!(normalize_watch_dir("/etc/passwd").is_err());
assert!(normalize_watch_dir("/").is_err());
}
#[test]
fn normalize_rejects_target_anywhere() {
assert!(normalize_watch_dir("target").is_err());
assert!(normalize_watch_dir("nested/target/cache").is_err());
}
#[test]
fn normalize_rejects_dotted_components() {
assert!(normalize_watch_dir(".git").is_err());
assert!(normalize_watch_dir(".cache").is_err());
assert!(normalize_watch_dir("nested/.hidden").is_err());
assert!(normalize_watch_dir("./views").is_ok());
}
#[test]
fn normalize_rejects_curdir_only() {
assert!(normalize_watch_dir(".").is_err());
assert!(normalize_watch_dir("./").is_err());
}
#[test]
fn sanitize_warns_and_skips_unsafe_entries() {
let dirs = sanitize_custom_watch_dirs(DevConfig {
watch_dirs: vec![
"../escape".into(),
"target".into(),
"views".into(),
"./locales".into(),
],
});
let expected_locales = "locales".to_owned();
assert_eq!(dirs, vec!["views".to_owned(), expected_locales]);
}
#[test]
fn an_unfinished_dependency_audit_prints_nothing() {
assert!(dependency_startup_lines(None).is_empty());
}
#[test]
fn a_clean_tree_prints_nothing() {
let clean = crate::deps::Evaluation::Audited {
findings: Vec::new(),
checks: vec!["advisories".to_owned()],
db_age_days: Some(1),
auditor: "cargo-deny 0.20.2".to_owned(),
};
assert!(dependency_startup_lines(Some(&clean)).is_empty());
}
#[test]
fn the_audit_deadline_is_bounded() {
assert!(DEPENDENCY_AUDIT_DEADLINE <= Duration::from_secs(60));
}
#[test]
fn the_dependency_audit_starts_after_the_build_and_is_never_awaited() {
let source = include_str!("dev.rs");
let code = &source[..source.find("#[cfg(test)]").expect("test module")];
let build = code
.find("let (built, diagnostics) = cargo_build_capturing(package);")
.expect("initial build");
let start = code
.find("let mut dependency_audit = DependencyAudit::start(")
.expect("audit start");
assert!(
build < start,
"the audit must start after the initial build"
);
assert!(
!code.contains("await_evaluation"),
"the dev loop must poll the audit, never await it"
);
}
#[test]
fn a_dependency_audit_that_never_answers_stops_being_polled() {
let (sender, receiver) = mpsc::channel();
let stop = crate::deps::StopHandle::default();
let mut audit = DependencyAudit {
receiver,
stop: stop.clone(),
deadline: std::time::Instant::now(),
done: false,
};
audit.report();
assert!(audit.done, "an expired deadline must end the polling");
assert!(
stop.is_stopped(),
"an expired deadline must kill the auditor"
);
drop(sender);
}
#[test]
fn a_dependency_audit_reports_its_verdict_once() {
let (sender, receiver) = mpsc::channel();
let stop = crate::deps::StopHandle::default();
let mut audit = DependencyAudit {
receiver,
stop: stop.clone(),
deadline: std::time::Instant::now() + Duration::from_secs(60),
done: false,
};
audit.report();
assert!(!audit.done, "a pending audit stays pending");
assert!(!stop.is_stopped(), "a pending audit is not killed early");
sender
.send(crate::deps::Evaluation::NoPolicy)
.expect("send");
audit.report();
assert!(audit.done, "a delivered verdict ends the polling");
assert!(!stop.is_stopped(), "a delivered verdict is never killed");
drop(audit);
assert!(!stop.is_stopped(), "a finished audit is not killed on drop");
}
#[test]
fn dropping_an_unfinished_audit_stops_it() {
let (_sender, receiver) = mpsc::channel();
let stop = crate::deps::StopHandle::default();
let audit = DependencyAudit {
receiver,
stop: stop.clone(),
deadline: std::time::Instant::now() + Duration::from_secs(60),
done: false,
};
drop(audit);
assert!(stop.is_stopped());
}
}