railwayapp 4.22.0

Interact with Railway via CLI
use std::collections::{BTreeMap, HashMap};

use anyhow::Result;

use crate::{
    config::Configs,
    controllers::{
        config::{ServiceInstance, fetch_environment_config},
        develop::{
            DEFAULT_PORT, HttpsDomainConfig, LocalDevConfig, LocalDevelopContext, NetworkMode,
            ServiceDomainConfig, build_service_endpoints, generate_port, get_https_domain,
            get_https_mode, override_railway_vars,
        },
    },
    gql::queries::project::ProjectProject,
};

pub use crate::controllers::develop::ports::is_local_develop_active;

/// Build context from environment config (fetches from API, auto-loads LocalDevConfig)
pub async fn build_local_override_context(
    client: &reqwest::Client,
    configs: &Configs,
    project: &ProjectProject,
    environment_id: &str,
) -> Result<LocalDevelopContext> {
    let env_response = fetch_environment_config(client, configs, environment_id, false).await?;
    let config = env_response.config;

    let local_dev_config = LocalDevConfig::load(&project.id).ok();

    let service_names: HashMap<String, String> = project
        .services
        .edges
        .iter()
        .map(|e| (e.node.id.clone(), e.node.name.clone()))
        .collect();

    let service_slugs = build_service_endpoints(&service_names, &config);

    let mut ctx = LocalDevelopContext::new(NetworkMode::Host);
    ctx.https_config = get_https_domain(environment_id).map(|domain| HttpsDomainConfig {
        base_domain: domain,
        use_port_443: get_https_mode(environment_id),
    });

    for (service_id, svc) in config.services.iter() {
        if svc.is_image_based() {
            let slug = service_slugs.get(service_id).cloned().unwrap_or_default();
            let port_mapping = build_port_mapping(service_id, svc);
            ctx.services.insert(
                service_id.clone(),
                ServiceDomainConfig {
                    slug,
                    port_mapping,
                    public_domain_prod: None,
                    https_proxy_port: None,
                },
            );
        }
    }

    if let Some(dev_config) = &local_dev_config {
        for (service_id, svc) in config.services.iter() {
            if svc.is_code_based() {
                if let Some(code_config) = dev_config.services.get(service_id) {
                    let slug = service_slugs.get(service_id).cloned().unwrap_or_default();
                    let port = code_config
                        .port
                        .map(|p| p as i64)
                        .or_else(|| svc.get_ports().first().copied())
                        .unwrap_or(DEFAULT_PORT as i64);
                    let external_port = code_config
                        .port
                        .unwrap_or_else(|| generate_port(service_id, port));

                    let mut port_mapping = HashMap::new();
                    for internal in svc.get_ports() {
                        port_mapping.insert(internal, external_port);
                    }
                    port_mapping.insert(port, external_port);

                    ctx.services.insert(
                        service_id.clone(),
                        ServiceDomainConfig {
                            slug,
                            port_mapping,
                            public_domain_prod: None,
                            https_proxy_port: Some(generate_port(service_id, port)),
                        },
                    );
                }
            }
        }
    }

    Ok(ctx)
}

fn build_port_mapping(service_id: &str, svc: &ServiceInstance) -> HashMap<i64, u16> {
    let mut mapping = HashMap::new();
    if let Some(networking) = &svc.networking {
        for config in networking.service_domains.values().flatten() {
            if let Some(port) = config.port {
                mapping
                    .entry(port)
                    .or_insert_with(|| generate_port(service_id, port));
            }
        }
        for port_str in networking.tcp_proxies.keys() {
            if let Ok(port) = port_str.parse::<i64>() {
                mapping
                    .entry(port)
                    .or_insert_with(|| generate_port(service_id, port));
            }
        }
    }
    mapping
}

/// Apply local overrides to variables for the run command (host network mode)
pub fn apply_local_overrides(
    vars: BTreeMap<String, String>,
    service_id: &str,
    ctx: &LocalDevelopContext,
) -> BTreeMap<String, String> {
    let service = ctx.for_service(service_id);
    override_railway_vars(vars, service.as_ref(), ctx)
}

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

    fn make_context(
        https_domain: Option<&str>,
        use_port_443: bool,
        services: HashMap<String, ServiceDomainConfig>,
    ) -> LocalDevelopContext {
        let mut ctx = LocalDevelopContext::new(NetworkMode::Host);
        ctx.https_config = https_domain.map(|domain| HttpsDomainConfig {
            base_domain: domain.to_string(),
            use_port_443,
        });
        ctx.services = services;
        ctx
    }

    #[test]
    fn test_apply_local_overrides_public_domain_with_port_mapping() {
        let mut vars = BTreeMap::new();
        vars.insert(
            "RAILWAY_PUBLIC_DOMAIN".to_string(),
            "old.railway.app".to_string(),
        );

        let mut services = HashMap::new();
        let mut port_mapping = HashMap::new();
        port_mapping.insert(3000, 12345u16);
        services.insert(
            "svc-1".to_string(),
            ServiceDomainConfig {
                slug: "api".to_string(),
                port_mapping,
                public_domain_prod: None,
                https_proxy_port: None,
            },
        );

        let ctx = make_context(Some("myproject.localhost"), true, services);

        let result = apply_local_overrides(vars, "svc-1", &ctx);
        assert_eq!(
            result.get("RAILWAY_PUBLIC_DOMAIN"),
            Some(&"api.myproject.localhost".to_string())
        );
    }

    #[test]
    fn test_apply_local_overrides_public_domain_without_port_mapping() {
        let mut vars = BTreeMap::new();
        vars.insert(
            "RAILWAY_PUBLIC_DOMAIN".to_string(),
            "old.railway.app".to_string(),
        );

        let mut services = HashMap::new();
        services.insert(
            "svc-1".to_string(),
            ServiceDomainConfig {
                slug: "api".to_string(),
                port_mapping: HashMap::new(),
                public_domain_prod: None,
                https_proxy_port: None,
            },
        );

        let ctx = make_context(Some("myproject.localhost"), true, services);

        let result = apply_local_overrides(vars, "svc-1", &ctx);
        assert_eq!(
            result.get("RAILWAY_PUBLIC_DOMAIN"),
            Some(&"api.myproject.localhost".to_string())
        );
    }

    #[test]
    fn test_apply_local_overrides_public_domain_port_mode() {
        let mut vars = BTreeMap::new();
        vars.insert(
            "RAILWAY_PUBLIC_DOMAIN".to_string(),
            "old.railway.app".to_string(),
        );

        let mut services = HashMap::new();
        let mut port_mapping = HashMap::new();
        port_mapping.insert(3000, 12345u16);
        services.insert(
            "svc-1".to_string(),
            ServiceDomainConfig {
                slug: "api".to_string(),
                port_mapping,
                public_domain_prod: None,
                https_proxy_port: Some(54321),
            },
        );

        let ctx = make_context(Some("myproject.localhost"), false, services);

        let result = apply_local_overrides(vars, "svc-1", &ctx);
        assert_eq!(
            result.get("RAILWAY_PUBLIC_DOMAIN"),
            Some(&"myproject.localhost:54321".to_string())
        );
    }

    #[test]
    fn test_apply_local_overrides_no_https_domain() {
        let mut vars = BTreeMap::new();
        vars.insert(
            "RAILWAY_PUBLIC_DOMAIN".to_string(),
            "old.railway.app".to_string(),
        );

        let mut services = HashMap::new();
        services.insert(
            "svc-1".to_string(),
            ServiceDomainConfig {
                slug: "api".to_string(),
                port_mapping: HashMap::new(),
                public_domain_prod: None,
                https_proxy_port: None,
            },
        );

        let ctx = make_context(None, false, services);

        let result = apply_local_overrides(vars, "svc-1", &ctx);
        assert_eq!(
            result.get("RAILWAY_PUBLIC_DOMAIN"),
            Some(&"localhost".to_string())
        );
    }

    #[test]
    fn test_apply_local_overrides_private_domain() {
        let mut vars = BTreeMap::new();
        vars.insert(
            "RAILWAY_PRIVATE_DOMAIN".to_string(),
            "old.railway.internal".to_string(),
        );

        let mut services = HashMap::new();
        services.insert(
            "svc-1".to_string(),
            ServiceDomainConfig {
                slug: "api".to_string(),
                port_mapping: HashMap::new(),
                public_domain_prod: None,
                https_proxy_port: None,
            },
        );

        let ctx = make_context(None, false, services);

        let result = apply_local_overrides(vars, "svc-1", &ctx);
        assert_eq!(
            result.get("RAILWAY_PRIVATE_DOMAIN"),
            Some(&"localhost".to_string())
        );
    }
}