rullst-core 12.1.0

Core runtime, routing engine, and telemetry for the Rullst framework suite.
Documentation
#![allow(clippy::unwrap_used, clippy::expect_used)]

use super::*;
use crate::config::{Environment, RullstConfig};

struct EnvironmentGuard {
    values: Vec<(&'static str, Option<std::ffi::OsString>)>,
}

impl EnvironmentGuard {
    fn clear(keys: &[&'static str]) -> Self {
        let values = keys
            .iter()
            .map(|key| (*key, std::env::var_os(key)))
            .collect::<Vec<_>>();
        for key in keys {
            unsafe { std::env::remove_var(key) };
        }
        Self { values }
    }

    fn set(&self, key: &'static str, value: impl AsRef<std::ffi::OsStr>) {
        unsafe { std::env::set_var(key, value) };
    }
}

impl Drop for EnvironmentGuard {
    fn drop(&mut self) {
        for (key, value) in &self.values {
            unsafe {
                match value {
                    Some(value) => std::env::set_var(key, value),
                    None => std::env::remove_var(key),
                }
            }
        }
    }
}

#[tokio::test]
async fn networking_respects_precedence_defaults_and_invalid_values() {
    let _lock = crate::server::TEST_ENV_LOCK.lock().await;
    let environment = EnvironmentGuard::clear(&["HOST", "RULLST_HOST", "PORT"]);
    let empty = HashMap::new();

    let development = Server::setup_networking(3000, None, Environment::Development, &empty)
        .expect("development default address");
    assert_eq!(development, "127.0.0.1:3000".parse().unwrap());
    let production = Server::setup_networking(3000, Some(8080), Environment::Production, &empty)
        .expect("production default address");
    assert_eq!(production, "0.0.0.0:8080".parse().unwrap());

    let dotenv = HashMap::from([
        ("HOST".to_string(), "127.0.0.2".to_string()),
        ("RULLST_HOST".to_string(), "127.0.0.3".to_string()),
        ("PORT".to_string(), "4000".to_string()),
    ]);
    let from_dotenv = Server::setup_networking(3000, Some(8080), Environment::Test, &dotenv)
        .expect("dotenv address");
    assert_eq!(from_dotenv, "127.0.0.2:4000".parse().unwrap());

    environment.set("RULLST_HOST", "127.0.0.4");
    environment.set("PORT", "5000");
    let legacy_host = Server::setup_networking(3000, None, Environment::Test, &HashMap::new())
        .expect("legacy host address");
    assert_eq!(legacy_host, "127.0.0.4:5000".parse().unwrap());

    environment.set("HOST", "127.0.0.5");
    let primary_host = Server::setup_networking(3000, None, Environment::Test, &HashMap::new())
        .expect("primary host address");
    assert_eq!(primary_host, "127.0.0.5:5000".parse().unwrap());

    environment.set("PORT", "not-a-port");
    assert!(matches!(
        Server::setup_networking(3000, None, Environment::Test, &HashMap::new()),
        Err(ServerError::Configuration(message)) if message.contains("valid u16")
    ));
    environment.set("PORT", "3000");
    environment.set("HOST", "not a host");
    assert!(matches!(
        Server::setup_networking(3000, None, Environment::Test, &HashMap::new()),
        Err(ServerError::InvalidAddress { .. })
    ));
}

#[tokio::test]
async fn environment_resolution_obeys_process_dotenv_and_config_precedence() {
    let _lock = crate::server::TEST_ENV_LOCK.lock().await;
    let environment = EnvironmentGuard::clear(&["RULLST_ENV", "APP_ENV"]);
    let mut config = RullstConfig::new();
    config.app.env = Some("test".to_string());
    assert_eq!(
        resolve_environment(&config, &HashMap::new()).unwrap(),
        Environment::Test
    );

    let dotenv = HashMap::from([("APP_ENV".to_string(), "staging".to_string())]);
    assert_eq!(
        resolve_environment(&config, &dotenv).unwrap(),
        Environment::Staging
    );
    environment.set("APP_ENV", "production");
    assert_eq!(
        resolve_environment(&config, &dotenv).unwrap(),
        Environment::Production
    );
    environment.set("RULLST_ENV", "development");
    assert_eq!(
        resolve_environment(&config, &dotenv).unwrap(),
        Environment::Development
    );
    environment.set("RULLST_ENV", "invalid");
    assert!(matches!(
        resolve_environment(&config, &dotenv),
        Err(ServerError::Configuration(_))
    ));
}

#[cfg(unix)]
#[tokio::test]
async fn non_unicode_environment_values_fail_closed() {
    use std::os::unix::ffi::OsStringExt;

    let _lock = crate::server::TEST_ENV_LOCK.lock().await;
    let environment = EnvironmentGuard::clear(&["RULLST_NON_UNICODE_TEST"]);
    environment.set(
        "RULLST_NON_UNICODE_TEST",
        std::ffi::OsString::from_vec(vec![0xff]),
    );
    assert!(matches!(
        read_optional_environment_variable("RULLST_NON_UNICODE_TEST"),
        Err(ServerError::Configuration(message)) if message.contains("not valid Unicode")
    ));
}

#[tokio::test]
async fn scheduler_shield_and_missing_hot_library_have_typed_lifecycles() {
    let mut scheduled = Server::new(Router::new()).schedule(Scheduler::new());
    let handle = scheduled
        .start_scheduler()
        .expect("scheduler start")
        .expect("scheduler handle");
    handle.shutdown().await.expect("scheduler shutdown");
    assert!(scheduled.start_scheduler().unwrap().is_none());

    let shield = crate::resilience::TrafficShield::new(
        crate::resilience::TrafficShieldConfig::new().with_db_probe(false),
    );
    let shielded = Server::new(Router::new()).shield(shield);
    let lifecycle = shielded
        .start_traffic_shield()
        .expect("shield start")
        .expect("shield lifecycle");
    lifecycle.shutdown();
    assert!(
        Server::new(Router::new())
            .start_traffic_shield()
            .unwrap()
            .is_none()
    );

    let _lock = crate::server::TEST_ENV_LOCK.lock().await;
    let environment = EnvironmentGuard::clear(&["RULLST_HMR_TOKEN"]);
    environment.set(
        "RULLST_HMR_TOKEN",
        "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
    );
    let error = Server::new_hot("/definitely/missing/rullst-app")
        .run_hot_reload(
            "/definitely/missing/rullst-app".to_string(),
            "127.0.0.1:0".parse().unwrap(),
            Environment::Development,
            std::future::ready(()),
        )
        .await
        .unwrap_err();
    if cfg!(debug_assertions) {
        assert!(
            matches!(&error, ServerError::HotReload(_)),
            "debug hot reload should reach the missing-library boundary, got {error:?}"
        );
    } else {
        assert!(
            matches!(&error, ServerError::HotReloadDisabled),
            "release builds must reject hot reload before loading a library, got {error:?}"
        );
    }
}

#[test]
fn server_accepts_a_shared_application_lifecycle() {
    let lifecycle = crate::lifecycle::ApplicationLifecycle::new();
    let server = Server::new(Router::new()).with_lifecycle(lifecycle.clone());
    assert!(server.lifecycle.is_some());
    assert_eq!(
        lifecycle.phase(),
        crate::lifecycle::ApplicationPhase::Starting
    );
}

#[tokio::test]
async fn lifecycle_helpers_are_monotonic_and_startup_failure_stops() {
    let lifecycle = crate::lifecycle::ApplicationLifecycle::new();
    mark_lifecycle_ready(Some(&lifecycle)).expect("ready transition");
    assert_eq!(lifecycle.phase(), crate::lifecycle::ApplicationPhase::Ready);

    lifecycle.begin_draining().expect("drain transition");
    assert!(matches!(
        mark_lifecycle_ready(Some(&lifecycle)),
        Err(ServerError::Lifecycle(_))
    ));
    mark_lifecycle_stopped(Some(&lifecycle));
    assert_eq!(
        lifecycle.phase(),
        crate::lifecycle::ApplicationPhase::Stopped
    );

    let failed = crate::lifecycle::ApplicationLifecycle::new();
    failed.begin_draining().expect("pre-start drain");
    let result = Server::new(Router::new())
        .with_lifecycle(failed.clone())
        .run(0)
        .await;
    assert!(matches!(result, Err(ServerError::Lifecycle(_))));
    assert_eq!(failed.phase(), crate::lifecycle::ApplicationPhase::Stopped);
}

#[tokio::test]
async fn custom_shutdown_drives_ready_drain_and_stopped_phases() {
    let _lock = crate::server::TEST_ENV_LOCK.lock().await;
    let environment = EnvironmentGuard::clear(&[
        "HOST",
        "RULLST_HOST",
        "PORT",
        "RULLST_ENV",
        "APP_ENV",
        "DATABASE_URL",
    ]);
    environment.set("RULLST_ENV", "test");

    let lifecycle = crate::lifecycle::ApplicationLifecycle::new();
    let observed = lifecycle.clone();
    let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
    let server = tokio::spawn(async move {
        Server::new(Router::new())
            .with_lifecycle(lifecycle)
            .run_with_shutdown(0, async move {
                let _ = shutdown_rx.await;
            })
            .await
    });

    tokio::time::timeout(std::time::Duration::from_secs(2), async {
        while observed.phase() != crate::lifecycle::ApplicationPhase::Ready {
            tokio::task::yield_now().await;
        }
    })
    .await
    .expect("server became ready");
    shutdown_tx.send(()).expect("shutdown receiver alive");
    tokio::time::timeout(std::time::Duration::from_secs(2), server)
        .await
        .expect("server stopped before deadline")
        .expect("server task joined")
        .expect("server shutdown succeeded");
    assert_eq!(
        observed.phase(),
        crate::lifecycle::ApplicationPhase::Stopped
    );
}

#[tokio::test]
async fn hot_reload_token_configuration_fails_closed() {
    let _lock = crate::server::TEST_ENV_LOCK.lock().await;
    let environment = EnvironmentGuard::clear(&["RULLST_HMR_TOKEN"]);
    assert!(matches!(
        resolve_hot_reload_token(),
        Err(ServerError::HotReloadConfiguration(message)) if message.contains("missing")
    ));

    environment.set("RULLST_HMR_TOKEN", "too-short");
    assert!(matches!(
        resolve_hot_reload_token(),
        Err(ServerError::HotReloadConfiguration(message)) if message.contains("64 hexadecimal")
    ));

    environment.set(
        "RULLST_HMR_TOKEN",
        "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
    );
    assert_eq!(
        resolve_hot_reload_token().unwrap().as_ref(),
        "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
    );
}

#[cfg(not(feature = "orm"))]
#[tokio::test]
async fn database_configuration_fails_closed_without_the_orm_feature() {
    let config = RullstConfig::new();
    let mut server = Server::new(Router::new());
    server
        .init_database(&config, &HashMap::new())
        .await
        .unwrap();

    let mut requested = Server::new(Router::new()).with_db("sqlite::memory:");
    assert!(matches!(
        requested.init_database(&config, &HashMap::new()).await,
        Err(ServerError::Database(_))
    ));
}