stackless-provider-sdk 0.2.1

Provider-facing traits and helpers for stackless catalog integrations
Documentation
//! Integration provider metadata consumed by the registry for validation,
//! output checking, and lifecycle dispatch. Each catalog adapter (Clerk, …)
//! implements [`Hostable`] once; the registry is built only from those impls.

/// Whether an integration's config may vary per stack host.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigScope {
    /// All settings live at `[integrations.<name>]`; host-key tables are rejected.
    GlobalOnly,
    /// `[integrations.<name>.<host>]` may override fields for hosts listed in
    /// [`IntegrationHosting::HostBound`].
    PerHost,
}

/// Where an integration's capability actually runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IntegrationHosting {
    /// Provider-operated cloud (e.g. Clerk). Not tied to `--on`; always available
    /// when referenced. Must pair with [`ConfigScope::GlobalOnly`].
    Managed,
    /// Runs on or through specific stack hosts; `--on` must be in the host list.
    HostBound(&'static [&'static str]),
}

/// A setting that cannot be applied with the credentials stackless holds and
/// must be enabled out-of-band (Dashboard, vendor CLI, …). Declared per provider
/// via [`Hostable::BLOCKED_SETTINGS`]; the registry fails validation with the
/// remediation when a blocked key is set truthy, instead of silently ignoring
/// it.
#[derive(Debug, Clone, Copy)]
pub struct BlockedSetting {
    /// Config key under `[integrations.<name>]`.
    pub key: &'static str,
    /// Exact out-of-band remediation (Dashboard path, CLI command, …).
    pub remediation: &'static str,
}

/// Metadata and compile-time constraints for one integration provider.
///
/// Implementors are registered in the integrations registry; validation and
/// dispatch never use free-form provider strings outside this table.
pub trait Hostable {
    /// Catalog name in `stackless.toml` (`provider = "clerk"`).
    const PROVIDER: &'static str;
    /// Runtime placement model — see [`IntegrationHosting`].
    const HOSTING: IntegrationHosting;
    /// TOML override policy — see [`ConfigScope`].
    const CONFIG_SCOPE: ConfigScope;
    /// Checkpoint `resource_kind` written by the provisioner.
    const RESOURCE_KIND: &'static str;
    /// Outputs available in `${integrations.<name>.<output>}` references.
    const OUTPUTS: &'static [&'static str];
    /// Settings stackless cannot apply with the credentials it holds; setting one
    /// truthy fails validation with its remediation. Default empty — most
    /// providers have none.
    const BLOCKED_SETTINGS: &'static [BlockedSetting] = &[];

    /// Compile-time guard: managed providers cannot accept per-host config.
    const VALIDATED: () = validate_hostable_pair(Self::HOSTING, Self::CONFIG_SCOPE);
}

/// Managed integrations must be globally configured.
// Compile-time guard (evaluated via the `VALIDATED` associated const), so this
// `panic!` is a build-time assertion that can never fire at runtime.
#[allow(clippy::panic)]
const fn validate_hostable_pair(hosting: IntegrationHosting, scope: ConfigScope) {
    match (hosting, scope) {
        (IntegrationHosting::Managed, ConfigScope::GlobalOnly) => (),
        (IntegrationHosting::Managed, ConfigScope::PerHost) => {
            panic!("Managed integrations must use ConfigScope::GlobalOnly")
        }
        _ => (),
    }
}

/// Whether `host` is listed for a host-bound provider.
pub fn host_bound_supports(hosting: IntegrationHosting, host: &str) -> bool {
    match hosting {
        IntegrationHosting::Managed => true,
        IntegrationHosting::HostBound(hosts) => hosts.contains(&host),
    }
}

/// Hosts declared for a host-bound provider (empty for managed). These are the
/// substrate keys that count as host overrides for this provider's config.
pub fn host_bound_hosts(hosting: IntegrationHosting) -> &'static [&'static str] {
    match hosting {
        IntegrationHosting::Managed => &[],
        IntegrationHosting::HostBound(hosts) => hosts,
    }
}

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

    #[test]
    fn host_bound_supports_declared_hosts_only() {
        let hosting = IntegrationHosting::HostBound(&["local", "render"]);
        assert!(host_bound_supports(hosting, "local"));
        assert!(host_bound_supports(hosting, "render"));
        assert!(!host_bound_supports(hosting, "vercel"));
    }

    #[test]
    fn managed_supports_every_active_host_check() {
        let hosting = IntegrationHosting::Managed;
        for host in ["local", "render", "vercel"] {
            assert!(host_bound_supports(hosting, host));
        }
    }
}