arcature 2026.2.1

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! Route metadata for the Arcature application DX layer.
//!
//! The `routes!` macro generates [`RouteDescriptor`] values as `const` items
//! — plain data with `&'static` slices, no allocation, no runtime reflection.
//! These descriptors feed `arc routes` inspection, `arc check` validation, and
//! the `ApplicationGraph` route metadata.
//!
//! All types are const-constructible (unit enum variants, `&'static str`
//! fields) so the macro can emit them as `const` items.

/// An HTTP method supported by the `routes!` DSL.
///
/// The variants map 1:1 to the axum method-routing constructors
/// (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`).
/// Const-constructible (unit variants).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
pub enum RouteMethod {
    /// `GET` — map to `axum::routing::get`.
    Get,
    /// `POST` — map to `axum::routing::post`.
    Post,
    /// `PUT` — map to `axum::routing::put`.
    Put,
    /// `PATCH` — map to `axum::routing::patch`.
    Patch,
    /// `DELETE` — map to `axum::routing::delete`.
    Delete,
    /// `HEAD` — map to `axum::routing::head`.
    Head,
    /// `OPTIONS` — map to `axum::routing::options`.
    Options,
}

impl RouteMethod {
    /// Returns the uppercase HTTP method string (e.g. `"GET"`, `"POST"`).
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Get => "GET",
            Self::Post => "POST",
            Self::Put => "PUT",
            Self::Patch => "PATCH",
            Self::Delete => "DELETE",
            Self::Head => "HEAD",
            Self::Options => "OPTIONS",
        }
    }

    /// Returns the lowercase axum routing function name (e.g. `"get"`,
    /// `"post"`).
    #[must_use]
    pub const fn as_routing_fn(self) -> &'static str {
        match self {
            Self::Get => "get",
            Self::Post => "post",
            Self::Put => "put",
            Self::Patch => "patch",
            Self::Delete => "delete",
            Self::Head => "head",
            Self::Options => "options",
        }
    }
}

impl std::fmt::Display for RouteMethod {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Metadata for a single route, generated by the `routes!` macro.
///
/// Every field is `&'static str` or a unit enum — no `Vec`, no `String`, no
/// allocation. A `const` array of `RouteDescriptor` is the inspection
/// artifact consumed by `arc routes` and `arc check`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct RouteDescriptor {
    /// The HTTP method.
    pub method: RouteMethod,
    /// The full path pattern (e.g. `"/links/{link}"`). Group prefixes are
    /// already prepended by the macro.
    pub path: &'static str,
    /// The dotted route name (e.g. `"auth.login"`, `"links.index"`). Empty
    /// string `""` for unnamed routes.
    pub name: &'static str,
    /// The handler path as a string (e.g. `"SessionsController::create"`).
    pub handler: &'static str,
    /// The page identities this route renders, declared statically via the
    /// `page:`/`pages:` route option. Each entry is a canonical frontend
    /// page name matching a `#[page("Name")]` contract. Empty for routes
    /// that do not render an Inertia page (API endpoints, redirects,
    /// destroy actions).
    ///
    /// This is the statically-explainable route→page edge (AP2.1-4 H4): the
    /// `routes!` macro emits it from route metadata, not by inferring
    /// `render_page` calls from the handler function body. The UAG records
    /// it; cross-stack validation checks every declared page exists in the
    /// `PageContracts` registry and has a frontend component — no runtime
    /// reflection, no source-body behavioral inference.
    #[cfg_attr(feature = "serde", serde(default))]
    pub pages: &'static [&'static str],
    /// The typed input field shapes for an **Action** route, declared via
    /// the `action: RequestType` route option (AP2.1-5, ADR-0007). The
    /// `routes!` macro resolves `<RequestType as RequestMetadata>::FIELDS`
    /// at compile time and bakes the slice here — no runtime type registry,
    /// no `TypeId`. Empty (`&[]`) for routes that are not actions. Actions
    /// are typed browser clients around **explicit** mutation routes (real
    /// non-safe-method HTTP requests); there is no `/_arc/actions` endpoint
    /// and no opaque dispatch (ADR-0007).
    #[cfg_attr(feature = "serde", serde(default))]
    pub action_fields: &'static [super::field_metadata::FieldShape],
    /// The request input type name for an **Action** route (e.g.
    /// `"StoreLinkRequest"`), used to name the generated TypeScript input
    /// interface. Empty (`""`) for non-action routes. This mirrors
    /// [`Self::query_type`]: the codegen names the input interface after the
    /// Rust request type the developer declared, so the generated TS stays
    /// faithful to the source (not a name synthesized from the route name).
    /// A no-body action (`DELETE /links/{link}` with an empty request struct)
    /// carries an empty `action_fields` slice but a non-empty `action_type`,
    /// so the codegen can still emit a typed client for it — `action_fields`
    /// alone could not distinguish it from a plain non-action route.
    #[cfg_attr(feature = "serde", serde(default))]
    pub action_type: &'static str,
    /// The typed response element field shapes for a **Query** route,
    /// declared via the `query: ResourceType` (single) or
    /// `query: Vec<ResourceType>` (collection) route option (AP2.1-5,
    /// ADR-0007). The `routes!` macro resolves
    /// `<ResourceType as ResourceMetadata>::FIELDS` at compile time and bakes
    /// the slice here. Empty (`&[]`) for routes that are not queries. Queries
    /// are typed GET read helpers (dedupe / latest-wins / optional debounce);
    /// `GET` never mutates (ADR-0007).
    #[cfg_attr(feature = "serde", serde(default))]
    pub query_fields: &'static [super::field_metadata::FieldShape],
    /// The response resource type name for a Query route (e.g.
    /// `"LinkResource"`), used to name the generated TypeScript response
    /// interface. Empty (`""`) for non-query routes. For `query: Vec<T>`,
    /// this is `T`'s name (the element type), and [`Self::query_array`] is
    /// `true`.
    #[cfg_attr(feature = "serde", serde(default))]
    pub query_type: &'static str,
    /// Whether a Query route's response is a collection (`query: Vec<T>` →
    /// `true`) or a single value (`query: T` → `false`). `false` for
    /// non-query routes. The codegen emits `Promise<T[]>` when `true` and
    /// `Promise<T>` when `false`.
    #[cfg_attr(feature = "serde", serde(default))]
    pub query_array: bool,
    /// The typed query-string field shapes for a **Query** route, declared via
    /// the `query_string: RequestType` route option (AP2.1-5, ADR-0007). The
    /// `routes!` macro resolves `<RequestType as RequestMetadata>::FIELDS` at
    /// compile time and bakes the slice here — the typed browser input beyond
    /// path params (e.g. `q`, `page`, `sort`, `filter`). Empty (`&[]`) for
    /// queries without a typed query-string contract (the default). The
    /// codegen emits a `QueryString<N>` type per query; the runtime encodes the
    /// values safely and deterministically (URL-encoded).
    #[cfg_attr(feature = "serde", serde(default))]
    pub query_string_fields: &'static [super::field_metadata::FieldShape],
    /// The query-string request type name for a Query route (e.g.
    /// `"LinkSearchRequest"`), used to name the generated TypeScript
    /// query-string interface. Empty (`""`) for queries without a typed
    /// query-string contract.
    #[cfg_attr(feature = "serde", serde(default))]
    pub query_string_type: &'static str,
}

/// A canonical resource action, used by `resource` route expansion and
/// `only`/`except` filtering.
///
/// This enum and its methods are consumed by the `arc routes` and `arc check`
/// CLI commands in later A-phases. They are part of the public inspection
/// API surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ResourceAction {
    /// `GET /resource` — list.
    Index,
    /// `GET /resource/new` — new form.
    Create,
    /// `POST /resource` — create.
    Store,
    /// `GET /resource/{id}` — show.
    Show,
    /// `GET /resource/{id}/edit` — edit form.
    Edit,
    /// `PUT /resource/{id}` — update.
    Update,
    /// `DELETE /resource/{id}` — destroy.
    Destroy,
}

#[allow(dead_code)] // Consumed by `arc routes` / `arc check` in later A-phases.
impl ResourceAction {
    /// Returns the action name as a lowercase string (e.g. `"index"`,
    /// `"store"`).
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Index => "index",
            Self::Create => "create",
            Self::Store => "store",
            Self::Show => "show",
            Self::Edit => "edit",
            Self::Update => "update",
            Self::Destroy => "destroy",
        }
    }

    /// Returns the HTTP method for this action.
    #[must_use]
    pub const fn method(self) -> RouteMethod {
        match self {
            Self::Index | Self::Create | Self::Show | Self::Edit => RouteMethod::Get,
            Self::Store => RouteMethod::Post,
            Self::Update => RouteMethod::Put,
            Self::Destroy => RouteMethod::Delete,
        }
    }

    /// Returns the path suffix for this action, relative to the resource
    /// base path. `{param}` is the route parameter placeholder. The
    /// returned string is allocated via `format!`.
    #[must_use]
    pub fn path_suffix(self, param: &str) -> String {
        match self {
            Self::Index | Self::Store => String::new(),
            Self::Create => "/new".to_string(),
            Self::Show | Self::Update | Self::Destroy => format!("/{{{param}}}"),
            Self::Edit => format!("/{{{param}}}/edit"),
        }
    }
}

impl std::fmt::Display for ResourceAction {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Parses a resource action name string into a [`ResourceAction`].
///
/// Returns `None` for unknown action names. Used by `only`/`except`
/// validation in the `routes!` macro.
#[must_use]
#[allow(dead_code)] // Consumed by `arc routes` / `arc check` in later A-phases.
pub fn parse_resource_action(name: &str) -> Option<ResourceAction> {
    match name {
        "index" => Some(ResourceAction::Index),
        "create" => Some(ResourceAction::Create),
        "store" => Some(ResourceAction::Store),
        "show" => Some(ResourceAction::Show),
        "edit" => Some(ResourceAction::Edit),
        "update" => Some(ResourceAction::Update),
        "destroy" => Some(ResourceAction::Destroy),
        _ => None,
    }
}

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

    #[test]
    fn route_method_display() {
        assert_eq!(RouteMethod::Get.to_string(), "GET");
        assert_eq!(RouteMethod::Post.to_string(), "POST");
        assert_eq!(RouteMethod::Delete.to_string(), "DELETE");
    }

    #[test]
    #[cfg(feature = "serde")]
    fn route_descriptor_serializes_to_json() {
        let descriptor = RouteDescriptor {
            method: RouteMethod::Get,
            path: "/links/{link}",
            name: "links.show",
            handler: "LinksController::show",
            pages: &["Show"],
            action_fields: &[],
            action_type: "",
            query_fields: &[],
            query_type: "",
            query_array: false,
            query_string_fields: &[],
            query_string_type: "",
        };
        let json = serde_json::to_string(&descriptor).unwrap();
        assert!(
            json.contains("\"method\":\"get\""),
            "method lowercase: {json}"
        );
        assert!(json.contains("\"path\":\"/links/{link}\""), "path: {json}");
        assert!(json.contains("\"name\":\"links.show\""), "name: {json}");
        assert!(
            json.contains("\"handler\":\"LinksController::show\""),
            "handler: {json}"
        );
        assert!(
            json.contains("\"pages\":[\"Show\"]"),
            "route→page edge serialized: {json}"
        );
    }

    #[test]
    fn route_method_routing_fn() {
        assert_eq!(RouteMethod::Get.as_routing_fn(), "get");
        assert_eq!(RouteMethod::Patch.as_routing_fn(), "patch");
    }

    #[test]
    fn resource_action_method_mapping() {
        assert_eq!(ResourceAction::Index.method(), RouteMethod::Get);
        assert_eq!(ResourceAction::Store.method(), RouteMethod::Post);
        assert_eq!(ResourceAction::Update.method(), RouteMethod::Put);
        assert_eq!(ResourceAction::Destroy.method(), RouteMethod::Delete);
    }

    #[test]
    fn parse_known_actions() {
        assert_eq!(parse_resource_action("index"), Some(ResourceAction::Index));
        assert_eq!(
            parse_resource_action("destroy"),
            Some(ResourceAction::Destroy)
        );
        assert_eq!(parse_resource_action("unknown"), None);
    }
}