lariv-rs 0.1.0

Compile-time plugin web application framework built on Axum, SeaORM, Maud, and HTMX
Documentation
//! Typed route tags and HTMX response-kind markers for compile-time wiring checks.
//!
//! Route tags are generated by [`crate::define_plugin_routes!`] with typed path fields,
//! `path()` / `url()` builders, and response marker traits that document what HTML a
//! handler must return for each swap target.
//!
//! # Routes
//!
//! Each generated route struct implements [`RouteTag`] (static path pattern) and
//! [`RouteUrl`] (concrete path/url builders). Marker traits such as [`AppPaneGet`],
//! [`FragmentGet`], and [`ModalGet`] classify expected HTMX response shapes.
//!
//! # Use cases
//!
//! - Build type-safe URLs in Maud templates without stringly-typed paths.
//! - Attach query strings with [`RouteQueryBuilder`] for filters and return links.
//! - Enforce at compile time that a handler matches its HTMX swap region.
//!
//! # Examples
//!
//! ```rust ignore
//! // Generated by define_plugin_routes! in a plugin:
//! let edit_url = UserEditRoute { id: 42 }.url();
//! let create_with_client = RouteQueryBuilder::new(ProposalCreateRoute)
//!     .query("ClientID", 7)
//!     .query_opt("return", Some("client"))
//!     .build();
//! ```

use std::fmt::Display;

use crate::components::swap::SwapKey;

/// Plugin route identity with a static path pattern (e.g. `"/users/{id}/edit"`).
///
/// Implemented by structs generated via [`crate::define_plugin_routes!`].
pub trait RouteTag {
    const PATH: &'static str;

    /// Path template placeholders (e.g. `["id"]`, `["parent_id"]`), when known.
    const PARAMS: &'static [&'static str] = &[];
}

/// Build concrete paths and navigation URLs from a typed route value.
///
/// # Use cases
///
/// - `path()` — form actions and `hx-post` targets (no trailing slash).
/// - `url()` — anchor `href`s and redirects (trailing slash plus current nav origin).
pub trait RouteUrl: RouteTag + Sized {
    fn path(self) -> String;

    /// In-app GET URL. Carries [`crate::components::nav_origin`] when the request
    /// arrived from the apps dashboard, so sidebar/crumb/row links keep that trail.
    fn url(self) -> String {
        nav_url(&self.path())
    }
}

/// GET handler returns a full app pane targeting `#app-layout`.
///
/// Use for list pages, detail views, and boosted navigation targets.
pub trait AppPaneGet: RouteTag {}

/// POST handler replaces `#app-layout` (typical create/edit form submission).
pub trait AppPanePost: RouteTag {}

/// GET handler returns a partial for the given [`SwapKey`] region (filters, tables).
pub trait FragmentGet<K: SwapKey>: RouteTag {}

/// POST handler targets the given [`SwapKey`] region (modal confirm, table row action).
pub trait FragmentPost<K: SwapKey>: RouteTag {}

/// GET handler returns a file attachment (plain link, `hx-boost=false`).
pub trait FileDownloadGet: RouteTag {}

/// POST handler returns a file attachment (plain form, no HTMX swap).
pub trait FileDownloadPost: RouteTag {}

/// GET handler returns modal markup appended to `document.body`.
pub trait ModalGet: RouteTag {}

/// GET handler for FK / M2M picker routes (modal open + table fragment pagination).
pub trait FkSelectGet<K: SwapKey, M: SwapKey>: RouteTag {}

/// POST via hx-boost into `#app-layout` (logout-style actions).
pub trait BoostPost: RouteTag {}

/// POST into a dynamic generation poll region (long-running job status).
pub trait GenerationPost: RouteTag {}

/// Fluent query-string builder wrapping a typed route.
///
/// # Examples
///
/// ```rust ignore
/// RouteQueryBuilder::new(ListUsersRoute)
///     .query("Name", filter)
///     .query("page", page)
///     .build_with_query()  // for hx-get / form action
/// ```
pub struct RouteQueryBuilder<R: RouteUrl> {
    route: R,
    query: Vec<(String, String)>,
}

impl<R: RouteUrl> RouteQueryBuilder<R> {
    /// Start a query builder for `route`.
    pub fn new(route: R) -> Self {
        Self {
            route,
            query: Vec::new(),
        }
    }

    /// Append a query-string pair (values are percent-encoded).
    pub fn query(mut self, key: &str, value: impl Display) -> Self {
        self.query.push((key.to_owned(), value.to_string()));
        self
    }

    /// Append a query pair when `value` is present.
    pub fn query_opt(self, key: &str, value: Option<impl Display>) -> Self {
        match value {
            Some(v) => self.query(key, v),
            None => self,
        }
    }

    /// Path without trailing slash, with optional `?query` (for `hx-post` / form `action`).
    pub fn build_with_query(self) -> String {
        crate::components::nav_origin::with_nav_origin(&append_query(
            self.route.path(),
            &self.query,
        ))
    }

    /// Path with trailing slash and optional `?query` (for navigation `href`s).
    pub fn build(self) -> String {
        crate::components::nav_origin::with_nav_origin(&append_query(
            trailing_slash(&self.route.path()),
            &self.query,
        ))
    }
}

/// Append a trailing slash for template URLs (Axum route paths omit it).
///
/// # Examples
///
/// ```rust
/// # use lariv_rs::http::trailing_slash;
/// assert_eq!(trailing_slash("/users"), "/users/");
/// assert_eq!(trailing_slash("/users/"), "/users/");
/// ```
pub fn trailing_slash(path: &str) -> String {
    if path.is_empty() || path.ends_with('/') {
        path.to_owned()
    } else {
        format!("{path}/")
    }
}

/// Trailing-slash navigation URL, including the current dashboard origin when set.
pub fn nav_url(path: &str) -> String {
    crate::components::nav_origin::nav_url(path)
}

fn append_query(path: String, query: &[(String, String)]) -> String {
    if query.is_empty() {
        return path;
    }
    let mut out = path;
    out.push('?');
    for (i, (key, value)) in query.iter().enumerate() {
        if i > 0 {
            out.push('&');
        }
        encode_query_component(&mut out, key);
        out.push('=');
        encode_query_component(&mut out, value);
    }
    out
}

fn encode_query_component(out: &mut String, s: &str) {
    for b in s.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(char::from(b));
            }
            _ => {
                out.push('%');
                out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize]));
                out.push(char::from(b"0123456789ABCDEF"[(b & 0xf) as usize]));
            }
        }
    }
}

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

    struct TestIdRoute {
        id: i64,
    }

    impl RouteTag for TestIdRoute {
        const PATH: &'static str = "/items/{id}/edit";
        const PARAMS: &'static [&'static str] = &["id"];
    }

    impl RouteUrl for TestIdRoute {
        fn path(self) -> String {
            format!("/items/{}/edit", self.id)
        }
    }

    struct TestStaticRoute;

    impl RouteTag for TestStaticRoute {
        const PATH: &'static str = "/proposals/create";
    }

    impl RouteUrl for TestStaticRoute {
        fn path(self) -> String {
            Self::PATH.to_owned()
        }
    }

    #[test]
    fn route_url_adds_slash() {
        assert_eq!(TestStaticRoute.url(), "/proposals/create/");
        assert_eq!(TestIdRoute { id: 1 }.url(), "/items/1/edit/");
    }

    #[test]
    fn query_params_and_encoding() {
        assert_eq!(
            RouteQueryBuilder::new(TestStaticRoute)
                .query("ClientID", 42)
                .query("return", "client")
                .build_with_query(),
            "/proposals/create?ClientID=42&return=client"
        );
        assert_eq!(
            RouteQueryBuilder::new(TestStaticRoute)
                .query("identifier", "a+b")
                .build_with_query(),
            "/proposals/create?identifier=a%2Bb"
        );
    }

    #[test]
    fn query_opt_skips_none() {
        assert_eq!(
            RouteQueryBuilder::new(TestStaticRoute)
                .query("ClientID", 1)
                .query_opt("return", None::<&str>)
                .build_with_query(),
            "/proposals/create?ClientID=1"
        );
    }

    #[tokio::test]
    async fn url_and_query_builder_carry_dashboard_origin() {
        crate::components::nav_origin::scope_from_dashboard(true, async {
            #[cfg(feature = "plugin-dashboard")]
            {
                assert_eq!(TestStaticRoute.url(), "/proposals/create/?from=dashboard");
                assert_eq!(
                    RouteQueryBuilder::new(TestStaticRoute)
                        .query("tab", "active")
                        .build(),
                    "/proposals/create/?tab=active&from=dashboard"
                );
                assert_eq!(
                    RouteQueryBuilder::new(TestStaticRoute)
                        .query("tab", "active")
                        .build(),
                    RouteQueryBuilder::new(TestStaticRoute)
                        .query("tab", "active")
                        .query("from", "dashboard")
                        .build()
                );
            }
            #[cfg(not(feature = "plugin-dashboard"))]
            {
                assert_eq!(TestStaticRoute.url(), "/proposals/create/");
            }
        })
        .await;
    }
}