use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use sha2::{Digest, Sha256};
use crate::edge_scan::{EdgeFn, EdgeScan};
fn build_cargo_command(
debug: bool,
embed: bool,
package: Option<&str>,
bin: Option<&str>,
extra_features: Option<&str>,
auditable: bool,
) -> Command {
let mut cargo = Command::new("cargo");
if auditable {
cargo.arg("auditable");
}
cargo.arg("build");
if !debug {
cargo.arg("--release");
}
if let Some(pkg) = package {
cargo.args(["-p", pkg]);
}
if let Some(b) = bin {
cargo.args(["--bin", b]);
}
match (embed, extra_features) {
(true, Some(extra)) => {
cargo.args(["--features", &format!("embed-assets,{extra}")]);
}
(true, None) => {
cargo.args(["--features", "embed-assets"]);
}
(false, Some(extra)) => {
cargo.args(["--features", extra]);
}
(false, None) => {}
}
cargo
}
pub const EDGE_TARGET: &str = "wasm32-wasip1";
const EDGE_BIN: &str = "edge-capsule";
const EDGE_NO_ROUTES_ERROR: &str =
"no #[edge] routes found; add #[edge] to a GET handler and register it with edge_routes![]";
pub const EDGE_TARGET_HINT: &str = "Run `rustup target add wasm32-wasip1`";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgePlan {
Skip,
SkipDebug,
Build,
}
pub const BUILD_STATIC_ENV: &str = "AUTUMN_BUILD_STATIC";
const STATIC_ALLOW_EMPTY_ENV: &str = "AUTUMN_BUILD_STATIC_ALLOW_EMPTY";
fn static_render_allows_empty(plan: EdgePlan) -> bool {
plan == EdgePlan::Build
}
pub const fn plan_edge_step(
has_edge_routes: bool,
edge_flag: bool,
debug: bool,
) -> Result<EdgePlan, &'static str> {
if edge_flag && !has_edge_routes {
return Err(EDGE_NO_ROUTES_ERROR);
}
if !has_edge_routes {
return Ok(EdgePlan::Skip);
}
if debug && !edge_flag {
return Ok(EdgePlan::SkipDebug);
}
Ok(EdgePlan::Build)
}
fn edge_capsule_features(features: Option<&str>, embed: bool) -> Option<String> {
match (embed, features) {
(true, Some(extra)) => Some(format!("embed-assets,{extra}")),
(true, None) => Some("embed-assets".to_owned()),
(false, extra) => extra.map(str::to_owned),
}
}
fn build_edge_cargo_command(package: Option<&str>, features: Option<&str>) -> Command {
let mut cargo = Command::new("cargo");
cargo.arg("build");
cargo.args(["--target", EDGE_TARGET]);
cargo.arg("--release");
cargo.args(["--bin", EDGE_BIN]);
if let Some(pkg) = package {
cargo.args(["-p", pkg]);
}
if let Some(extra) = features {
cargo.args(["--features", extra]);
}
cargo
}
pub const fn edge_target_installed_from_probe(probe_ok: bool, libdir_exists: bool) -> bool {
probe_ok && libdir_exists
}
pub fn rustup_list_has_edge_target(stdout: &str) -> bool {
stdout.lines().any(|line| line.trim() == EDGE_TARGET)
}
pub fn edge_target_installed() -> bool {
if let Ok(out) = Command::new("rustc")
.args(["--print", "target-libdir", "--target", EDGE_TARGET])
.output()
{
let libdir = String::from_utf8_lossy(&out.stdout);
return edge_target_installed_from_probe(
out.status.success(),
Path::new(libdir.trim()).is_dir(),
);
}
Command::new("rustup")
.args(["target", "list", "--installed"])
.output()
.is_ok_and(|out| {
out.status.success()
&& rustup_list_has_edge_target(&String::from_utf8_lossy(&out.stdout))
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EdgeCapsuleTarget {
pub artifact: PathBuf,
pub package: String,
}
fn edge_bin_snippet(krate: &str) -> String {
format!("fn main() {{\n autumn_edge::serve({krate}::handlers::edge_routes());\n}}")
}
fn resolve_edge_capsule_from_metadata(
metadata: &serde_json::Value,
package: Option<&str>,
cwd: &Path,
) -> Result<EdgeCapsuleTarget, String> {
let target_dir = metadata["target_directory"]
.as_str()
.ok_or("target_directory missing from cargo metadata")?;
let packages = metadata["packages"]
.as_array()
.ok_or("packages missing from cargo metadata")?;
let matching: Vec<_> = package.map_or_else(
|| {
packages
.iter()
.filter(|pkg| {
pkg["manifest_path"]
.as_str()
.and_then(|manifest| 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 owner = matching
.iter()
.find(|pkg| pkg_owns_bin(pkg, EDGE_BIN))
.and_then(|pkg| pkg["name"].as_str());
let Some(owner) = owner else {
let krate = matching
.first()
.and_then(|pkg| pkg["name"].as_str())
.or(package)
.unwrap_or("your_crate")
.replace('-', "_");
return Err(format!(
"this project has #[edge] routes but no `{EDGE_BIN}` binary target.\n\
Create src/bin/{EDGE_BIN}.rs:\n\n{}\n\n\
and declare `[[bin]] name = \"{EDGE_BIN}\"` if your package needs it.",
edge_bin_snippet(&krate)
));
};
let mut artifact = PathBuf::from(target_dir);
artifact.push(EDGE_TARGET);
artifact.push("release");
artifact.push(format!("{EDGE_BIN}.wasm"));
Ok(EdgeCapsuleTarget {
artifact,
package: owner.to_owned(),
})
}
fn format_unregistered_warning(unregistered: &[&EdgeFn]) -> String {
let list = unregistered
.iter()
.map(|f| f.location())
.collect::<Vec<_>>()
.join(", ");
format!(
"\u{26A0} {} #[edge] handler(s) are not registered and will not be served at the edge: {list}\n \
Add them to edge_routes![] in the list passed to autumn_edge::serve().",
unregistered.len()
)
}
fn format_unsupported_warning(unsupported: &[&EdgeFn]) -> Option<String> {
if unsupported.is_empty() {
return None;
}
Some(format!(
"\u{26A0} {} #[edge] route(s) need what the edge cannot provide. The compiler \
stops on each one:\n{}\n {}",
unsupported.len(),
crate::edge_scan::format_unsupported(unsupported),
crate::edge_scan::EDGE_UNSUPPORTED_HINT,
))
}
fn format_edge_success(names: &[&str], artifact: &Path, size_bytes: Option<u64>) -> String {
let size = size_bytes.map_or_else(
|| "size unknown".to_owned(),
|bytes| format!("{} KB", bytes.div_ceil(1024)),
);
format!(
"\u{1F342} Edge capsule: {} route(s) ({}) \u{2192} {} ({size})",
names.len(),
names.join(", "),
artifact.display(),
)
}
fn run_edge_capsule_build(scan: &EdgeScan, package: Option<&str>, features: Option<&str>) {
let served: Vec<&str> = scan
.registered_fns()
.iter()
.map(|f| f.name.as_str())
.collect();
if served.is_empty() {
eprintln!(
"\n\u{2717} found {} #[edge] handler(s) but none are registered with edge_routes![]; \
the capsule would serve nothing.\n \
Register them (`edge_routes![{}]`) and pass the result to autumn_edge::serve() in \
src/bin/{EDGE_BIN}.rs.",
scan.functions.len(),
scan.names().join(", "),
);
std::process::exit(1);
}
let unregistered = scan.unregistered();
if !unregistered.is_empty() {
eprintln!("\n{}", format_unregistered_warning(&unregistered));
}
let metadata = read_cargo_metadata();
let cwd = std::env::current_dir().expect("current dir");
let capsule =
resolve_edge_capsule_from_metadata(&metadata, package, &cwd).unwrap_or_else(|error| {
eprintln!("\n\u{2717} {error}");
std::process::exit(1);
});
eprintln!("\nCompiling edge capsule ({EDGE_TARGET}, release profile)...");
run_cargo_or_exit(build_edge_cargo_command(
package.or(Some(capsule.package.as_str())),
features,
));
let size = std::fs::metadata(&capsule.artifact).ok().map(|m| m.len());
eprintln!(
"\n{}",
format_edge_success(&served, &capsule.artifact, size)
);
}
fn edge_scan_requested_features(features: Option<&str>, embed: bool) -> Vec<&str> {
let mut requested: Vec<&str> = features
.map(|value| {
value
.split(|c: char| c == ',' || c.is_whitespace())
.filter(|name| !name.is_empty())
.collect()
})
.unwrap_or_default();
if embed {
requested.push("embed-assets");
}
requested
}
fn resolve_project_edge_scan(
debug: bool,
embed: bool,
package: Option<&str>,
bin: Option<&str>,
features: Option<&str>,
) -> EdgeScan {
let cwd = std::env::current_dir().expect("current dir");
let root = if package.is_none() && bin.is_none() && cwd.join("src").is_dir() {
cwd
} else {
find_binary(debug, package, bin)
.1
.unwrap_or_else(|| cwd.clone())
};
let requested = edge_scan_requested_features(features, embed);
let capsule_bin = crate::doctor::resolve_edge_capsule_bin(&root);
crate::edge_scan::resolve_edge_scan_with_extra_file(&root, &requested, capsule_bin.as_deref())
}
fn run_cargo_or_exit(mut cargo: Command) {
let status = crate::built_artifact::run_build(&mut cargo).expect("failed to run cargo build");
if !status.success() {
eprintln!("\u{2717} Compilation failed");
std::process::exit(1);
}
}
fn build_embedded(
debug: bool,
profile: &str,
package: Option<&str>,
bin: Option<&str>,
features: Option<&str>,
auditable: bool,
) {
let (_, manifest_dir, _) = find_binary(debug, package, bin);
let static_dir = manifest_dir
.unwrap_or_else(|| std::env::current_dir().expect("current dir"))
.join("static");
eprintln!("Compiling ({profile} profile)...");
run_cargo_or_exit(build_cargo_command(
debug, false, package, bin, features, auditable,
));
eprintln!("\nFingerprinting static assets for embedding...");
fingerprint_assets_in(&static_dir);
eprintln!("\nEmbedding assets and locales into the binary...");
run_cargo_or_exit(build_cargo_command(
debug, true, package, bin, features, auditable,
));
eprintln!("\n\u{1F342} Build complete! Assets and locales embedded into the binary.");
}
fn apply_renderer_env(
cmd: &mut Command,
debug: bool,
package: Option<&str>,
bin: Option<&str>,
resolved_pkg: Option<&str>,
manifest_dir: Option<&PathBuf>,
) {
cmd.env_remove(crate::serve::MANAGED_PG_ATTACH_URL_ENV);
let effective_pkg = effective_package(package, bin, resolved_pkg);
if let Some(pg) = crate::serve::managed_pg_env(effective_pkg) {
cmd.env(crate::serve::MANAGED_PG_DATA_DIR_ENV, &pg.data_dir);
if let Some(url) = pg.attach_url {
cmd.env(crate::serve::MANAGED_PG_ATTACH_URL_ENV, url);
}
}
if std::env::var("AUTUMN_PROFILE").is_err() {
cmd.env("AUTUMN_PROFILE", if debug { "dev" } else { "prod" });
}
if let Some(dir) = manifest_dir {
let cwd = std::env::current_dir().expect("current dir");
if dir != &cwd {
cmd.current_dir(dir);
}
}
}
fn effective_package<'a>(
package: Option<&'a str>,
bin: Option<&'a str>,
resolved_pkg: Option<&'a str>,
) -> Option<&'a str> {
package.or_else(|| bin.and(resolved_pkg))
}
#[allow(clippy::fn_params_excessive_bools)]
pub fn run(
debug: bool,
embed: bool,
edge: bool,
package: Option<&str>,
bin: Option<&str>,
features: Option<&str>,
auditable: bool,
) {
eprintln!("\u{1F342} autumn build\n");
let profile = if debug { "dev" } else { "release" };
let edge_scan = resolve_project_edge_scan(debug, embed, package, bin, features);
if let Some(warning) = format_unsupported_warning(&edge_scan.unsupported()) {
eprintln!("{warning}\n");
}
let plan = plan_edge_step(!edge_scan.is_empty(), edge, debug).unwrap_or_else(|error| {
eprintln!("\u{2717} {error}");
std::process::exit(1);
});
if plan == EdgePlan::Build && !edge_target_installed() {
eprintln!(
"\u{2717} the `{EDGE_TARGET}` target is not installed, so the edge capsule cannot be \
compiled.\n {EDGE_TARGET_HINT}"
);
std::process::exit(1);
}
if plan == EdgePlan::SkipDebug {
eprintln!(
"note: {} #[edge] route(s) found; skipping the edge capsule in a debug build \
(pass --edge to build it)\n",
edge_scan.functions.len()
);
}
if embed {
build_embedded(debug, profile, package, bin, features, auditable);
if plan == EdgePlan::Build {
let resolved_pkg = find_binary(debug, package, bin).2;
run_edge_capsule_build(
&edge_scan,
package.or(resolved_pkg.as_deref()),
edge_capsule_features(features, true).as_deref(),
);
}
return;
}
eprintln!("Compiling ({profile} profile)...");
run_cargo_or_exit(build_cargo_command(
debug, embed, package, bin, features, auditable,
));
let (binary, manifest_dir, resolved_pkg) = find_binary(debug, package, bin);
if !debug {
eprintln!("\nFingerprinting static assets...");
let static_dir = manifest_dir.as_deref().map_or_else(
|| std::path::Path::new("static").to_owned(),
|d| d.join("static"),
);
fingerprint_assets_in(&static_dir);
}
if plan == EdgePlan::Build {
run_edge_capsule_build(&edge_scan, package.or(resolved_pkg.as_deref()), features);
}
eprintln!("\nRunning static renderer...\n");
let mut cmd = Command::new(&binary);
cmd.env(BUILD_STATIC_ENV, "1");
if static_render_allows_empty(plan) {
cmd.env(STATIC_ALLOW_EMPTY_ENV, "1");
} else {
cmd.env_remove(STATIC_ALLOW_EMPTY_ENV);
}
apply_renderer_env(
&mut cmd,
debug,
package,
bin,
resolved_pkg.as_deref(),
manifest_dir.as_ref(),
);
let status = cmd.status().unwrap_or_else(|e| {
eprintln!("\u{2717} Failed to run {}: {e}", binary.display());
std::process::exit(1);
});
if !status.success() {
eprintln!("\n\u{2717} Static build failed");
std::process::exit(1);
}
eprintln!("\n\u{1F342} Build complete!");
}
fn fingerprint_assets_in(static_dir: &Path) {
if !static_dir.exists() {
return;
}
remove_previous_fingerprints(static_dir);
let mut manifest_files: HashMap<String, String> = HashMap::new();
collect_and_fingerprint(static_dir, static_dir, &mut manifest_files);
let manifest = serde_json::json!({
"version": "1",
"files": manifest_files,
});
let manifest_path = static_dir.join(".autumn-manifest.json");
match serde_json::to_string_pretty(&manifest) {
Ok(json) => {
if let Err(e) = std::fs::write(&manifest_path, json) {
eprintln!("\u{2717} Failed to write asset manifest: {e}");
} else {
eprintln!(
" \u{2713} Fingerprinted {} asset(s) \u{2192} {}",
manifest_files.len(),
manifest_path.display()
);
}
}
Err(e) => eprintln!("\u{2717} Failed to serialize asset manifest: {e}"),
}
}
fn collect_and_fingerprint(root: &Path, dir: &Path, out: &mut HashMap<String, String>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
eprintln!(" \u{26A0} Could not read {}: {e}", dir.display());
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
let file_name = entry.file_name();
let name_str = file_name.to_string_lossy();
if name_str.starts_with('.') {
continue;
}
if path.is_dir() {
collect_and_fingerprint(root, &path, out);
continue;
}
if is_fingerprinted_filename(&name_str) {
continue;
}
let contents = match std::fs::read(&path) {
Ok(c) => c,
Err(e) => {
eprintln!(" \u{26A0} Could not read {}: {e}", path.display());
continue;
}
};
let hash = {
let mut hasher = Sha256::new();
hasher.update(&contents);
let result = hasher.finalize();
hex::encode(&result[..4]) };
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let ext = path
.extension()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let fp_name = if ext.is_empty() {
format!("{stem}.{hash}")
} else {
format!("{stem}.{hash}.{ext}")
};
let fp_path = path.with_file_name(&fp_name);
if let Err(e) = std::fs::write(&fp_path, &contents) {
eprintln!(" \u{26A0} Could not write {}: {e}", fp_path.display());
continue;
}
if let (Ok(logical), Ok(fingerprinted)) =
(path.strip_prefix(root), fp_path.strip_prefix(root))
{
out.insert(
logical.to_string_lossy().replace('\\', "/"),
fingerprinted.to_string_lossy().replace('\\', "/"),
);
}
}
}
fn remove_previous_fingerprints(static_dir: &Path) {
let manifest_path = static_dir.join(".autumn-manifest.json");
let Ok(contents) = std::fs::read_to_string(&manifest_path) else {
return; };
let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&contents) else {
return;
};
let Some(files) = manifest["files"].as_object() else {
return;
};
for fingerprinted_rel in files.values() {
if let Some(rel) = fingerprinted_rel.as_str() {
if rel.contains("..") || Path::new(rel).is_absolute() {
continue;
}
let fp_path = static_dir.join(rel);
if fp_path.exists() {
let _ = std::fs::remove_file(&fp_path);
}
}
}
}
fn is_fingerprinted_filename(filename: &str) -> bool {
let parts: Vec<&str> = filename.split('.').collect();
let hash_candidate = match parts.len() {
n if n >= 3 => parts[n - 2],
2 => parts[1],
_ => return false,
};
hash_candidate.len() == 8
&& hash_candidate
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
fn find_binary(
debug: bool,
package: Option<&str>,
bin: Option<&str>,
) -> (PathBuf, Option<PathBuf>, Option<String>) {
let metadata = read_cargo_metadata();
let cwd = std::env::current_dir().expect("current dir");
let (path, manifest_dir, pkg) = resolve_binary_from_metadata(
&metadata, debug, package, bin, &cwd,
)
.unwrap_or_else(|error| {
eprintln!("\u{2717} {error}");
std::process::exit(1);
});
(crate::built_artifact::prefer_built(path), manifest_dir, pkg)
}
fn read_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 pkg_owns_bin(pkg: &serde_json::Value, bin_name: &str) -> bool {
pkg["targets"].as_array().is_some_and(|ts| {
ts.iter().any(|t| {
t["name"].as_str() == Some(bin_name)
&& t["kind"]
.as_array()
.is_some_and(|ks| ks.iter().any(|k| k == "bin"))
})
})
}
fn resolve_binary_from_metadata(
metadata: &serde_json::Value,
debug: bool,
package: Option<&str>,
bin: Option<&str>,
cwd: &Path,
) -> Result<(PathBuf, Option<PathBuf>, Option<String>), String> {
let target_dir = metadata["target_directory"]
.as_str()
.ok_or("target_directory missing from cargo metadata")?;
let packages = metadata["packages"]
.as_array()
.ok_or("packages missing from cargo metadata")?;
let matching_packages: Vec<_> = package.map_or_else(
|| {
packages
.iter()
.filter(|pkg| {
pkg["manifest_path"]
.as_str()
.and_then(|manifest| 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()
},
);
if let (Some(explicit), None) = (bin, package) {
let owners: Vec<&str> = matching_packages
.iter()
.filter(|pkg| pkg_owns_bin(pkg, explicit))
.filter_map(|pkg| pkg["name"].as_str())
.collect();
if owners.len() > 1 {
return Err(format!(
"binary target '{explicit}' is defined in multiple packages: {}; \
use -p <package> to select one",
owners.join(", ")
));
}
}
let (bin_name, manifest_dir, resolved_pkg_name) = matching_packages
.iter()
.find_map(|pkg| {
let name = if let Some(explicit) = bin {
if !pkg_owns_bin(pkg, explicit) {
return None;
}
explicit.to_owned()
} else if let Some(name) = pkg["default_run"].as_str() {
name.to_owned()
} else {
pkg["targets"].as_array()?.iter().find_map(|t| {
let is_bin = t["kind"].as_array()?.iter().any(|k| k == "bin");
let name = t["name"].as_str()?;
if is_bin && name != EDGE_BIN {
Some(name.to_owned())
} else {
None
}
})?
};
let dir = pkg["manifest_path"]
.as_str()
.and_then(|p| Path::new(p).parent().map(PathBuf::from));
let pkg_name = pkg["name"].as_str().map(ToOwned::to_owned);
Some((name, dir, pkg_name))
})
.ok_or_else(|| {
bin.map_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}'"),
)
},
|explicit| {
package.map_or_else(
|| format!("no binary target '{explicit}' found in current package"),
|pkg_name| {
format!("no binary target '{explicit}' found in package '{pkg_name}'")
},
)
},
)
})?;
let profile_dir = if debug { "debug" } else { "release" };
let mut path = PathBuf::from(target_dir);
path.push(profile_dir);
path.push(bin_name);
if cfg!(windows) {
path.set_extension("exe");
}
Ok((path, manifest_dir, resolved_pkg_name))
}
#[cfg(test)]
mod tests {
use super::*;
fn cargo_args(
debug: bool,
embed: bool,
package: Option<&str>,
bin: Option<&str>,
extra_features: Option<&str>,
) -> Vec<String> {
build_cargo_command(debug, embed, package, bin, extra_features, false)
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
fn cargo_args_auditable(debug: bool, embed: bool, auditable: bool) -> Vec<String> {
build_cargo_command(debug, embed, None, None, None, auditable)
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
#[test]
fn auditable_builds_go_through_the_cargo_auditable_subcommand() {
let args = cargo_args_auditable(false, false, true);
assert_eq!(
args.first().map(String::as_str),
Some("auditable"),
"auditable builds must run `cargo auditable build ...`: {args:?}"
);
assert_eq!(args.get(1).map(String::as_str), Some("build"));
assert!(args.contains(&"--release".to_string()));
}
#[test]
fn non_auditable_builds_are_unchanged() {
let args = cargo_args_auditable(false, false, false);
assert_eq!(args.first().map(String::as_str), Some("build"));
assert!(!args.contains(&"auditable".to_string()));
}
#[test]
fn auditable_applies_to_the_embed_phase_too() {
let args = cargo_args_auditable(false, true, true);
assert_eq!(args.first().map(String::as_str), Some("auditable"));
assert!(args.windows(2).any(|w| w == ["--features", "embed-assets"]));
}
#[test]
fn embed_build_enables_feature_in_release() {
let args = cargo_args(false, true, None, None, None);
assert!(args.contains(&"--release".to_string()));
assert!(
args.windows(2).any(|w| w == ["--features", "embed-assets"]),
"embed build must enable the embed-assets feature: {args:?}"
);
}
#[test]
fn non_embed_build_omits_embed_feature() {
let args = cargo_args(false, false, Some("blog"), None, None);
assert!(
!args.iter().any(|a| a.contains("embed-assets")),
"non-embed build must not enable embed-assets: {args:?}"
);
assert!(args.windows(2).any(|w| w == ["-p", "blog"]));
}
#[test]
fn edge_cargo_command_forwards_the_native_builds_features() {
let cmd = build_edge_cargo_command(Some("blog"), Some("blog/extra-routes"));
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
args.windows(2)
.any(|w| w == ["--features", "blog/extra-routes"]),
"the capsule build must receive the native build's features: {args:?}"
);
assert!(args.windows(2).any(|w| w == ["-p", "blog"]));
assert!(args.windows(2).any(|w| w == ["--target", EDGE_TARGET]));
}
#[test]
fn edge_cargo_command_omits_features_when_none_were_requested() {
let cmd = build_edge_cargo_command(None, None);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
!args.iter().any(|a| a == "--features"),
"no --features flag without a request: {args:?}"
);
}
#[test]
fn effective_package_prefers_p_and_falls_back_to_the_bins_owner() {
assert_eq!(
effective_package(Some("blog"), Some("app"), Some("wiki")),
Some("blog")
);
assert_eq!(
effective_package(None, Some("app"), Some("wiki")),
Some("wiki")
);
assert_eq!(effective_package(None, None, Some("wiki")), None);
}
#[test]
fn extra_features_forwarded_to_cargo() {
let args = cargo_args(
false,
false,
None,
None,
Some("autumn-web/managed-pg-bundled"),
);
assert!(
args.windows(2)
.any(|w| w == ["--features", "autumn-web/managed-pg-bundled"]),
"extra_features must be forwarded when embed is false: {args:?}"
);
assert!(
!args.iter().any(|a| a.contains("embed-assets")),
"embed-assets must not appear in non-embed build: {args:?}"
);
}
#[test]
fn extra_features_combined_with_embed_assets() {
let args = cargo_args(
false,
true,
None,
None,
Some("autumn-web/managed-pg-bundled"),
);
assert!(
args.windows(2)
.any(|w| w == ["--features", "embed-assets,autumn-web/managed-pg-bundled"]),
"embed + extra_features must produce a combined --features value: {args:?}"
);
}
#[test]
fn bin_arg_is_passed_to_cargo() {
let args = cargo_args(false, true, Some("blog"), Some("blog-server"), None);
assert!(
args.windows(2).any(|w| w == ["--bin", "blog-server"]),
"--bin must be forwarded to cargo: {args:?}"
);
assert!(args.windows(2).any(|w| w == ["-p", "blog"]));
}
fn edge_cargo_args(package: Option<&str>) -> Vec<String> {
build_edge_cargo_command(package, None)
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
#[test]
fn edge_cargo_command_targets_wasi_release_capsule_bin() {
let args = edge_cargo_args(None);
assert_eq!(
args,
vec![
"build",
"--target",
"wasm32-wasip1",
"--release",
"--bin",
"edge-capsule"
],
"edge build must target wasm32-wasip1 in release: {args:?}"
);
}
#[test]
fn edge_cargo_command_forwards_package() {
let args = edge_cargo_args(Some("blog"));
assert!(args.windows(2).any(|w| w == ["-p", "blog"]), "{args:?}");
assert!(
args.windows(2).any(|w| w == ["--bin", "edge-capsule"]),
"{args:?}"
);
}
#[test]
fn edge_target_probe_requires_the_libdir_to_exist() {
assert!(edge_target_installed_from_probe(true, true));
assert!(!edge_target_installed_from_probe(true, false));
assert!(!edge_target_installed_from_probe(false, true));
}
#[test]
fn rustup_installed_list_is_parsed() {
assert!(rustup_list_has_edge_target(
"wasm32-wasip1\nx86_64-unknown-linux-gnu\n"
));
assert!(!rustup_list_has_edge_target("x86_64-unknown-linux-gnu\n"));
assert!(!rustup_list_has_edge_target("wasm32-wasip2\n"));
}
#[test]
fn edge_plan_release_build_with_routes_compiles_capsule() {
assert_eq!(plan_edge_step(true, false, false), Ok(EdgePlan::Build));
}
#[test]
fn edge_plan_debug_build_skips_with_a_note_unless_flagged() {
assert_eq!(plan_edge_step(true, false, true), Ok(EdgePlan::SkipDebug));
assert_eq!(plan_edge_step(true, true, true), Ok(EdgePlan::Build));
}
#[test]
fn edge_plan_without_routes_skips_silently() {
assert_eq!(plan_edge_step(false, false, false), Ok(EdgePlan::Skip));
}
#[test]
fn edge_plan_flag_without_routes_is_an_actionable_error() {
assert_eq!(
plan_edge_step(false, true, false),
Err(EDGE_NO_ROUTES_ERROR)
);
assert!(EDGE_NO_ROUTES_ERROR.contains("edge_routes![]"));
}
#[test]
fn edge_capsule_features_add_embed_assets_for_an_embed_build() {
assert_eq!(edge_capsule_features(None, false), None);
assert_eq!(
edge_capsule_features(Some("a,b"), false),
Some("a,b".to_owned())
);
assert_eq!(
edge_capsule_features(None, true),
Some("embed-assets".to_owned())
);
assert_eq!(
edge_capsule_features(Some("a,b"), true),
Some("embed-assets,a,b".to_owned())
);
}
#[test]
fn embed_adds_the_embed_assets_feature_to_the_edge_scan() {
let requested = edge_scan_requested_features(None, true);
assert_eq!(requested, vec!["embed-assets"]);
}
#[test]
fn non_embed_does_not_add_the_embed_assets_feature_to_the_edge_scan() {
let requested = edge_scan_requested_features(None, false);
assert!(requested.is_empty());
}
#[test]
fn embed_combines_with_explicitly_requested_features_for_the_edge_scan() {
let requested = edge_scan_requested_features(Some("a,b"), true);
assert_eq!(requested, vec!["a", "b", "embed-assets"]);
}
fn capsule_metadata(with_capsule_bin: bool) -> serde_json::Value {
let mut targets = vec![serde_json::json!({"name": "blog", "kind": ["bin"]})];
if with_capsule_bin {
targets.push(serde_json::json!({"name": "edge-capsule", "kind": ["bin"]}));
}
serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "blog",
"manifest_path": "/projects/blog/Cargo.toml",
"targets": targets,
}]
})
}
#[test]
fn resolve_edge_capsule_points_at_the_wasm_artifact() {
let capsule = resolve_edge_capsule_from_metadata(
&capsule_metadata(true),
Some("blog"),
Path::new("/projects"),
)
.unwrap();
assert_eq!(
capsule.artifact,
PathBuf::from("/tmp/target/wasm32-wasip1/release/edge-capsule.wasm")
);
assert_eq!(capsule.package, "blog");
}
#[test]
fn resolve_edge_capsule_matches_by_cwd_without_package_flag() {
let capsule = resolve_edge_capsule_from_metadata(
&capsule_metadata(true),
None,
Path::new("/projects/blog"),
)
.unwrap();
assert_eq!(capsule.package, "blog");
}
#[test]
fn missing_capsule_bin_prints_the_file_to_create() {
let error = resolve_edge_capsule_from_metadata(
&capsule_metadata(false),
Some("blog"),
Path::new("/projects"),
)
.unwrap_err();
assert!(error.contains("src/bin/edge-capsule.rs"), "{error}");
assert!(error.contains("fn main() {"), "{error}");
assert!(
error.contains("autumn_edge::serve(blog::handlers::edge_routes());"),
"the snippet must name the resolved crate: {error}"
);
}
#[test]
fn edge_success_line_names_routes_artifact_and_size() {
let line = format_edge_success(
&["greet", "note"],
Path::new("/tmp/target/wasm32-wasip1/release/edge-capsule.wasm"),
Some(2048),
);
assert_eq!(
line,
"\u{1F342} Edge capsule: 2 route(s) (greet, note) \
\u{2192} /tmp/target/wasm32-wasip1/release/edge-capsule.wasm (2 KB)"
);
}
#[test]
fn edge_success_line_tolerates_an_unreadable_artifact() {
let line = format_edge_success(&["greet"], Path::new("edge-capsule.wasm"), None);
assert!(line.contains("(size unknown)"), "{line}");
}
#[test]
fn unregistered_warning_names_every_handler() {
let scan = crate::edge_scan::scan_sources(&[(
"src/routes.rs",
"#[edge]\nfn greet() {}\n#[edge]\nfn stats() {}\nfn wire() { edge_routes![greet]; }",
)]);
let warning = format_unregistered_warning(&scan.unregistered());
assert!(warning.contains("stats @ src/routes.rs:4"), "{warning}");
assert!(!warning.contains("greet @"), "{warning}");
assert!(warning.contains("edge_routes![]"), "{warning}");
}
#[test]
fn only_an_edge_build_lets_the_static_renderer_find_no_routes() {
assert!(static_render_allows_empty(EdgePlan::Build));
assert!(!static_render_allows_empty(EdgePlan::Skip));
assert!(!static_render_allows_empty(EdgePlan::SkipDebug));
}
#[test]
fn unsupported_warning_names_the_route_the_reason_and_the_fix() {
let scan = crate::edge_scan::scan_sources(&[(
"src/routes.rs",
"#[edge]\nfn ok() {}\n#[post(\"/x\")]\n#[edge]\nfn write() {}",
)]);
assert!(format_unsupported_warning(&[]).is_none());
let error = format_unsupported_warning(&scan.unsupported()).expect("one route");
assert!(error.contains("1 #[edge] route(s)"), "{error}");
assert!(
error.contains("write @ src/routes.rs:5: #[post]"),
"{error}"
);
assert!(!error.contains("ok @"), "{error}");
assert!(error.contains("Remove #[edge]"), "{error}");
}
fn expected_binary(path: &str) -> PathBuf {
let mut p = PathBuf::from(path);
if cfg!(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 (bin, manifest_dir, _) = resolve_binary_from_metadata(
&metadata,
true,
Some("hello"),
None,
Path::new("/projects"),
)
.unwrap();
assert_eq!(bin, expected_binary("/tmp/target/debug/hello"));
assert_eq!(manifest_dir, Some(PathBuf::from("/projects/hello")));
}
#[test]
fn resolve_binary_by_cwd() {
let metadata = sample_metadata("/tmp/target", "hello", "/projects/hello");
let (bin, manifest_dir, _) =
resolve_binary_from_metadata(&metadata, true, None, None, Path::new("/projects/hello"))
.unwrap();
assert_eq!(bin, expected_binary("/tmp/target/debug/hello"));
assert_eq!(manifest_dir, Some(PathBuf::from("/projects/hello")));
}
#[test]
fn resolve_binary_uses_release_profile() {
let metadata = sample_metadata("/tmp/target", "hello", "/projects/hello");
let (bin, _, _) = resolve_binary_from_metadata(
&metadata,
false,
Some("hello"),
None,
Path::new("/projects"),
)
.unwrap();
assert_eq!(bin, expected_binary("/tmp/target/release/hello"));
}
#[test]
fn resolve_binary_reports_missing_package() {
let metadata = sample_metadata("/tmp/target", "hello", "/projects/hello");
let result = resolve_binary_from_metadata(
&metadata,
true,
Some("missing"),
None,
Path::new("/projects"),
);
assert!(result.unwrap_err().contains("package 'missing'"));
}
#[test]
fn resolve_binary_reports_missing_targets() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "hello",
"manifest_path": "/projects/hello/Cargo.toml",
"targets": []
}]
});
let result = resolve_binary_from_metadata(
&metadata,
true,
Some("hello"),
None,
Path::new("/projects"),
);
assert!(result.unwrap_err().contains("package 'hello'"));
}
#[test]
fn resolve_binary_never_falls_back_to_the_edge_capsule() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "greeting",
"manifest_path": "/projects/greeting/Cargo.toml",
"targets": [
{ "name": "edge-capsule", "kind": ["bin"], "src_path": "/projects/greeting/src/bin/edge-capsule.rs" },
{ "name": "greeting", "kind": ["bin"], "src_path": "/projects/greeting/src/main.rs" }
]
}]
});
let (bin, _, _) = resolve_binary_from_metadata(
&metadata,
true,
Some("greeting"),
None,
Path::new("/projects"),
)
.unwrap();
assert_eq!(bin, expected_binary("/tmp/target/debug/greeting"));
let (bin, _, _) = resolve_binary_from_metadata(
&metadata,
true,
Some("greeting"),
Some("edge-capsule"),
Path::new("/projects"),
)
.unwrap();
assert_eq!(bin, expected_binary("/tmp/target/debug/edge-capsule"));
}
#[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"], "src_path": "/projects/todo-app/src/bin/seed.rs" },
{ "name": "todo-app", "kind": ["bin"], "src_path": "/projects/todo-app/src/main.rs" }
]
}]
});
let (bin, _, _) = resolve_binary_from_metadata(
&metadata,
true,
Some("todo-app"),
None,
Path::new("/projects"),
)
.unwrap();
assert_eq!(bin, expected_binary("/tmp/target/debug/todo-app"));
}
#[test]
fn resolve_binary_explicit_bin_overrides_default_run() {
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"], "src_path": "/projects/todo-app/src/bin/seed.rs" },
{ "name": "todo-app", "kind": ["bin"], "src_path": "/projects/todo-app/src/main.rs" }
]
}]
});
let (bin, _, _) = resolve_binary_from_metadata(
&metadata,
true,
Some("todo-app"),
Some("seed"),
Path::new("/projects"),
)
.unwrap();
assert_eq!(bin, expected_binary("/tmp/target/debug/seed"));
}
#[test]
fn resolve_binary_bin_picks_correct_workspace_member() {
let metadata = serde_json::json!({
"target_directory": "/workspace/target",
"packages": [
{
"name": "app-a",
"manifest_path": "/workspace/app-a/Cargo.toml",
"targets": [{ "name": "server", "kind": ["bin"], "src_path": "/workspace/app-a/src/main.rs" }]
},
{
"name": "app-b",
"manifest_path": "/workspace/app-b/Cargo.toml",
"targets": [{ "name": "web", "kind": ["bin"], "src_path": "/workspace/app-b/src/main.rs" }]
}
]
});
let (bin, manifest_dir, _) = resolve_binary_from_metadata(
&metadata,
true,
None,
Some("web"),
Path::new("/workspace"),
)
.unwrap();
assert_eq!(bin, expected_binary("/workspace/target/debug/web"));
assert_eq!(
manifest_dir,
Some(PathBuf::from("/workspace/app-b")),
"--bin web must resolve to app-b's manifest_dir, not app-a's"
);
}
#[test]
fn resolve_binary_bin_ambiguous_across_workspace_members_errors() {
let metadata = serde_json::json!({
"target_directory": "/workspace/target",
"packages": [
{
"name": "app-a",
"manifest_path": "/workspace/app-a/Cargo.toml",
"targets": [{ "name": "web", "kind": ["bin"], "src_path": "/workspace/app-a/src/main.rs" }]
},
{
"name": "app-b",
"manifest_path": "/workspace/app-b/Cargo.toml",
"targets": [{ "name": "web", "kind": ["bin"], "src_path": "/workspace/app-b/src/main.rs" }]
}
]
});
let result = resolve_binary_from_metadata(
&metadata,
false,
None,
Some("web"),
Path::new("/workspace"),
);
let err = result.unwrap_err();
assert!(
err.contains("web") && err.contains("app-a") && err.contains("app-b"),
"error must name the binary and the conflicting packages so the user \
knows to add -p; got: {err}"
);
assert!(
err.contains("-p"),
"error must suggest -p <package> to disambiguate; got: {err}"
);
}
#[test]
fn resolve_binary_bin_not_in_any_member_errors() {
let metadata = serde_json::json!({
"target_directory": "/tmp/target",
"packages": [{
"name": "app-a",
"manifest_path": "/workspace/app-a/Cargo.toml",
"targets": [{ "name": "server", "kind": ["bin"], "src_path": "/workspace/app-a/src/main.rs" }]
}]
});
let result = resolve_binary_from_metadata(
&metadata,
true,
None,
Some("missing-bin"),
Path::new("/workspace"),
);
assert!(
result.unwrap_err().contains("missing-bin"),
"error must name the missing binary target"
);
}
#[test]
fn resolve_binary_returns_manifest_dir_for_workspace_package() {
let metadata = serde_json::json!({
"target_directory": "/workspace/target",
"packages": [{
"name": "reddit-clone",
"manifest_path": "/workspace/examples/reddit-clone/Cargo.toml",
"targets": [{ "name": "reddit-clone", "kind": ["bin"], "src_path": "/workspace/examples/reddit-clone/src/main.rs" }]
}]
});
let (bin, manifest_dir, _) = resolve_binary_from_metadata(
&metadata,
false,
Some("reddit-clone"),
None,
Path::new("/workspace"),
)
.unwrap();
assert_eq!(
bin,
expected_binary("/workspace/target/release/reddit-clone")
);
assert_eq!(
manifest_dir,
Some(PathBuf::from("/workspace/examples/reddit-clone"))
);
}
#[test]
fn resolve_binary_returns_pkg_name_for_bin_without_package() {
let metadata = serde_json::json!({
"target_directory": "/workspace/target",
"packages": [
{
"name": "api",
"manifest_path": "/workspace/api/Cargo.toml",
"targets": [{ "name": "api-server", "kind": ["bin"], "src_path": "/workspace/api/src/main.rs" }]
},
{
"name": "web",
"manifest_path": "/workspace/web/Cargo.toml",
"targets": [{ "name": "web-server", "kind": ["bin"], "src_path": "/workspace/web/src/main.rs" }]
}
]
});
let (bin, manifest_dir, resolved_pkg) = resolve_binary_from_metadata(
&metadata,
false,
None,
Some("api-server"),
Path::new("/workspace"),
)
.unwrap();
assert_eq!(bin, expected_binary("/workspace/target/release/api-server"));
assert_eq!(manifest_dir, Some(PathBuf::from("/workspace/api")));
assert_eq!(
resolved_pkg,
Some("api".to_owned()),
"--bin without -p must return the resolved package name for managed-PG namespacing"
);
}
#[test]
fn fingerprint_detection_positive() {
assert!(is_fingerprinted_filename("autumn.a1b2c3d4.css"));
assert!(is_fingerprinted_filename("app.00000000.js"));
assert!(is_fingerprinted_filename("logo.deadbeef.png"));
assert!(is_fingerprinted_filename("CNAME.a1b2c3d4"));
assert!(is_fingerprinted_filename("robots.deadbeef"));
}
#[test]
fn fingerprint_detection_negative() {
assert!(!is_fingerprinted_filename("autumn.css"));
assert!(!is_fingerprinted_filename("htmx.min.js"));
assert!(!is_fingerprinted_filename("autumn.abc.css"));
assert!(!is_fingerprinted_filename("autumn.a1b2c3d4e5.css"));
assert!(!is_fingerprinted_filename("autumn.A1B2C3D4.css"));
assert!(!is_fingerprinted_filename("autumn.zzzzzzzz.css"));
assert!(!is_fingerprinted_filename("CNAME"));
}
#[test]
fn run_cargo_or_exit_succeeds_on_zero_exit() {
let mut cmd = Command::new("cargo");
cmd.args(["build", "--help"]);
run_cargo_or_exit(cmd);
}
#[test]
fn apply_renderer_env_removes_pg_attach_url_unconditionally() {
let mut cmd = Command::new("echo");
apply_renderer_env(&mut cmd, false, None, None, None, None);
let removed = cmd.get_envs().any(|(k, v)| {
k.to_str() == Some(crate::serve::MANAGED_PG_ATTACH_URL_ENV) && v.is_none()
});
assert!(
removed,
"MANAGED_PG_ATTACH_URL must be removed from env regardless of managed-PG state"
);
}
#[test]
fn apply_renderer_env_sets_autumn_profile_when_absent() {
if std::env::var("AUTUMN_PROFILE").is_ok() {
return;
}
let find_profile = |cmd: &Command| -> Option<String> {
cmd.get_envs().find_map(|(k, v)| {
if k.to_str() == Some("AUTUMN_PROFILE") {
v.and_then(|s| s.to_str().map(str::to_owned))
} else {
None
}
})
};
let mut cmd_debug = Command::new("echo");
apply_renderer_env(&mut cmd_debug, true, None, None, None, None);
assert_eq!(
find_profile(&cmd_debug).as_deref(),
Some("dev"),
"debug=true must set AUTUMN_PROFILE=dev when not already in env"
);
let mut cmd_release = Command::new("echo");
apply_renderer_env(&mut cmd_release, false, None, None, None, None);
assert_eq!(
find_profile(&cmd_release).as_deref(),
Some("prod"),
"debug=false must set AUTUMN_PROFILE=prod when not already in env"
);
}
#[test]
fn apply_renderer_env_pins_current_dir_when_manifest_differs_from_cwd() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let manifest_dir = tmp.path().to_path_buf();
let cwd = std::env::current_dir().unwrap();
if manifest_dir == cwd {
return; }
let mut cmd = Command::new("echo");
apply_renderer_env(&mut cmd, false, None, None, None, Some(&manifest_dir));
assert_eq!(
cmd.get_current_dir(),
Some(manifest_dir.as_path()),
"current_dir must be pinned to manifest_dir when it differs from cwd"
);
}
#[test]
fn apply_renderer_env_effective_pkg_prefers_package_over_resolved() {
let mut cmd = Command::new("echo");
apply_renderer_env(
&mut cmd,
false,
Some("my-pkg"),
Some("my-bin"),
Some("resolved"),
None,
);
let removed = cmd.get_envs().any(|(k, v)| {
k.to_str() == Some(crate::serve::MANAGED_PG_ATTACH_URL_ENV) && v.is_none()
});
assert!(removed);
}
#[test]
fn fingerprint_static_assets_writes_manifest_and_copies() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let static_dir = tmp.path().join("static");
let css_dir = static_dir.join("css");
std::fs::create_dir_all(&css_dir).unwrap();
let css_content = b"body { color: red; }";
std::fs::write(css_dir.join("autumn.css"), css_content).unwrap();
fingerprint_assets_in(&static_dir);
let manifest_path = static_dir.join(".autumn-manifest.json");
assert!(manifest_path.exists(), "manifest must be written");
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
let files = manifest["files"].as_object().unwrap();
assert_eq!(files.len(), 1, "one asset fingerprinted");
let fp = files["css/autumn.css"].as_str().unwrap();
assert!(
fp.starts_with("css/autumn."),
"fingerprinted path has correct prefix"
);
assert!(
std::path::Path::new(fp)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("css")),
"fingerprinted path has correct extension"
);
assert!(
static_dir.join(fp).exists(),
"fingerprinted copy must be written: {fp}"
);
}
}