modo-rs 0.8.0

Rust web framework for small monolithic apps
Documentation
use std::collections::HashMap;
use std::path::Path;

pub(crate) fn compute_hashes(static_path: &Path) -> crate::Result<HashMap<String, String>> {
    let mut hashes = HashMap::new();
    if static_path.exists() {
        walk_dir(static_path, static_path, &mut hashes)?;
    }
    Ok(hashes)
}

fn walk_dir(base: &Path, dir: &Path, hashes: &mut HashMap<String, String>) -> crate::Result<()> {
    let entries = std::fs::read_dir(dir).map_err(|e| {
        crate::Error::internal(format!("Failed to read directory {}: {e}", dir.display()))
    })?;

    for entry in entries {
        let entry = entry
            .map_err(|e| crate::Error::internal(format!("Failed to read directory entry: {e}")))?;
        let path = entry.path();

        if path.is_dir() {
            walk_dir(base, &path, hashes)?;
        } else {
            let content = std::fs::read(&path).map_err(|e| {
                crate::Error::internal(format!("Failed to read {}: {e}", path.display()))
            })?;

            let hash = crate::encoding::hex::sha256(&content);
            let short_hash = hash[..8].to_string();

            let relative = path
                .strip_prefix(base)
                .unwrap()
                .to_str()
                .unwrap()
                .to_string();

            hashes.insert(relative, short_hash);
        }
    }

    Ok(())
}

pub(crate) fn build_static_url(
    prefix: &str,
    hashes: &HashMap<String, String>,
    path: &str,
) -> String {
    if let Some(hash) = hashes.get(path) {
        format!("{prefix}/{path}?v={hash}")
    } else {
        format!("{prefix}/{path}")
    }
}

pub(crate) fn make_static_url_function(
    prefix: String,
    hashes: HashMap<String, String>,
) -> impl Fn(String) -> minijinja::Value + Send + Sync + 'static {
    move |path: String| {
        let url = build_static_url(&prefix, &hashes, &path);
        minijinja::Value::from_safe_string(url)
    }
}

pub(crate) fn static_service(static_path: &str, prefix: &str) -> axum::Router {
    use tower_http::services::ServeDir;

    let serve = ServeDir::new(static_path);

    // In debug mode: no-cache. In release: immutable cache.
    if cfg!(debug_assertions) {
        axum::Router::new().nest_service(
            prefix,
            tower::ServiceBuilder::new()
                .layer(axum::middleware::from_fn(no_cache_middleware))
                .service(serve),
        )
    } else {
        axum::Router::new().nest_service(
            prefix,
            tower::ServiceBuilder::new()
                .layer(axum::middleware::from_fn(immutable_cache_middleware))
                .service(serve),
        )
    }
}

async fn no_cache_middleware(
    req: axum::extract::Request,
    next: axum::middleware::Next,
) -> axum::response::Response {
    let mut resp = next.run(req).await;
    let headers = resp.headers_mut();
    if !headers.contains_key(http::header::CACHE_CONTROL) {
        headers.insert(
            http::header::CACHE_CONTROL,
            http::HeaderValue::from_static("no-cache"),
        );
    }
    resp
}

async fn immutable_cache_middleware(
    req: axum::extract::Request,
    next: axum::middleware::Next,
) -> axum::response::Response {
    let mut resp = next.run(req).await;
    let headers = resp.headers_mut();
    if !headers.contains_key(http::header::CACHE_CONTROL) {
        headers.insert(
            http::header::CACHE_CONTROL,
            http::HeaderValue::from_static("public, max-age=31536000, immutable"),
        );
    }
    resp
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    fn write_static_file(dir: &Path, path: &str, content: &str) {
        let full_path = dir.join(path);
        std::fs::create_dir_all(full_path.parent().unwrap()).unwrap();
        std::fs::write(full_path, content).unwrap();
    }

    #[test]
    fn computes_hashes_for_all_files() {
        let dir = tempfile::tempdir().unwrap();
        write_static_file(dir.path(), "css/app.css", "body { color: red; }");
        write_static_file(dir.path(), "js/app.js", "console.log('hello');");

        let map = compute_hashes(dir.path()).unwrap();
        assert_eq!(map.len(), 2);
        assert!(map.contains_key("css/app.css"));
        assert!(map.contains_key("js/app.js"));
    }

    #[test]
    fn hash_is_8_hex_chars() {
        let dir = tempfile::tempdir().unwrap();
        write_static_file(dir.path(), "style.css", "body {}");

        let map = compute_hashes(dir.path()).unwrap();
        let hash = &map["style.css"];
        assert_eq!(hash.len(), 8);
        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn same_content_produces_same_hash() {
        let dir = tempfile::tempdir().unwrap();
        write_static_file(dir.path(), "a.css", "same");
        write_static_file(dir.path(), "b.css", "same");

        let map = compute_hashes(dir.path()).unwrap();
        assert_eq!(map["a.css"], map["b.css"]);
    }

    #[test]
    fn different_content_produces_different_hash() {
        let dir = tempfile::tempdir().unwrap();
        write_static_file(dir.path(), "a.css", "aaa");
        write_static_file(dir.path(), "b.css", "bbb");

        let map = compute_hashes(dir.path()).unwrap();
        assert_ne!(map["a.css"], map["b.css"]);
    }

    #[test]
    fn static_url_generates_versioned_path() {
        let mut hashes = HashMap::new();
        hashes.insert("css/app.css".into(), "a3f2b1c4".into());

        let url = build_static_url("/assets", &hashes, "css/app.css");
        assert_eq!(url, "/assets/css/app.css?v=a3f2b1c4");
    }

    #[test]
    fn static_url_returns_plain_path_for_unknown_file() {
        let hashes = HashMap::new();
        let url = build_static_url("/assets", &hashes, "unknown.css");
        assert_eq!(url, "/assets/unknown.css");
    }

    #[test]
    fn empty_directory_produces_empty_map() {
        let dir = tempfile::tempdir().unwrap();
        let map = compute_hashes(dir.path()).unwrap();
        assert!(map.is_empty());
    }

    #[test]
    fn non_existent_static_path_returns_empty_map() {
        let hashes = compute_hashes(Path::new("/tmp/definitely-does-not-exist-modo-test")).unwrap();
        assert!(hashes.is_empty());
    }
}