use std::collections::HashMap;
use std::path::Path;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use futures::StreamExt;
use tower::ServiceExt;
use super::{ManifestEntry, StaticManifest, StaticParams, StaticRouteMeta, url_to_file_path};
const DEFAULT_CONCURRENCY: usize = 8;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BuildError {
#[error("Route {path} returned HTTP {status} (expected 2xx)")]
NonSuccessStatus {
path: String,
status: StatusCode,
},
#[error("Failed to read response body for {path}: {source}")]
BodyRead {
path: String,
source: axum::Error,
},
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Params function for route {path} returned no parameter sets")]
EmptyParams {
path: String,
},
}
struct RenderJob {
url: String,
revalidate: Option<u64>,
}
async fn expand_route(
meta: &StaticRouteMeta,
router: &axum::Router,
) -> Result<Vec<RenderJob>, BuildError> {
match meta.params_fn {
None => {
Ok(vec![RenderJob {
url: meta.path.to_owned(),
revalidate: meta.revalidate,
}])
}
Some(params_fn) => {
let param_sets = params_fn(router.clone()).await;
if param_sets.is_empty() {
return Err(BuildError::EmptyParams {
path: meta.path.to_owned(),
});
}
let jobs = param_sets
.into_iter()
.map(|params| {
let url = substitute_params(meta.path, ¶ms);
RenderJob {
url,
revalidate: meta.revalidate,
}
})
.collect();
Ok(jobs)
}
}
}
fn substitute_params(pattern: &str, params: &StaticParams) -> String {
let mut result = pattern.to_owned();
for (key, value) in params {
let placeholder = format!("{{{key}}}");
result = result.replace(&placeholder, value);
}
result
}
pub async fn render_static_routes(
router: axum::Router,
metas: &[StaticRouteMeta],
dist_dir: &Path,
) -> Result<(), BuildError> {
let mut jobs = Vec::new();
for meta in metas {
let expanded = expand_route(meta, &router).await?;
eprintln!(" Route {} -> {} page(s)", meta.path, expanded.len());
jobs.extend(expanded);
}
let staging = dist_dir.with_extension("staging");
if tokio::fs::try_exists(&staging).await.unwrap_or(false) {
tokio::fs::remove_dir_all(&staging).await?;
}
tokio::fs::create_dir_all(&staging).await?;
for job in &jobs {
let file_path = url_to_file_path(&job.url);
let full_path = staging.join(&file_path);
if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
}
let results: Vec<Result<(String, ManifestEntry), BuildError>> =
futures::stream::iter(jobs.iter().map(|job| {
let router = router.clone();
let staging = staging.clone();
let url = job.url.clone();
let revalidate = job.revalidate;
async move {
eprintln!(" Rendering {url} ...");
let response = router
.oneshot(
Request::builder()
.uri(&url)
.body(Body::empty())
.expect("valid request"),
)
.await
.expect("router infallible");
if !response.status().is_success() {
return Err(BuildError::NonSuccessStatus {
path: url,
status: response.status(),
});
}
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.map_err(|e| BuildError::BodyRead {
path: url.clone(),
source: e,
})?;
let file_path = url_to_file_path(&url);
let full_path = staging.join(&file_path);
tokio::fs::write(&full_path, &body_bytes).await?;
Ok((
url,
ManifestEntry {
file: file_path,
revalidate,
},
))
}
}))
.buffer_unordered(DEFAULT_CONCURRENCY)
.collect()
.await;
let mut manifest_routes = HashMap::new();
for result in results {
match result {
Ok((path, entry)) => {
manifest_routes.insert(path, entry);
}
Err(e) => {
let _ = tokio::fs::remove_dir_all(&staging).await;
return Err(e);
}
}
}
let manifest = StaticManifest {
generated_at: timestamp_now(),
autumn_version: env!("CARGO_PKG_VERSION").to_owned(),
routes: manifest_routes,
};
let json = serde_json::to_string_pretty(&manifest)?;
tokio::fs::write(staging.join("manifest.json"), json).await?;
if tokio::fs::try_exists(dist_dir).await.unwrap_or(false) {
tokio::fs::remove_dir_all(dist_dir).await?;
}
tokio::fs::rename(&staging, dist_dir).await?;
Ok(())
}
fn timestamp_now() -> String {
let now = std::time::SystemTime::now();
let secs = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{secs}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::static_gen::StaticRouteMeta;
use std::future::Future;
use std::pin::Pin;
fn test_meta(path: &'static str, name: &'static str) -> StaticRouteMeta {
StaticRouteMeta {
path,
name,
revalidate: None,
params_fn: None,
}
}
fn test_meta_with_revalidate(
path: &'static str,
name: &'static str,
revalidate: u64,
) -> StaticRouteMeta {
StaticRouteMeta {
path,
name,
revalidate: Some(revalidate),
params_fn: None,
}
}
fn slug_params_hello_world(
_router: axum::Router,
) -> Pin<Box<dyn Future<Output = Vec<StaticParams>> + Send>> {
Box::pin(async {
vec![
crate::static_params! { "slug" => "hello" },
crate::static_params! { "slug" => "world" },
]
})
}
fn slug_params_alpha_beta(
_router: axum::Router,
) -> Pin<Box<dyn Future<Output = Vec<StaticParams>> + Send>> {
Box::pin(async {
vec![
crate::static_params! { "slug" => "alpha" },
crate::static_params! { "slug" => "beta" },
]
})
}
fn multi_params(
_router: axum::Router,
) -> Pin<Box<dyn Future<Output = Vec<StaticParams>> + Send>> {
Box::pin(async {
vec![
crate::static_params! { "year" => "2026", "slug" => "hello" },
crate::static_params! { "year" => "2025", "slug" => "world" },
]
})
}
fn slug_params_hello(
_router: axum::Router,
) -> Pin<Box<dyn Future<Output = Vec<StaticParams>> + Send>> {
Box::pin(async { vec![crate::static_params! { "slug" => "hello" }] })
}
fn echo_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|uri: axum::http::Uri| async move {
format!("Hello from {}", uri.path())
}))
}
#[tokio::test]
async fn renders_single_route_to_dist() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let result =
render_static_routes(echo_router(), &[test_meta("/about", "about")], &dist).await;
assert!(result.is_ok(), "render failed: {:?}", result.err());
let html = std::fs::read_to_string(dist.join("about/index.html")).unwrap();
assert_eq!(html, "Hello from /about");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(manifest.routes.len(), 1);
assert!(manifest.routes.contains_key("/about"));
}
#[tokio::test]
async fn renders_root_route() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let result = render_static_routes(echo_router(), &[test_meta("/", "index")], &dist).await;
assert!(result.is_ok());
let html = std::fs::read_to_string(dist.join("index.html")).unwrap();
assert_eq!(html, "Hello from /");
}
#[tokio::test]
async fn rejects_non_2xx_response() {
let router =
axum::Router::new().fallback(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "boom") });
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let result = render_static_routes(router, &[test_meta("/about", "about")], &dist).await;
assert!(result.is_err());
assert!(!dist.exists(), "dist should not exist after failed build");
let staging = dist.with_extension("staging");
assert!(
!staging.exists(),
"staging dir should be cleaned up after failed build"
);
}
#[tokio::test]
async fn cleans_stale_dist_before_build() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
std::fs::create_dir_all(&dist).unwrap();
std::fs::write(dist.join("stale.html"), "old").unwrap();
let result =
render_static_routes(echo_router(), &[test_meta("/about", "about")], &dist).await;
assert!(result.is_ok());
assert!(!dist.join("stale.html").exists());
assert!(dist.join("about/index.html").exists());
}
#[tokio::test]
async fn renders_multiple_routes_concurrently() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let result = render_static_routes(
echo_router(),
&[
test_meta("/", "index"),
test_meta("/about", "about"),
test_meta("/contact", "contact"),
],
&dist,
)
.await;
assert!(result.is_ok());
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(manifest.routes.len(), 3);
assert!(dist.join("index.html").exists());
assert!(dist.join("about/index.html").exists());
assert!(dist.join("contact/index.html").exists());
}
#[test]
fn substitute_params_single() {
let params = crate::static_params! { "slug" => "hello-world" };
let result = substitute_params("/posts/{slug}", ¶ms);
assert_eq!(result, "/posts/hello-world");
}
#[test]
fn substitute_params_multiple() {
let params = crate::static_params! {
"year" => "2026",
"slug" => "hello",
};
let result = substitute_params("/blog/{year}/{slug}", ¶ms);
assert_eq!(result, "/blog/2026/hello");
}
#[test]
fn substitute_params_no_placeholders() {
let params = StaticParams::new();
let result = substitute_params("/about", ¶ms);
assert_eq!(result, "/about");
}
#[tokio::test]
async fn renders_parameterized_route() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let meta = StaticRouteMeta {
path: "/posts/{slug}",
name: "show_post",
revalidate: None,
params_fn: Some(slug_params_hello_world),
};
let result = render_static_routes(echo_router(), &[meta], &dist).await;
assert!(result.is_ok(), "render failed: {:?}", result.err());
let hello_html = std::fs::read_to_string(dist.join("posts/hello/index.html")).unwrap();
assert_eq!(hello_html, "Hello from /posts/hello");
let world_html = std::fs::read_to_string(dist.join("posts/world/index.html")).unwrap();
assert_eq!(world_html, "Hello from /posts/world");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(manifest.routes.len(), 2);
assert!(manifest.routes.contains_key("/posts/hello"));
assert!(manifest.routes.contains_key("/posts/world"));
}
#[tokio::test]
async fn renders_multi_param_route() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let meta = StaticRouteMeta {
path: "/blog/{year}/{slug}",
name: "blog_post",
revalidate: None,
params_fn: Some(multi_params),
};
let result = render_static_routes(echo_router(), &[meta], &dist).await;
assert!(result.is_ok(), "render failed: {:?}", result.err());
assert!(dist.join("blog/2026/hello/index.html").exists());
assert!(dist.join("blog/2025/world/index.html").exists());
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(manifest.routes.len(), 2);
assert!(manifest.routes.contains_key("/blog/2026/hello"));
assert!(manifest.routes.contains_key("/blog/2025/world"));
}
#[tokio::test]
async fn mixed_simple_and_parameterized_routes() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let metas = vec![
test_meta("/", "index"),
test_meta("/about", "about"),
StaticRouteMeta {
path: "/posts/{slug}",
name: "show_post",
revalidate: None,
params_fn: Some(slug_params_alpha_beta),
},
];
let result = render_static_routes(echo_router(), &metas, &dist).await;
assert!(result.is_ok(), "render failed: {:?}", result.err());
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(manifest.routes.len(), 4);
assert!(manifest.routes.contains_key("/"));
assert!(manifest.routes.contains_key("/about"));
assert!(manifest.routes.contains_key("/posts/alpha"));
assert!(manifest.routes.contains_key("/posts/beta"));
}
#[tokio::test]
async fn parameterized_route_manifest_includes_revalidate() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let meta = StaticRouteMeta {
path: "/posts/{slug}",
name: "show_post",
revalidate: Some(3600),
params_fn: Some(slug_params_hello),
};
let result = render_static_routes(echo_router(), &[meta], &dist).await;
assert!(result.is_ok());
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
let entry = manifest.routes.get("/posts/hello").unwrap();
assert_eq!(entry.revalidate, Some(3600));
}
#[tokio::test]
async fn simple_route_with_revalidate() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let meta = test_meta_with_revalidate("/about", "about", 60);
let result = render_static_routes(echo_router(), &[meta], &dist).await;
assert!(result.is_ok());
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
let entry = manifest.routes.get("/about").unwrap();
assert_eq!(entry.revalidate, Some(60));
}
}