tovuk 0.1.75

Deploy Rust workers, static frontends, and worker-static apps to Tovuk.
use super::super::{project_kind::ProjectKind, resource_config::ResourceConfig};
use serde::Serialize;

#[derive(Clone, Debug, Serialize)]
pub(crate) struct TovukConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) name: Option<String>,
    pub(crate) kind: ProjectKind,
    pub(crate) capabilities: CapabilitiesConfig,
    pub(crate) build: BuildConfig,
    pub(crate) run: RunConfig,
    pub(crate) frontend: FrontendConfig,
    #[serde(rename = "worker")]
    pub(crate) backend: BackendConfig,
    pub(crate) resources: ResourceConfig,
}

#[derive(Clone, Debug, Serialize)]
pub(crate) struct CapabilitiesConfig {
    pub(crate) static_frontend: CapabilityToggle,
    pub(crate) worker: CapabilityToggle,
    pub(crate) sqlite: CapabilityToggle,
    pub(crate) object_storage: CapabilityToggle,
    pub(crate) kv: CapabilityToggle,
    pub(crate) state: CapabilityToggle,
    pub(crate) queue: CapabilityToggle,
    pub(crate) cron: CapabilityToggle,
    pub(crate) service_bindings: CapabilityToggle,
    pub(crate) secrets: CapabilityToggle,
    pub(crate) custom_domains: CapabilityToggle,
    pub(crate) logs: CapabilityToggle,
    pub(crate) builds: CapabilityToggle,
    pub(crate) usage_caps: CapabilityToggle,
    pub(crate) billing: CapabilityToggle,
    pub(crate) support: CapabilityToggle,
    pub(crate) abuse: CapabilityToggle,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(transparent)]
pub(crate) struct CapabilityToggle(bool);

impl CapabilityToggle {
    pub(crate) const fn from_bool(value: bool) -> Self {
        Self(value)
    }

    pub(crate) const fn enabled() -> Self {
        Self(true)
    }

    pub(crate) const fn is_enabled(self) -> bool {
        self.0
    }
}

impl CapabilitiesConfig {
    pub(crate) const KEYS: [&'static str; 17] = [
        "static_frontend",
        "worker",
        "sqlite",
        "object_storage",
        "kv",
        "state",
        "queue",
        "cron",
        "service_bindings",
        "secrets",
        "custom_domains",
        "logs",
        "builds",
        "usage_caps",
        "billing",
        "support",
        "abuse",
    ];

    pub(crate) fn for_kind(kind: ProjectKind) -> Self {
        Self {
            static_frontend: CapabilityToggle::from_bool(matches!(
                kind,
                ProjectKind::StaticFrontend | ProjectKind::WorkerStatic
            )),
            worker: CapabilityToggle::from_bool(matches!(
                kind,
                ProjectKind::RustWorker | ProjectKind::WorkerStatic
            )),
            sqlite: CapabilityToggle::from_bool(false),
            object_storage: CapabilityToggle::from_bool(false),
            kv: CapabilityToggle::from_bool(false),
            state: CapabilityToggle::from_bool(false),
            queue: CapabilityToggle::from_bool(false),
            cron: CapabilityToggle::from_bool(false),
            service_bindings: CapabilityToggle::from_bool(false),
            secrets: CapabilityToggle::from_bool(false),
            custom_domains: CapabilityToggle::from_bool(false),
            logs: CapabilityToggle::enabled(),
            builds: CapabilityToggle::enabled(),
            usage_caps: CapabilityToggle::enabled(),
            billing: CapabilityToggle::enabled(),
            support: CapabilityToggle::enabled(),
            abuse: CapabilityToggle::enabled(),
        }
    }

    pub(crate) fn enabled_keys(&self) -> Vec<&'static str> {
        self.filtered_keys(true)
    }

    pub(crate) fn disabled_keys(&self) -> Vec<&'static str> {
        self.filtered_keys(false)
    }

    pub(crate) fn enabled_product_keys(&self) -> Vec<&'static str> {
        self.enabled_keys()
            .into_iter()
            .filter(|key| !matches!(*key, "billing" | "support" | "abuse"))
            .collect()
    }

    fn filtered_keys(&self, expected: bool) -> Vec<&'static str> {
        Self::KEYS
            .iter()
            .copied()
            .filter(|key| self.value_for_key(key) == expected)
            .collect()
    }

    pub(crate) fn value_for_key(&self, key: &str) -> bool {
        match key {
            "static_frontend" => self.static_frontend.is_enabled(),
            "worker" => self.worker.is_enabled(),
            "sqlite" => self.sqlite.is_enabled(),
            "object_storage" => self.object_storage.is_enabled(),
            "kv" => self.kv.is_enabled(),
            "state" => self.state.is_enabled(),
            "queue" => self.queue.is_enabled(),
            "cron" => self.cron.is_enabled(),
            "service_bindings" => self.service_bindings.is_enabled(),
            "secrets" => self.secrets.is_enabled(),
            "custom_domains" => self.custom_domains.is_enabled(),
            "logs" => self.logs.is_enabled(),
            "builds" => self.builds.is_enabled(),
            "usage_caps" => self.usage_caps.is_enabled(),
            "billing" => self.billing.is_enabled(),
            "support" => self.support.is_enabled(),
            "abuse" => self.abuse.is_enabled(),
            _ => false,
        }
    }
}

#[derive(Clone, Debug, Serialize)]
pub(crate) struct BuildConfig {
    pub(crate) command: String,
    pub(crate) check: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) output: Option<String>,
}

#[derive(Clone, Debug, Serialize)]
pub(crate) struct RunConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) command: Option<String>,
    pub(crate) port: u16,
    pub(crate) health: String,
}

#[derive(Clone, Debug, Default, Serialize)]
pub(crate) struct FrontendConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) root: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) check: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) build: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) output: Option<String>,
}

#[derive(Clone, Debug, Default, Serialize)]
pub(crate) struct BackendConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) root: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) check: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) build: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) command: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) port: Option<u16>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) health: Option<String>,
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::{
        super::super::{project_kind::ProjectKind, resource_config::ResourceConfig},
        BackendConfig, BuildConfig, CapabilitiesConfig, FrontendConfig, RunConfig, TovukConfig,
    };

    #[test]
    fn serializes_worker_static_backend_as_worker() {
        let config = TovukConfig {
            name: Some("fullstack".to_owned()),
            kind: ProjectKind::WorkerStatic,
            capabilities: CapabilitiesConfig::for_kind(ProjectKind::WorkerStatic),
            build: BuildConfig {
                command: "cargo build --release".to_owned(),
                check: "cargo fmt --all --check".to_owned(),
                output: None,
            },
            run: RunConfig {
                command: None,
                port: 3000,
                health: "/healthz".to_owned(),
            },
            frontend: FrontendConfig {
                root: Some("web".to_owned()),
                check: Some("bun ci && bun run typecheck && bun run lint".to_owned()),
                build: Some("bun run build".to_owned()),
                output: Some("dist".to_owned()),
            },
            backend: BackendConfig {
                root: Some("api".to_owned()),
                check: Some("cargo fmt --all --check".to_owned()),
                build: Some("cargo build --release".to_owned()),
                command: Some("./target/release/api".to_owned()),
                port: Some(3000),
                health: Some("/api/healthz".to_owned()),
            },
            resources: ResourceConfig {
                memory: "128mb".to_owned(),
                cpu: "1".to_owned(),
                idle_timeout_minutes: 15,
            },
        };

        let value = serde_json::to_value(config)
            .unwrap_or_else(|error| json!({ "serialization_error": error.to_string() }));

        assert_eq!(value["worker"]["root"], json!("api"));
        assert_eq!(value.get("backend"), None);
    }
}