Skip to main content

autumn_web/static_gen/
types.rs

1//! Core types for the static generation engine.
2//!
3//! This module defines the vocabulary used to describe statically generated routes,
4//! such as `StaticRouteMeta` (metadata about a route) and `StaticManifest` (the JSON
5//! ledger of all files generated during the build).
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::future::Future;
10use std::path::Path;
11use std::pin::Pin;
12
13/// A set of path parameter values for a parameterized static route.
14///
15/// Maps parameter names (e.g. `"slug"`) to their values (e.g. `"hello-world"`).
16///
17/// # Example
18///
19/// ```
20/// use autumn_web::static_gen::StaticParams;
21///
22/// let mut params = StaticParams::new();
23/// params.insert("slug".to_owned(), "hello-world".to_owned());
24/// ```
25pub type StaticParams = HashMap<String, String>;
26
27/// Convenience macro for building a [`StaticParams`] map.
28///
29/// # Example
30///
31/// ```
32/// use autumn_web::static_params;
33///
34/// let params = static_params! { "slug" => "hello-world" };
35/// assert_eq!(params.get("slug").unwrap(), "hello-world");
36/// ```
37#[macro_export]
38macro_rules! static_params {
39    ($($key:expr => $value:expr),* $(,)?) => {{
40        #[allow(unused_mut)]
41        let mut map = ::std::collections::HashMap::new();
42        $(map.insert($key.to_owned(), $value.to_owned());)*
43        map
44    }};
45}
46
47/// The type-erased async function that returns parameter sets for a
48/// parameterized static route.
49///
50/// This is the type stored inside [`StaticRouteMeta::params_fn`]. The
51/// build engine calls it to enumerate all parameter combinations that
52/// should be pre-rendered.
53pub type ParamsFn = fn(axum::Router) -> Pin<Box<dyn Future<Output = Vec<StaticParams>> + Send>>;
54
55/// Metadata for a route that should be statically generated at build time.
56///
57/// Used by the `#[static_get]` proc macro to register routes for the
58/// static-site build step. The `revalidate` field controls ISR
59/// (Incremental Static Regeneration): if set, the pre-rendered page
60/// will be refreshed after the given number of seconds.
61#[derive(Clone)]
62pub struct StaticRouteMeta {
63    /// The URL path pattern, e.g. `"/"` or `"/posts/{slug}"`.
64    pub path: &'static str,
65    /// The handler function name (used for diagnostics and manifest keys).
66    pub name: &'static str,
67    /// Optional ISR revalidation interval in seconds.
68    /// `None` means the page is generated once and never refreshed.
69    pub revalidate: Option<u64>,
70    /// Optional async function that returns parameter sets for
71    /// parameterized routes. `None` for simple (non-parameterized) routes.
72    pub params_fn: Option<ParamsFn>,
73}
74
75impl std::fmt::Debug for StaticRouteMeta {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("StaticRouteMeta")
78            .field("path", &self.path)
79            .field("name", &self.name)
80            .field("revalidate", &self.revalidate)
81            .field("params_fn", &self.params_fn.as_ref().map(|_| "..."))
82            .finish()
83    }
84}
85
86/// Persistent manifest written by `autumn build` and read at runtime
87/// by the static-file middleware.
88///
89/// Stored as JSON alongside the generated HTML files.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct StaticManifest {
92    /// ISO-8601 timestamp of when the build ran.
93    pub generated_at: String,
94    /// Autumn framework version that produced this manifest.
95    pub autumn_version: String,
96    /// Map from URL path (e.g. `"/about"`) to the generated file entry.
97    pub routes: HashMap<String, ManifestEntry>,
98}
99
100/// A single entry inside a [`StaticManifest`].
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ManifestEntry {
103    /// Relative filesystem path to the generated HTML file
104    /// (e.g. `"about/index.html"`).
105    pub file: String,
106    /// Optional ISR revalidation interval in seconds, copied from
107    /// [`StaticRouteMeta::revalidate`].
108    pub revalidate: Option<u64>,
109}
110
111impl StaticManifest {
112    /// Load a manifest from a JSON file on disk.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if the file cannot be read or contains invalid JSON.
117    pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
118        let contents = std::fs::read_to_string(path)?;
119        let manifest: Self = serde_json::from_str(&contents)?;
120        Ok(manifest)
121    }
122}
123
124/// Convert a URL path to the corresponding filesystem path for a
125/// statically generated HTML file.
126///
127/// # Rules
128///
129/// | URL path | File path |
130/// |----------|-----------|
131/// | `/` | `index.html` |
132/// | `/about` | `about/index.html` |
133/// | `/about/` | `about/index.html` |
134/// | `/posts/hello` | `posts/hello/index.html` |
135#[must_use]
136pub fn url_to_file_path(url_path: &str) -> String {
137    let trimmed = url_path.trim_matches('/');
138    if trimmed.is_empty() {
139        "index.html".to_owned()
140    } else {
141        format!("{trimmed}/index.html")
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use std::io::Write;
149
150    #[test]
151    fn url_to_file_path_root() {
152        assert_eq!(url_to_file_path("/"), "index.html");
153    }
154
155    #[test]
156    fn url_to_file_path_simple() {
157        assert_eq!(url_to_file_path("/about"), "about/index.html");
158    }
159
160    #[test]
161    fn url_to_file_path_nested() {
162        assert_eq!(url_to_file_path("/posts/hello"), "posts/hello/index.html");
163    }
164
165    #[test]
166    fn url_to_file_path_trailing_slash() {
167        assert_eq!(url_to_file_path("/about/"), "about/index.html");
168    }
169
170    #[test]
171    fn manifest_roundtrip() {
172        let mut routes = HashMap::new();
173        routes.insert(
174            "/".to_owned(),
175            ManifestEntry {
176                file: "index.html".to_owned(),
177                revalidate: None,
178            },
179        );
180        routes.insert(
181            "/about".to_owned(),
182            ManifestEntry {
183                file: "about/index.html".to_owned(),
184                revalidate: Some(3600),
185            },
186        );
187
188        let manifest = StaticManifest {
189            generated_at: "2026-03-27T12:00:00Z".to_owned(),
190            autumn_version: "0.3.0".to_owned(),
191            routes,
192        };
193
194        // Serialize to JSON
195        let json = serde_json::to_string(&manifest).expect("serialize");
196
197        // Write to a temp file, then load back via StaticManifest::load
198        let dir = tempfile::tempdir().expect("tempdir");
199        let file_path = dir.path().join("manifest.json");
200        {
201            let mut f = std::fs::File::create(&file_path).expect("create file");
202            f.write_all(json.as_bytes()).expect("write");
203        }
204
205        let loaded = StaticManifest::load(&file_path).expect("load");
206
207        assert_eq!(loaded.generated_at, "2026-03-27T12:00:00Z");
208        assert_eq!(loaded.autumn_version, "0.3.0");
209        assert_eq!(loaded.routes.len(), 2);
210
211        let root_entry = loaded.routes.get("/").expect("root route");
212        assert_eq!(root_entry.file, "index.html");
213        assert!(root_entry.revalidate.is_none());
214
215        let about_entry = loaded.routes.get("/about").expect("about route");
216        assert_eq!(about_entry.file, "about/index.html");
217        assert_eq!(about_entry.revalidate, Some(3600));
218    }
219
220    #[test]
221    fn static_route_meta_clone() {
222        let meta = StaticRouteMeta {
223            path: "/test",
224            name: "test_handler",
225            revalidate: Some(60),
226            params_fn: None,
227        };
228        let copy = meta.clone();
229        // Use original after clone to prove it's a real copy, not a move
230        assert_eq!(meta.path, copy.path);
231        assert_eq!(copy.name, "test_handler");
232        assert_eq!(copy.revalidate, Some(60));
233    }
234
235    #[test]
236    fn static_params_macro() {
237        let params = static_params! { "slug" => "hello-world" };
238        assert_eq!(params.get("slug").unwrap(), "hello-world");
239    }
240
241    #[test]
242    fn static_params_macro_multiple() {
243        let params = static_params! {
244            "year" => "2026",
245            "month" => "03",
246            "slug" => "hello",
247        };
248        assert_eq!(params.len(), 3);
249        assert_eq!(params.get("year").unwrap(), "2026");
250        assert_eq!(params.get("month").unwrap(), "03");
251        assert_eq!(params.get("slug").unwrap(), "hello");
252    }
253
254    #[test]
255    fn static_params_macro_empty() {
256        let params: StaticParams = static_params! {};
257        assert!(params.is_empty());
258    }
259
260    #[test]
261    fn static_route_meta_with_params_fn() {
262        fn dummy_params(
263            _router: axum::Router,
264        ) -> Pin<Box<dyn Future<Output = Vec<StaticParams>> + Send>> {
265            Box::pin(async { vec![static_params! { "slug" => "test" }] })
266        }
267
268        let meta = StaticRouteMeta {
269            path: "/posts/{slug}",
270            name: "show_post",
271            revalidate: None,
272            params_fn: Some(dummy_params),
273        };
274        assert!(meta.params_fn.is_some());
275        assert_eq!(meta.path, "/posts/{slug}");
276    }
277
278    #[test]
279    fn static_route_meta_debug() {
280        let meta = StaticRouteMeta {
281            path: "/test",
282            name: "test",
283            revalidate: None,
284            params_fn: None,
285        };
286        let debug = format!("{meta:?}");
287        assert!(debug.contains("test"));
288    }
289}