use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
pub type StaticParams = HashMap<String, String>;
#[macro_export]
macro_rules! static_params {
($($key:expr => $value:expr),* $(,)?) => {{
#[allow(unused_mut)]
let mut map = ::std::collections::HashMap::new();
$(map.insert($key.to_owned(), $value.to_owned());)*
map
}};
}
pub type ParamsFn = fn(axum::Router) -> Pin<Box<dyn Future<Output = Vec<StaticParams>> + Send>>;
#[derive(Clone)]
pub struct StaticRouteMeta {
pub path: &'static str,
pub name: &'static str,
pub revalidate: Option<u64>,
pub params_fn: Option<ParamsFn>,
}
impl std::fmt::Debug for StaticRouteMeta {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StaticRouteMeta")
.field("path", &self.path)
.field("name", &self.name)
.field("revalidate", &self.revalidate)
.field("params_fn", &self.params_fn.as_ref().map(|_| "..."))
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaticManifest {
pub generated_at: String,
pub autumn_version: String,
pub routes: HashMap<String, ManifestEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestEntry {
pub file: String,
pub revalidate: Option<u64>,
}
impl StaticManifest {
pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string(path)?;
let manifest: Self = serde_json::from_str(&contents)?;
Ok(manifest)
}
}
#[must_use]
pub fn url_to_file_path(url_path: &str) -> String {
let trimmed = url_path.trim_matches('/');
if trimmed.is_empty() {
"index.html".to_owned()
} else {
format!("{trimmed}/index.html")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn url_to_file_path_root() {
assert_eq!(url_to_file_path("/"), "index.html");
}
#[test]
fn url_to_file_path_simple() {
assert_eq!(url_to_file_path("/about"), "about/index.html");
}
#[test]
fn url_to_file_path_nested() {
assert_eq!(url_to_file_path("/posts/hello"), "posts/hello/index.html");
}
#[test]
fn url_to_file_path_trailing_slash() {
assert_eq!(url_to_file_path("/about/"), "about/index.html");
}
#[test]
fn manifest_roundtrip() {
let mut routes = HashMap::new();
routes.insert(
"/".to_owned(),
ManifestEntry {
file: "index.html".to_owned(),
revalidate: None,
},
);
routes.insert(
"/about".to_owned(),
ManifestEntry {
file: "about/index.html".to_owned(),
revalidate: Some(3600),
},
);
let manifest = StaticManifest {
generated_at: "2026-03-27T12:00:00Z".to_owned(),
autumn_version: "0.3.0".to_owned(),
routes,
};
let json = serde_json::to_string(&manifest).expect("serialize");
let dir = tempfile::tempdir().expect("tempdir");
let file_path = dir.path().join("manifest.json");
{
let mut f = std::fs::File::create(&file_path).expect("create file");
f.write_all(json.as_bytes()).expect("write");
}
let loaded = StaticManifest::load(&file_path).expect("load");
assert_eq!(loaded.generated_at, "2026-03-27T12:00:00Z");
assert_eq!(loaded.autumn_version, "0.3.0");
assert_eq!(loaded.routes.len(), 2);
let root_entry = loaded.routes.get("/").expect("root route");
assert_eq!(root_entry.file, "index.html");
assert!(root_entry.revalidate.is_none());
let about_entry = loaded.routes.get("/about").expect("about route");
assert_eq!(about_entry.file, "about/index.html");
assert_eq!(about_entry.revalidate, Some(3600));
}
#[test]
fn static_route_meta_clone() {
let meta = StaticRouteMeta {
path: "/test",
name: "test_handler",
revalidate: Some(60),
params_fn: None,
};
let copy = meta.clone();
assert_eq!(meta.path, copy.path);
assert_eq!(copy.name, "test_handler");
assert_eq!(copy.revalidate, Some(60));
}
#[test]
fn static_params_macro() {
let params = static_params! { "slug" => "hello-world" };
assert_eq!(params.get("slug").unwrap(), "hello-world");
}
#[test]
fn static_params_macro_multiple() {
let params = static_params! {
"year" => "2026",
"month" => "03",
"slug" => "hello",
};
assert_eq!(params.len(), 3);
assert_eq!(params.get("year").unwrap(), "2026");
assert_eq!(params.get("month").unwrap(), "03");
assert_eq!(params.get("slug").unwrap(), "hello");
}
#[test]
fn static_params_macro_empty() {
let params: StaticParams = static_params! {};
assert!(params.is_empty());
}
#[test]
fn static_route_meta_with_params_fn() {
fn dummy_params(
_router: axum::Router,
) -> Pin<Box<dyn Future<Output = Vec<StaticParams>> + Send>> {
Box::pin(async { vec![static_params! { "slug" => "test" }] })
}
let meta = StaticRouteMeta {
path: "/posts/{slug}",
name: "show_post",
revalidate: None,
params_fn: Some(dummy_params),
};
assert!(meta.params_fn.is_some());
assert_eq!(meta.path, "/posts/{slug}");
}
#[test]
fn static_route_meta_debug() {
let meta = StaticRouteMeta {
path: "/test",
name: "test",
revalidate: None,
params_fn: None,
};
let debug = format!("{meta:?}");
assert!(debug.contains("test"));
}
}