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
//! The [`Application`] type — the high-level composition root.
//!
//! [`Application`] sits above the low-level [`crate::App`] kernel (engine spec
//! §1). It holds the resolved routes, an optional application proxy function,
//! the bind address/port, and — when subsystem features are enabled — optional
//! config for the post-routing layers (Inertia, pages, maintenance) and
//! optional lifecycle config for runtime subsystems (db, cache, storage, mail,
//! jobs). The pipeline assembler (`crate::pipeline`) composes the pipeline
//! layers into the correct lifecycle-zone ordering when `serve`/`run` is
//! called. The startup/shutdown functions (`super::startup` / `super::shutdown`)
//! connect and tear down the runtime subsystems in a coordinated order when
//! `run_with_lifecycle` is called.
//!
//! State `S` is the Axum router state type, carried at the type level exactly
//! as in [`crate::App`]; it defaults to `()` (a stateless app). Serving
//! requires the state to be resolved to `()` — call
//! `ApplicationBuilder::state` (the engine analogue of `axum::Router::with_state`)
//! before `serve`/`run`, or pass a state-building closure to
//! `run_with_lifecycle` (which resolves state from the live [`Resources`]
//! after startup).

use std::sync::Arc;

use crate::proxy::{ProxyAction, ProxyRequest};

/// The default bind address used by `Application::run`.
pub(crate) const DEFAULT_BIND_ADDR: &str = "127.0.0.1";

/// The default port used by `Application::run`.
pub(crate) const DEFAULT_PORT: u16 = 3000;

/// The application proxy function signature: a synchronous, pure decision from
/// the incoming request to a [`ProxyAction`]. The engine owns all HTTP
/// plumbing; the application owns only policy (engine spec §5).
pub type ProxyFn = Arc<dyn Fn(ProxyRequest<'_>) -> ProxyAction + Send + Sync + 'static>;

/// The high-level Arcature application composition root.
///
/// Built via [`ApplicationBuilder`](crate::ApplicationBuilder) (see
/// [`Application::new`]) and served via `serve`/`run` once state is resolved
/// to `()`.
///
/// This is not a god object (engine spec §12): it holds only the resolved
/// routes, an optional proxy handle, bind metadata, and cfg-gated optional
/// subsystem layer config. Subsystem *resources* (db connections, cache
/// pools, etc.) are added as typed cfg-gated fields in the runtime-lifecycle
/// PR, never as a `HashMap<TypeId, Box<dyn Any>>`.
pub struct Application<S = ()> {
    pub(crate) routes: crate::Routes<S>,
    pub(crate) proxy: Option<ProxyFn>,
    pub(crate) bind_address: String,
    pub(crate) port: u16,

    /// Optional Inertia config. When `Some`, the pipeline assembler applies
    /// `InertiaLayer` as a post-routing layer (engine spec §36).
    #[cfg(feature = "inertia")]
    pub(crate) inertia_config: Option<crate::inertia::InertiaConfig>,
    #[cfg(feature = "inertia")]
    pub(crate) page_contracts: Option<crate::inertia::PageContracts>,

    /// Optional special-pages config. When `Some`, the pipeline assembler
    /// installs the 404 fallback via `fallback_service`. When `None`, the
    /// engine uses `Pages::default()` for the fallback (engine spec §37/§9).
    #[cfg(feature = "pages")]
    pub(crate) pages: Option<crate::pages::Pages>,

    /// Optional maintenance guard. When `Some`, the pipeline assembler applies
    /// `MaintenanceLayer` as a post-routing layer that short-circuits with 503
    /// when the application is in maintenance mode (engine spec §10).
    #[cfg(feature = "pages")]
    pub(crate) maintenance_guard: Option<crate::pages::MaintenanceGuard>,

    // ── Lifecycle config (consumed by `startup`, not the pipeline) ─────────
    // Each is `Option` so the builder can default to "no connection" even when
    // the feature compiles. `run_with_lifecycle` reads these, passes them to
    // `startup()`, and sets them to `None` on the resolved `Application<()>`.
    /// Database config for `startup()`. When `Some`, the engine builds one
    /// `PgPool` via `Db::connect`, shares it with jobs, and exposes the `Db`
    /// handle via `Resources::db()`.
    #[cfg(feature = "db")]
    pub(crate) database: Option<crate::db::DbConfig>,

    /// Cache config for `startup()`.
    #[cfg(feature = "cache")]
    pub(crate) cache_config: Option<crate::cache::CacheConfig>,

    /// Storage config for `startup()`.
    #[cfg(feature = "storage")]
    pub(crate) storage_config: Option<crate::storage::StorageConfig>,

    /// SMTP config for `startup()`.
    #[cfg(feature = "mail")]
    pub(crate) mail_config: Option<crate::mail::SmtpConfig>,

    /// Job handler registry for `startup()`. When `Some`, the engine spawns a
    /// worker over the shared `PgPool`. The registry is either pre-built
    /// ([`JobsRegistry::Static`], via `.jobs(registry)`) or built after the
    /// pool connects ([`JobsRegistry::WithDb`], via `.jobs_with_db(closure)`)
    /// so handlers can capture a `Db` clone.
    #[cfg(feature = "jobs")]
    pub(crate) jobs_registry: Option<super::jobs_registry::JobsRegistry>,

    /// Optional worker config (defaults to `WorkerConfig::default()` when
    /// `None`). Only used when `jobs_registry` is `Some`.
    #[cfg(feature = "jobs")]
    pub(crate) worker_config: Option<crate::jobs::WorkerConfig>,

    /// Optional global error-mapping function. When `Some`, the pipeline
    /// assembler applies `ErrorMappingLayer` as a post-routing layer that
    /// passes every response through the function (A10). The function
    /// typically reformats error responses (e.g. 5xx → Problem Details).
    #[cfg(feature = "dx")]
    pub(crate) error_mapping: Option<crate::pipeline::error_mapping::ErrorMapFn>,

    /// Optional explicit Vite IPC endpoint for the one-port dev proxy
    /// (AP2.1-3). When `Some`, the pipeline assembler uses this endpoint
    /// directly; when `None`, it falls back to `ARCATURE_VITE_IPC` (the
    /// `arc dev` convention). This is the explicit, typed configuration seam
    /// (AGENTS.md §21: configuration is explicit and resolved); the env var
    /// remains the default path `arc dev` sets. `IpcEndpoint` is `pub(crate)`;
    /// the builder method accepts an `Option<PathBuf>`.
    #[cfg(feature = "dev-proxy")]
    pub(crate) dev_proxy_endpoint: Option<crate::dev_proxy::endpoint::IpcEndpoint>,
}

impl Application<()> {
    /// Start a stateless [`ApplicationBuilder`](crate::ApplicationBuilder)
    /// (empty route table, default bind `127.0.0.1:3000`). This is the normal
    /// entry point:
    ///
    /// ```
    /// use arcature::prelude::*;
    /// # fn _r() -> Application {
    /// Application::new()
    ///     .routes(Routes::new().route("/", get(|| async { "ok" })))
    ///     .build()
    /// # }
    /// ```
    ///
    /// State `S` of the returned builder is `()`; call `.routes(Routes<T>)` to
    /// switch to a stateful router, then `.state(value)` to resolve back to
    /// `()` before serving.
    //
    // `new()` returns `ApplicationBuilder<()>`, not `Self`, by design: the
    // builder pattern consumes and re-emits the builder through fluent
    // methods. This is the canonical builder entry point.
    #[allow(clippy::new_ret_no_self)]
    #[must_use]
    pub fn new() -> crate::ApplicationBuilder<()> {
        crate::ApplicationBuilder::new()
    }
}

impl<S> Application<S> {
    /// The configured bind address.
    #[must_use]
    pub fn bind_address(&self) -> &str {
        &self.bind_address
    }

    /// The configured port.
    #[must_use]
    pub fn port(&self) -> u16 {
        self.port
    }

    /// The registered Cross-Stack Linker page contracts, if configured.
    #[cfg(feature = "inertia")]
    pub fn page_contracts(&self) -> Option<&crate::inertia::PageContracts> {
        self.page_contracts.as_ref()
    }
}

impl<S> Default for Application<S>
where
    S: Clone + Send + Sync + 'static,
{
    fn default() -> Self {
        let (addr, port) = (DEFAULT_BIND_ADDR, DEFAULT_PORT);
        Self {
            routes: crate::Routes::new(),
            proxy: None,
            bind_address: addr.to_owned(),
            port,
            #[cfg(feature = "inertia")]
            inertia_config: None,
            #[cfg(feature = "inertia")]
            page_contracts: None,
            #[cfg(feature = "pages")]
            pages: None,
            #[cfg(feature = "pages")]
            maintenance_guard: None,
            #[cfg(feature = "db")]
            database: None,
            #[cfg(feature = "cache")]
            cache_config: None,
            #[cfg(feature = "storage")]
            storage_config: None,
            #[cfg(feature = "mail")]
            mail_config: None,
            #[cfg(feature = "jobs")]
            jobs_registry: None,
            #[cfg(feature = "jobs")]
            worker_config: None,
            #[cfg(feature = "dx")]
            error_mapping: None,
            #[cfg(feature = "dev-proxy")]
            dev_proxy_endpoint: None,
        }
    }
}

impl<S> std::fmt::Debug for Application<S> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Do not derive Debug: `ProxyFn` (Arc<dyn Fn>) and subsystem configs
        // may not be Debug. Surface only safe metadata.
        let mut d = f.debug_struct("Application");
        d.field("bind_address", &self.bind_address)
            .field("port", &self.port)
            .field("has_proxy", &self.proxy.is_some());
        #[cfg(feature = "inertia")]
        d.field("has_inertia", &self.inertia_config.is_some());
        #[cfg(feature = "inertia")]
        d.field("has_page_contracts", &self.page_contracts.is_some());
        #[cfg(feature = "pages")]
        d.field("has_pages", &self.pages.is_some());
        #[cfg(feature = "pages")]
        d.field("has_maintenance", &self.maintenance_guard.is_some());
        #[cfg(feature = "db")]
        d.field("has_database", &self.database.is_some());
        #[cfg(feature = "cache")]
        d.field("has_cache", &self.cache_config.is_some());
        #[cfg(feature = "storage")]
        d.field("has_storage", &self.storage_config.is_some());
        #[cfg(feature = "mail")]
        d.field("has_mail", &self.mail_config.is_some());
        #[cfg(feature = "jobs")]
        d.field("has_jobs", &self.jobs_registry.is_some());
        #[cfg(feature = "dx")]
        d.field("has_error_mapping", &self.error_mapping.is_some());
        d.finish()
    }
}