coil-runtime 0.1.1

HTTP runtime and request handling for the Coil framework.
Documentation
use coil_auth::DefaultAuthModelPackage;
use coil_config::PlatformConfig;
use coil_runtime::{RuntimeBootstrapError, RuntimeBuildError, customer_root_runtime};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

const VALID_CONFIG: &str = r#"
[app]
name = "showcase-events"
environment = "production"

[server]
bind = "0.0.0.0:8080"
trusted_proxies = ["10.0.0.0/8"]

[http.session]
store = "redis"
idle_timeout_secs = 3600
absolute_timeout_secs = 86400

[http.session_cookie]
name = "coil_session"
path = "/"
same_site = "lax"
secure = true
http_only = true

[http.flash_cookie]
name = "coil_flash"
path = "/"
same_site = "lax"
secure = true
http_only = true

[http.csrf]
enabled = true
field_name = "_csrf"
header_name = "x-csrf-token"

[tls]
mode = "acme"
challenge = "dns-01"
provider = "cloudflare-dns"

[storage]
default_class = "public_upload"
single_node_escape_hatch = "explicit_single_node"
object_store = "s3"
object_store_secret = { kind = "env", var = "OBJECT_STORE_URL" }
local_root = "/tmp/coil-runtime-tests"
deployment = "single_node"

[cache]
l1 = "moka"
l2 = "redis"

[i18n]
default_locale = "en-GB"
supported_locales = ["en-GB", "fr-FR"]
fallback_locale = "en-GB"
localized_routes = true

[seo]
canonical_host = "www.example.com"
emit_json_ld = true

[auth]
package = "coil-default-auth"
explain_api = false
tenant_id = 101

[modules]
enabled = ["cms"]

[wasm]
directory = "extensions"
default_time_limit_ms = 50
allow_network = false

[jobs]
backend = "redis"

[observability]
metrics = true
tracing = true

[assets]
publish_manifest = true
cdn_base_url = "https://cdn.example.com"
"#;

struct TempAppRoot {
    path: PathBuf,
}

impl Drop for TempAppRoot {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.path);
    }
}

fn unique_temp_app_root(label: &str) -> TempAppRoot {
    let unique = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    let path = std::env::temp_dir().join(format!("coil-runtime-{label}-{unique}"));
    fs::create_dir_all(&path).unwrap();
    TempAppRoot { path }
}

fn write_template_file(root: &Path, relative: &str, contents: &str) {
    let path = root.join(relative);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).unwrap();
    }
    fs::write(path, contents).unwrap();
}

fn write_customer_root_manifest(root: &Path, enabled_modules: &[&str]) {
    let enabled = enabled_modules
        .iter()
        .map(|module| format!("\"{module}\""))
        .collect::<Vec<_>>()
        .join(", ");
    fs::write(
        root.join("app.toml"),
        format!(
            r#"[app]
name = "showcase-events"
display_name = "Showcase Events"

[domains]
canonical = "www.example.com"
additional = []

[i18n]
default_locale = "en-GB"
supported_locales = ["en-GB", "fr-FR"]
localized_routes = true

[theme]
active = "showcase"
template_namespaces = ["customer-app"]
asset_roots = []

[auth]
mode = "extend"
package = "coil-default-auth"

[modules]
enabled = [{enabled}]
"#
        ),
    )
    .unwrap();
}

#[test]
fn chapter96_customer_root_builder_requires_customer_root_context() {
    let config = PlatformConfig::from_toml_str(VALID_CONFIG).unwrap();

    let error = customer_root_runtime(config, DefaultAuthModelPackage::default())
        .register_module(coil_cms::CmsModule::new())
        .build()
        .unwrap_err();

    assert!(matches!(
        error,
        RuntimeBuildError::CustomerRootNotConfigured
    ));
}

#[test]
fn chapter96_customer_root_builder_filters_modules_to_manifest_enabled_set() {
    let app_root = unique_temp_app_root("chapter96-filter-modules");
    write_template_file(
        &app_root.path,
        "templates/pages/home.html",
        r#"<!doctype html>
<html xmlns:coil="https://coil.rs">
  <body><main>chapter96</main></body>
</html>"#,
    );
    write_customer_root_manifest(&app_root.path, &["cms"]);
    let config = PlatformConfig::from_toml_str(VALID_CONFIG).unwrap();

    let plan = customer_root_runtime(config, DefaultAuthModelPackage::default())
        .with_customer_root(&app_root.path)
        .register_module(coil_cms::CmsModule::new())
        .register_module(coil_admin::AdminModule::new())
        .build()
        .unwrap();

    assert_eq!(
        plan.modules
            .iter()
            .map(|module| module.name.as_str())
            .collect::<Vec<_>>(),
        vec!["cms"]
    );
}

#[test]
fn chapter96_customer_root_run_from_env_honors_manifest_module_filtering() {
    let app_root = unique_temp_app_root("chapter96-run-from-env");
    write_template_file(
        &app_root.path,
        "templates/pages/home.html",
        r#"<!doctype html>
<html xmlns:coil="https://coil.rs">
  <body><main>chapter96</main></body>
</html>"#,
    );
    write_customer_root_manifest(&app_root.path, &["admin"]);
    let config = PlatformConfig::from_toml_str(
        &VALID_CONFIG.replace("enabled = [\"cms\"]", "enabled = [\"admin\"]"),
    )
    .unwrap();

    let error = customer_root_runtime(config, DefaultAuthModelPackage::default())
        .with_customer_root(&app_root.path)
        .register_module(coil_cms::CmsModule::new())
        .run_from_env()
        .unwrap_err();

    assert!(matches!(
        error,
        RuntimeBootstrapError::Build(RuntimeBuildError::CustomerManifestMissingLinkedModules {
            modules
        }) if modules == vec!["admin".to_string()]
    ));
}