#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
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>,
pub seo: crate::seo::SeoRouteDefaults,
pub content_type: Option<&'static str>,
}
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(|_| "..."))
.field("seo", &self.seo)
.field("content_type", &self.content_type)
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StaticManifest {
pub generated_at: String,
pub autumn_version: String,
pub routes: HashMap<String, ManifestEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ManifestEntry {
pub file: String,
#[serde(default)]
pub revalidate: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub content_type_explicit: bool,
}
impl ManifestEntry {
#[must_use]
pub fn new(file: impl Into<String>) -> Self {
Self {
file: file.into(),
revalidate: None,
content_type: None,
content_type_explicit: false,
}
}
#[must_use]
pub const fn with_revalidate(mut self, revalidate: Option<u64>) -> Self {
self.revalidate = revalidate;
self
}
#[must_use]
pub fn with_content_type(mut self, content_type: Option<String>) -> Self {
self.content_type = content_type;
self
}
#[must_use]
pub const fn with_content_type_explicit(mut self, explicit: bool) -> Self {
self.content_type_explicit = explicit;
self
}
}
impl StaticManifest {
#[must_use]
pub fn new(routes: HashMap<String, ManifestEntry>) -> Self {
Self {
generated_at: unix_timestamp_now(),
autumn_version: env!("CARGO_PKG_VERSION").to_owned(),
routes,
}
}
#[must_use]
pub fn with_generated_at(mut self, generated_at: impl Into<String>) -> Self {
self.generated_at = generated_at.into();
self
}
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")
}
}
fn unix_timestamp_now() -> String {
let secs = crate::time::clock_unix_secs(&crate::time::AmbientClock);
format!("{secs}")
}
#[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::new("index.html".to_owned()));
routes.insert(
"/about".to_owned(),
ManifestEntry::new("about/index.html".to_owned()).with_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 manifest_entry_roundtrips_recorded_content_type() {
let mut routes = HashMap::new();
routes.insert(
"/feed.xml".to_owned(),
ManifestEntry::new("feed.xml/index.html".to_owned())
.with_content_type(Some("application/rss+xml".to_owned())),
);
let manifest = StaticManifest {
generated_at: "2026-09-01T00:00:00Z".to_owned(),
autumn_version: "0.6.0".to_owned(),
routes,
};
let json = serde_json::to_string(&manifest).expect("serialize");
let loaded: StaticManifest = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
loaded.routes["/feed.xml"].content_type.as_deref(),
Some("application/rss+xml"),
"the recorded Content-Type must survive a manifest round-trip"
);
}
#[test]
fn legacy_manifest_without_content_type_deserializes_to_none() {
let json = r#"{
"generated_at": "2026-03-27T12:00:00Z",
"autumn_version": "0.3.0",
"routes": {
"/about": { "file": "about/index.html", "revalidate": null }
}
}"#;
let loaded: StaticManifest = serde_json::from_str(json).expect("legacy manifest loads");
let entry = loaded.routes.get("/about").expect("about route");
assert_eq!(entry.file, "about/index.html");
assert!(
entry.content_type.is_none(),
"a legacy entry must default to no recorded Content-Type"
);
}
#[test]
fn manifest_entry_omits_absent_content_type_from_json() {
let entry = ManifestEntry::new("about/index.html".to_owned());
let json = serde_json::to_string(&entry).expect("serialize");
assert!(
!json.contains("content_type"),
"absent Content-Type must not be written to the manifest, got {json}"
);
}
#[test]
fn manifest_entry_writes_a_stable_manifest_shape() {
let json =
serde_json::to_string(&ManifestEntry::new("about/index.html")).expect("serialize");
assert!(
json.contains("\"revalidate\""),
"revalidate must stay present so generated manifests keep the shape \
they had before #1832, got {json}"
);
assert!(
!json.contains("content_type"),
"absent content_type must still be omitted, got {json}"
);
}
#[test]
fn manifest_entry_builders_compose() {
let entry = ManifestEntry::new("feed.xml/index.html")
.with_revalidate(Some(600))
.with_content_type(Some("application/rss+xml".to_owned()));
assert_eq!(entry.file, "feed.xml/index.html");
assert_eq!(entry.revalidate, Some(600));
assert_eq!(entry.content_type.as_deref(), Some("application/rss+xml"));
let bare = ManifestEntry::new("index.html");
assert_eq!(bare.file, "index.html");
assert!(bare.revalidate.is_none());
assert!(bare.content_type.is_none());
}
#[test]
fn manifest_entry_defaults_to_not_explicit_and_omits_the_flag() {
let entry = ManifestEntry::new("a/index.html");
assert!(!entry.content_type_explicit);
let json = serde_json::to_string(&entry).expect("serialize");
assert!(!json.contains("content_type_explicit"), "{json}");
}
#[test]
fn explicit_content_type_roundtrips() {
let entry = ManifestEntry::new("logo.png/index.html")
.with_content_type(Some("application/octet-stream".to_owned()))
.with_content_type_explicit(true);
let json = serde_json::to_string(&entry).expect("serialize");
let loaded: ManifestEntry = serde_json::from_str(&json).expect("deserialize");
assert!(loaded.content_type_explicit);
}
#[test]
fn legacy_entry_without_flag_is_not_explicit() {
let json = r#"{ "file": "a/index.html", "revalidate": null, "content_type": "text/css" }"#;
let loaded: ManifestEntry = serde_json::from_str(json).expect("legacy entry loads");
assert!(!loaded.content_type_explicit);
}
#[test]
fn static_route_meta_clone() {
let meta = StaticRouteMeta {
path: "/test",
name: "test_handler",
revalidate: Some(60),
params_fn: None,
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: Some("text/plain"),
};
let copy = meta.clone();
assert_eq!(copy.content_type, Some("text/plain"));
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),
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: None,
};
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,
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: None,
};
let debug = format!("{meta:?}");
assert!(debug.contains("test"));
}
}