arcature 0.1.0

Arcature: an opinionated full-stack Rust web framework. One package, batteries included.
Documentation
//! `ModuleDescriptor` -- the metadata for a single Arcature feature module.
//!
//! A module descriptor is the compile-time metadata that the `module!` macro
//! generates. It is a plain data struct -- no behavior, no runtime
//! connection -- suitable for side-effect-free inspection (`arc typegen`,
//! `arc build`). The `application!` macro collects descriptors into an
//! [`ApplicationGraph`](super::application_graph::ApplicationGraph).
//!
//! All fields use `&'static [&'static str]` (not `Vec`) so a descriptor can
//! be constructed as a `const` by the `module!` macro -- no allocation, no
//! runtime cost.
//!
//! # Binding metadata
//!
//! The listener / job / schedule binding types come from the runtime
//! subsystems they describe: [`ListenerBinding`](crate::events::ListenerBinding)
//! from `events`, [`ScheduleBinding`](crate::jobs::ScheduleBinding) /
//! [`ScheduleCadence`](crate::jobs::ScheduleCadence) from `jobs`. The
//! [`JobBinding`] and [`CommandBinding`] below are pure compile-time
//! metadata with no runtime counterpart (the `jobs` runtime registers
//! handlers via `Registry::add`; the `commands` runtime registers via
//! `CommandRegistry::register`), so they live here alongside the
//! `ModuleDescriptor` that aggregates them.

/// A compile-time job handler binding.
///
/// Records that a handler function is bound to a job kind+version, for the
/// Unified Application Graph artifact. The `module!` macro generates
/// `&'static [JobBinding]` from the `jobs:` section.
///
/// This is metadata only -- it does NOT register the handler at runtime.
/// The application registers handlers explicitly via
/// `Registry::add` at startup.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct JobBinding {
    /// The job kind string (e.g. `"send_verification_email"`).
    pub kind: &'static str,
    /// The job version (incremented on payload schema changes).
    pub version: i16,
    /// The handler function name (e.g. `"handle_send_verification_email"`).
    pub handler: &'static str,
}

/// A compile-time application command binding.
///
/// Records that a function is bound to a command name, for the Unified
/// Application Graph artifact. The `module!` macro generates
/// `&'static [CommandBinding]` from the `commands:` section.
///
/// This is metadata only -- it does NOT register the command at runtime.
/// The application registers commands explicitly via
/// `CommandRegistry::register` at startup.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct CommandBinding {
    /// The command name (e.g. `"users:prune"`).
    pub name: &'static str,
    /// The command function name (e.g. `"prune_users"`).
    pub function: &'static str,
}

/// The metadata for a single feature module.
///
/// Generated by the `module!` macro as a `const` and registered into the
/// [`ApplicationGraph`](super::application_graph::ApplicationGraph) by the
/// `application!` macro. Every field is a `&'static` slice -- no `Vec`, no
/// trait objects, no `TypeId`, no runtime reflection, no allocation.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ModuleDescriptor {
    /// The module name (e.g. `"Accounts"`, `"Links"`).
    pub name: &'static str,
    /// Modules this module imports (by name). Only imported modules'
    /// exported capabilities are visible to this module.
    pub imports: &'static [&'static str],
    /// Capabilities this module exports (by name). Only exported
    /// capabilities are visible to importing modules.
    pub exports: &'static [&'static str],
    /// Controller type names registered in this module.
    pub controllers: &'static [&'static str],
    /// Per-controller method metadata, parallel to
    /// [`controllers`](Self::controllers "controllers"): entry `i` is the
    /// `ControllerMetadata::METHODS` slice for controller `controllers[i]`.
    /// Each [`ControllerMethod`](super::controller_metadata::ControllerMethod)
    /// carries the method name and the page-contract identity derived from
    /// the handler's `Page<T>` return type. The UAG joins
    /// `RouteDescriptor.handler` (`"ControllerType::method"`) to these
    /// entries to infer the route->page edge when the route declares no
    /// `page:`/`pages:`. Empty by default.
    pub controller_methods: &'static [&'static [super::controller_metadata::ControllerMethod]],
    /// Service type names registered in this module.
    pub services: &'static [&'static str],
    /// Policy type names registered in this module.
    pub policies: &'static [&'static str],
    /// Route descriptors for routes declared in this module. Empty by
    /// default; populated when a `routes:` section is added to `module!`.
    /// See [`RouteDescriptor`](super::route_metadata::RouteDescriptor).
    pub routes: &'static [super::route_metadata::RouteDescriptor],
    /// Event -> listener bindings declared in this module. Empty by
    /// default; populated when a `listeners:` section is added to `module!`.
    pub listeners: &'static [crate::events::ListenerBinding],
    /// Job handler bindings declared in this module. Empty by
    /// default; populated when a `jobs:` section is added to `module!`.
    pub jobs: &'static [JobBinding],
    /// Application command bindings declared in this module. Empty by
    /// default; populated when a `commands:` section is added to `module!`.
    pub commands: &'static [CommandBinding],
    /// Schedule bindings declared in this module. Empty by default;
    /// populated when a `schedules:` section is added to `module!`.
    pub schedules: &'static [crate::jobs::ScheduleBinding],
    /// Frontend page identities owned by this module, populated from
    /// `module!`'s `pages:` section.
    ///
    /// The names are read off each page type's `PAGE_CONTRACT_ENTRY` const,
    /// so listing a page here is only possible for a type the `#[page]`
    /// macro accepted -- the Client Exposure Firewall is enforced by the
    /// same const that supplies the name, not re-checked afterwards.
    ///
    /// Names rather than
    /// [`PageContractEntry`](crate::inertia::PageContractEntry) values:
    /// an entry carries a `fn` pointer, which has no meaningful
    /// serialization and no equality, and it would drag the `inertia`
    /// feature into a descriptor that every application builds. The UAG
    /// joins these names to `RouteDescriptor.pages` and to
    /// `ControllerMethod.page`, all three of which are the same identity.
    pub pages: &'static [&'static str],
}

impl ModuleDescriptor {
    /// Creates a new module descriptor with the given name and empty lists.
    pub const fn new(name: &'static str) -> Self {
        Self {
            name,
            imports: &[],
            exports: &[],
            controllers: &[],
            controller_methods: &[],
            services: &[],
            policies: &[],
            routes: &[],
            listeners: &[],
            jobs: &[],
            commands: &[],
            schedules: &[],
            pages: &[],
        }
    }
}

/// A node in the module dependency graph, used for cycle detection and
/// inspection. Derived from a [`ModuleDescriptor`]'s import edges.
#[derive(Debug, Clone, serde::Serialize)]
pub struct ModuleNode {
    /// The module name.
    pub name: &'static str,
    /// The modules this module imports (edges in the dependency graph).
    pub imports: Vec<&'static str>,
}

impl From<&ModuleDescriptor> for ModuleNode {
    fn from(desc: &ModuleDescriptor) -> Self {
        ModuleNode {
            name: desc.name,
            imports: desc.imports.to_vec(),
        }
    }
}

/// Builds a map of module-name -> [`ModuleNode`] from a slice of descriptors.
/// Used by [`ApplicationGraph`](super::application_graph::ApplicationGraph)
/// for cycle detection and inspection.
pub fn module_node_map(
    descriptors: &[ModuleDescriptor],
) -> std::collections::BTreeMap<&'static str, ModuleNode> {
    descriptors
        .iter()
        .map(|d| (d.name, ModuleNode::from(d)))
        .collect()
}

// Re-export the runtime binding types so the macros / UAG can reference all
// binding metadata from one place (`crate::dx::graph::*`). These types are
// owned by their runtime subsystems (events / jobs); this re-export does
// not duplicate them.
pub use crate::events::ListenerBinding;
pub use crate::jobs::{ScheduleBinding, ScheduleCadence};

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

    #[test]
    fn schedule_cadence_serializes_with_kind_tag() {
        let every = ScheduleCadence::Every { seconds: 300 };
        assert_eq!(
            serde_json::to_string(&every).unwrap(),
            r#"{"kind":"every","seconds":300}"#
        );
        let daily = ScheduleCadence::Daily { hour: 3, minute: 0 };
        assert_eq!(
            serde_json::to_string(&daily).unwrap(),
            r#"{"kind":"daily","hour":3,"minute":0}"#
        );
    }

    #[test]
    fn module_descriptor_new_has_empty_lists() {
        const DESC: ModuleDescriptor = ModuleDescriptor::new("Links");
        assert_eq!(DESC.name, "Links");
        assert!(DESC.imports.is_empty());
        assert!(DESC.controllers.is_empty());
        assert!(DESC.routes.is_empty());
    }

    #[test]
    fn module_node_map_builds_from_descriptors() {
        const DESCRIPTORS: &[ModuleDescriptor] =
            &[ModuleDescriptor::new("A"), ModuleDescriptor::new("B")];
        let map = module_node_map(DESCRIPTORS);
        assert_eq!(map.len(), 2);
        assert!(map.contains_key("A"));
        assert!(map.contains_key("B"));
    }

    #[test]
    fn job_binding_serializes_to_json() {
        let binding = JobBinding {
            kind: "send_email",
            version: 2,
            handler: "handle_send_email",
        };
        let json = serde_json::to_string(&binding).unwrap();
        assert!(json.contains("\"kind\":\"send_email\""), "{json}");
        assert!(json.contains("\"version\":2"), "{json}");
        assert!(json.contains("\"handler\":\"handle_send_email\""), "{json}");
    }
}