Skip to main content

git_claw/core/
port.rs

1//! Deterministic port calculation and environment variable key formatting.
2
3use crate::core::error::PortError;
4use std::collections::BTreeMap;
5
6/// Converts a port key name to an uppercase environment variable name.
7///
8/// Keys already ending with `_PORT`, starting with `PORT_`, or equal to `PORT`
9/// are preserved as-is. Otherwise, `PORT_` is prepended.
10///
11/// # Example
12///
13/// ```
14/// use git_claw::core::port::format_port_env_key;
15///
16/// assert_eq!(format_port_env_key("APP_PORT"), "APP_PORT");
17/// assert_eq!(format_port_env_key("web"), "PORT_WEB");
18/// assert_eq!(format_port_env_key("PORT"), "PORT");
19/// ```
20pub fn format_port_env_key(key: &str) -> String {
21    let sanitized: String = key
22        .chars()
23        .map(|c| if c.is_alphanumeric() { c } else { '_' })
24        .collect();
25    let upper = sanitized.to_uppercase();
26    if upper == "PORT" || upper.starts_with("PORT_") || upper.ends_with("_PORT") {
27        upper
28    } else {
29        format!("PORT_{}", upper)
30    }
31}
32
33/// Calculates effective ports: `Effective Port = Base Port + Slot ID`.
34/// Returns a map of environment key -> effective port, or PortError on overflow.
35pub fn calculate_effective_ports(
36    base_ports: &BTreeMap<String, u16>,
37    slot_id: u32,
38) -> Result<BTreeMap<String, u16>, PortError> {
39    let mut result = BTreeMap::new();
40    for (key, &base) in base_ports {
41        let total = (base as u32)
42            .checked_add(slot_id)
43            .ok_or_else(|| PortError::PortOverflow {
44                key: key.clone(),
45                base_port: base,
46                slot_id,
47            })?;
48
49        if total > u16::MAX as u32 {
50            return Err(PortError::PortOverflow {
51                key: key.clone(),
52                base_port: base,
53                slot_id,
54            });
55        }
56
57        result.insert(format_port_env_key(key), total as u16);
58    }
59    Ok(result)
60}
61
62/// Returns true if a port key represents a client connection to a shared service.
63///
64/// Ports for shared services (e.g. `DATABASE_PORT` for a shared `postgres` container)
65/// must connect to the shared container on its standard port and not be shifted by slot ID.
66///
67/// # Example
68///
69/// ```
70/// use git_claw::core::port::is_shared_service_port;
71///
72/// let shared = vec!["postgres".to_string(), "redis".to_string()];
73/// assert!(is_shared_service_port("DATABASE_PORT", &shared));
74/// assert!(is_shared_service_port("REDIS_PORT", &shared));
75/// assert!(!is_shared_service_port("APP_PORT", &shared));
76/// ```
77pub fn is_shared_service_port(port_name: &str, shared_services: &[String]) -> bool {
78    let upper = port_name.to_ascii_uppercase();
79    for svc in shared_services {
80        let svc_upper = svc.to_ascii_uppercase();
81        if (svc_upper == "POSTGRES"
82            || svc_upper == "POSTGRESQL"
83            || svc_upper == "DB"
84            || svc_upper == "DATABASE")
85            && (upper.contains("DATABASE")
86                || upper.contains("POSTGRES")
87                || upper.contains("PGPORT")
88                || upper.contains("DB_")
89                || upper.starts_with("DB"))
90        {
91            return true;
92        }
93        if (svc_upper == "REDIS") && upper.contains("REDIS") {
94            return true;
95        }
96        if (svc_upper == "MYSQL" || svc_upper == "MARIADB")
97            && (upper.contains("MYSQL") || upper.contains("MARIADB"))
98        {
99            return true;
100        }
101        if (svc_upper == "MONGO" || svc_upper == "MONGODB") && upper.contains("MONGO") {
102            return true;
103        }
104        if (svc_upper == "RABBITMQ" || svc_upper == "AMQP")
105            && (upper.contains("RABBITMQ") || upper.contains("AMQP"))
106        {
107            return true;
108        }
109    }
110    false
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn test_format_port_env_key() {
119        assert_eq!(format_port_env_key("web"), "PORT_WEB");
120        assert_eq!(format_port_env_key("APP_PORT"), "APP_PORT");
121        assert_eq!(format_port_env_key("PORT"), "PORT");
122        assert_eq!(format_port_env_key("port_http"), "PORT_HTTP");
123        assert_eq!(format_port_env_key("frontend-app"), "PORT_FRONTEND_APP");
124        assert_eq!(format_port_env_key("db_primary"), "PORT_DB_PRIMARY");
125    }
126
127    #[test]
128    fn test_calculate_effective_ports_ok() {
129        let mut base_ports = BTreeMap::new();
130        base_ports.insert("web".to_string(), 3000);
131        base_ports.insert("api".to_string(), 8000);
132
133        let effective = calculate_effective_ports(&base_ports, 2).expect("calculates ports");
134        assert_eq!(effective.get("PORT_WEB"), Some(&3002));
135        assert_eq!(effective.get("PORT_API"), Some(&8002));
136    }
137
138    #[test]
139    fn test_calculate_effective_ports_overflow() {
140        let mut base_ports = BTreeMap::new();
141        base_ports.insert("overflow".to_string(), 65535);
142
143        let result = calculate_effective_ports(&base_ports, 1);
144        assert!(matches!(result, Err(PortError::PortOverflow { .. })));
145    }
146
147    #[test]
148    fn test_is_shared_service_port() {
149        let shared = vec!["postgres".to_string(), "redis".to_string()];
150        assert!(is_shared_service_port("DATABASE_PORT", &shared));
151        assert!(is_shared_service_port("TEST_DATABASE_PORT", &shared));
152        assert!(is_shared_service_port("DB_PORT", &shared));
153        assert!(is_shared_service_port("POSTGRES_PORT", &shared));
154        assert!(is_shared_service_port("PGPORT", &shared));
155        assert!(is_shared_service_port("REDIS_PORT", &shared));
156        assert!(!is_shared_service_port("APP_PORT", &shared));
157        assert!(!is_shared_service_port("FLOWER_PORT", &shared));
158        assert!(!is_shared_service_port("HTTP_PORT", &shared));
159    }
160}