Skip to main content

ralph_api/
config.rs

1use std::env;
2use std::path::PathBuf;
3use std::str::FromStr;
4
5use anyhow::{Context, Result};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum AuthMode {
9    TrustedLocal,
10    Token,
11}
12
13impl AuthMode {
14    pub fn as_contract_mode(self) -> &'static str {
15        match self {
16            Self::TrustedLocal => "trusted_local",
17            Self::Token => "token",
18        }
19    }
20}
21
22impl FromStr for AuthMode {
23    type Err = anyhow::Error;
24
25    fn from_str(value: &str) -> Result<Self> {
26        match value.trim().to_ascii_lowercase().as_str() {
27            "trusted_local" | "trusted-local" | "local" => Ok(Self::TrustedLocal),
28            "token" => Ok(Self::Token),
29            other => {
30                anyhow::bail!("invalid auth mode '{other}'. expected one of: trusted_local, token")
31            }
32        }
33    }
34}
35
36#[derive(Debug, Clone)]
37pub struct ApiConfig {
38    pub host: String,
39    pub port: u16,
40    pub served_by: String,
41    pub auth_mode: AuthMode,
42    pub token: Option<String>,
43    pub idempotency_ttl_secs: u64,
44    pub workspace_root: PathBuf,
45    pub loop_process_interval_ms: u64,
46    pub ralph_command: String,
47}
48
49impl Default for ApiConfig {
50    fn default() -> Self {
51        let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
52
53        Self {
54            host: "127.0.0.1".to_string(),
55            port: 3000,
56            served_by: "ralph-api".to_string(),
57            auth_mode: AuthMode::TrustedLocal,
58            token: None,
59            idempotency_ttl_secs: 60 * 60,
60            workspace_root,
61            loop_process_interval_ms: 30_000,
62            ralph_command: "ralph".to_string(),
63        }
64    }
65}
66
67impl ApiConfig {
68    pub fn from_env() -> Result<Self> {
69        let mut config = Self::default();
70
71        if let Ok(host) = env::var("RALPH_API_HOST") {
72            config.host = host;
73        }
74
75        if let Ok(port) = env::var("RALPH_API_PORT") {
76            config.port = port
77                .parse::<u16>()
78                .with_context(|| format!("failed parsing RALPH_API_PORT='{port}' as u16"))?;
79        }
80
81        if let Ok(served_by) = env::var("RALPH_API_SERVED_BY") {
82            config.served_by = served_by;
83        }
84
85        if let Ok(mode) = env::var("RALPH_API_AUTH_MODE") {
86            config.auth_mode = mode.parse::<AuthMode>()?;
87        }
88
89        if let Ok(token) = env::var("RALPH_API_TOKEN")
90            && !token.trim().is_empty()
91        {
92            config.token = Some(token);
93        }
94
95        if let Ok(ttl) = env::var("RALPH_API_IDEMPOTENCY_TTL_SECS") {
96            config.idempotency_ttl_secs = ttl.parse::<u64>().with_context(|| {
97                format!("failed parsing RALPH_API_IDEMPOTENCY_TTL_SECS='{ttl}' as u64")
98            })?;
99        }
100
101        if let Ok(workspace_root) = env::var("RALPH_API_WORKSPACE_ROOT") {
102            config.workspace_root = PathBuf::from(workspace_root);
103        }
104
105        if let Ok(interval_ms) = env::var("RALPH_API_LOOP_PROCESS_INTERVAL_MS") {
106            config.loop_process_interval_ms = interval_ms.parse::<u64>().with_context(|| {
107                format!("failed parsing RALPH_API_LOOP_PROCESS_INTERVAL_MS='{interval_ms}' as u64")
108            })?;
109        }
110
111        if let Ok(ralph_command) = env::var("RALPH_API_RALPH_COMMAND")
112            && !ralph_command.trim().is_empty()
113        {
114            config.ralph_command = ralph_command;
115        }
116
117        config.validate()?;
118        Ok(config)
119    }
120
121    pub fn validate(&self) -> Result<()> {
122        if self.auth_mode == AuthMode::Token
123            && self
124                .token
125                .as_deref()
126                .is_none_or(|token| token.trim().is_empty())
127        {
128            anyhow::bail!("RALPH_API_TOKEN must be configured when auth mode is token");
129        }
130
131        if self.auth_mode == AuthMode::TrustedLocal && !is_loopback_host(&self.host) {
132            anyhow::bail!(
133                "trusted_local auth mode requires loopback host; set RALPH_API_HOST to 127.0.0.1/::1 (or localhost) or switch to token auth"
134            );
135        }
136
137        Ok(())
138    }
139}
140
141fn is_loopback_host(host: &str) -> bool {
142    let normalized = host
143        .trim()
144        .trim_matches('[')
145        .trim_matches(']')
146        .to_ascii_lowercase();
147
148    matches!(normalized.as_str(), "127.0.0.1" | "localhost" | "::1")
149}
150
151#[cfg(test)]
152mod tests {
153    use super::{ApiConfig, AuthMode};
154
155    #[test]
156    fn defaults_are_localhost_and_trusted_local() {
157        let config = ApiConfig::default();
158        assert_eq!(config.host, "127.0.0.1");
159        assert_eq!(config.auth_mode, AuthMode::TrustedLocal);
160        assert!(config.validate().is_ok());
161    }
162
163    #[test]
164    fn trusted_local_allows_ipv6_loopback() {
165        let mut config = ApiConfig::default();
166        config.host = "::1".to_string();
167        assert!(
168            config.validate().is_ok(),
169            "RALPH_API_HOST=::1 must be accepted by trusted_local"
170        );
171    }
172
173    #[test]
174    fn trusted_local_allows_bracketed_ipv6_loopback() {
175        let mut config = ApiConfig::default();
176        config.host = "[::1]".to_string();
177        assert!(
178            config.validate().is_ok(),
179            "RALPH_API_HOST=[::1] must be accepted by trusted_local"
180        );
181    }
182
183    #[test]
184    fn trusted_local_rejects_non_loopback_hosts() {
185        let mut config = ApiConfig::default();
186        config.host = "0.0.0.0".to_string();
187        assert!(config.validate().is_err());
188    }
189
190    #[test]
191    fn token_auth_allows_non_loopback_hosts() {
192        let mut config = ApiConfig::default();
193        config.host = "0.0.0.0".to_string();
194        config.auth_mode = AuthMode::Token;
195        config.token = Some("secret-token".to_string());
196
197        assert!(config.validate().is_ok());
198    }
199}