use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use sha2::{Digest, Sha256};
pub fn run(debug: bool, package: Option<&str>) {
eprintln!("\u{1F342} autumn build\n");
let profile = if debug { "dev" } else { "release" };
let mut cargo = Command::new("cargo");
cargo.arg("build");
if !debug {
cargo.arg("--release");
}
if let Some(pkg) = package {
cargo.args(["-p", pkg]);
}
eprintln!("Compiling ({profile} profile)...");
let status = cargo.status().expect("failed to run cargo build");
if !status.success() {
eprintln!("\u{2717} Compilation failed");
std::process::exit(1);
}
if !debug {
eprintln!("\nFingerprinting static assets...");
fingerprint_static_assets();
}
let (binary, manifest_dir) = find_binary(debug, package);
eprintln!("\nRunning static renderer...\n");
let mut cmd = Command::new(&binary);
cmd.env("AUTUMN_BUILD_STATIC", "1");
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);
}
}
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_static_assets() {
fingerprint_assets_in(Path::new("static"));
}
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>) -> (PathBuf, Option<PathBuf>) {
let output = Command::new("cargo")
.args(["metadata", "--format-version=1", "--no-deps"])
.output()
.expect("failed to run cargo metadata");
if !output.status.success() {
eprintln!("\u{2717} Failed to read cargo metadata");
std::process::exit(1);
}
let metadata: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("parse cargo metadata");
let cwd = std::env::current_dir().expect("current dir");
resolve_binary_from_metadata(&metadata, debug, package, &cwd).unwrap_or_else(|error| {
eprintln!("\u{2717} {error}");
std::process::exit(1);
})
}
fn resolve_binary_from_metadata(
metadata: &serde_json::Value,
debug: bool,
package: Option<&str>,
cwd: &Path,
) -> Result<(PathBuf, Option<PathBuf>), 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()
},
);
let (bin_name, manifest_dir) = matching_packages
.iter()
.find_map(|pkg| {
let name = 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");
if is_bin {
t["name"].as_str().map(String::from)
} else {
None
}
})?
};
let dir = pkg["manifest_path"]
.as_str()
.and_then(|p| Path::new(p).parent().map(PathBuf::from));
Some((name, dir))
})
.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 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))
}
#[cfg(test)]
mod tests {
use super::*;
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"), 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, 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"), 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"), 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"), Path::new("/projects"));
assert!(result.unwrap_err().contains("package 'hello'"));
}
#[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"), Path::new("/projects"))
.unwrap();
assert_eq!(bin, expected_binary("/tmp/target/debug/todo-app"));
}
#[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"),
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 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 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}"
);
}
}