1#[derive(Debug, Clone, Default)]
10pub struct CloudConfig {
11 pub enabled: bool,
13 pub api_url: Option<String>,
15 pub api_token: Option<String>,
17 pub run_id: Option<String>,
19 pub heartbeat_interval_secs: u32,
21 pub graceful_degradation: bool,
23 pub git_remote: GitRemoteConfig,
25}
26
27#[derive(Debug, Clone)]
31pub struct GitRemoteConfig {
32 pub auth_method: GitAuthMethod,
34 pub push_branch: Option<String>,
36 pub create_pr: bool,
38 pub pr_title_template: Option<String>,
40 pub pr_body_template: Option<String>,
42 pub pr_base_branch: Option<String>,
44 pub force_push: bool,
46 pub remote_name: String,
48}
49
50#[derive(Debug, Clone)]
51pub enum GitAuthMethod {
52 SshKey {
54 key_path: Option<String>,
56 },
57 Token {
59 token: String,
61 username: String,
63 },
64 CredentialHelper {
66 helper: String,
68 },
69}
70
71#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
79pub struct CloudStateConfig {
80 pub enabled: bool,
81 pub api_url: Option<String>,
82 pub run_id: Option<String>,
83 pub heartbeat_interval_secs: u32,
84 pub graceful_degradation: bool,
85 pub git_remote: GitRemoteStateConfig,
86}
87
88#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
89pub struct GitRemoteStateConfig {
90 pub auth_method: GitAuthStateMethod,
91 pub push_branch: String,
92 pub create_pr: bool,
93 pub pr_title_template: Option<String>,
94 pub pr_body_template: Option<String>,
95 pub pr_base_branch: Option<String>,
96 pub force_push: bool,
97 pub remote_name: String,
98}
99
100impl Default for GitRemoteStateConfig {
101 fn default() -> Self {
102 Self {
103 auth_method: GitAuthStateMethod::SshKey { key_path: None },
104 push_branch: String::new(),
105 create_pr: false,
106 pr_title_template: None,
107 pr_body_template: None,
108 pr_base_branch: None,
109 force_push: false,
110 remote_name: "origin".to_string(),
111 }
112 }
113}
114
115#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
116pub enum GitAuthStateMethod {
117 SshKey { key_path: Option<String> },
118 Token { username: String },
119 CredentialHelper { helper: String },
120}
121
122impl Default for GitAuthStateMethod {
123 fn default() -> Self {
124 Self::SshKey { key_path: None }
125 }
126}
127
128impl CloudStateConfig {
129 #[must_use]
130 pub fn disabled() -> Self {
131 Self {
132 enabled: false,
133 api_url: None,
134 run_id: None,
135 heartbeat_interval_secs: 30,
136 graceful_degradation: true,
137 git_remote: GitRemoteStateConfig::default(),
138 }
139 }
140}
141
142impl From<&CloudConfig> for CloudStateConfig {
143 fn from(cfg: &CloudConfig) -> Self {
144 let auth_method = match &cfg.git_remote.auth_method {
145 GitAuthMethod::SshKey { key_path } => GitAuthStateMethod::SshKey {
146 key_path: key_path.clone(),
147 },
148 GitAuthMethod::Token { username, .. } => GitAuthStateMethod::Token {
149 username: username.clone(),
150 },
151 GitAuthMethod::CredentialHelper { helper } => GitAuthStateMethod::CredentialHelper {
152 helper: helper.clone(),
153 },
154 };
155
156 Self {
157 enabled: cfg.enabled,
158 api_url: cfg.api_url.clone(),
159 run_id: cfg.run_id.clone(),
160 heartbeat_interval_secs: cfg.heartbeat_interval_secs,
161 graceful_degradation: cfg.graceful_degradation,
162 git_remote: GitRemoteStateConfig {
163 auth_method,
164 push_branch: cfg.git_remote.push_branch.clone().unwrap_or_default(),
165 create_pr: cfg.git_remote.create_pr,
166 pr_title_template: cfg.git_remote.pr_title_template.clone(),
167 pr_body_template: cfg.git_remote.pr_body_template.clone(),
168 pr_base_branch: cfg.git_remote.pr_base_branch.clone(),
169 force_push: cfg.git_remote.force_push,
170 remote_name: cfg.git_remote.remote_name.clone(),
171 },
172 }
173 }
174}
175
176impl Default for GitAuthMethod {
177 fn default() -> Self {
178 Self::SshKey { key_path: None }
179 }
180}
181
182impl Default for GitRemoteConfig {
183 fn default() -> Self {
184 Self {
185 auth_method: GitAuthMethod::default(),
186 push_branch: None,
187 create_pr: false,
188 pr_title_template: None,
189 pr_body_template: None,
190 pr_base_branch: None,
191 force_push: false,
192 remote_name: "origin".to_string(),
193 }
194 }
195}
196
197impl CloudConfig {
198 #[must_use]
201 pub fn from_env_fn(get: impl Fn(&str) -> Option<String>) -> Self {
202 let enabled =
203 get("RALPH_CLOUD_MODE").is_some_and(|v| v.eq_ignore_ascii_case("true") || v == "1");
204
205 if !enabled {
206 return Self::disabled();
207 }
208
209 Self {
210 enabled: true,
211 api_url: get("RALPH_CLOUD_API_URL"),
212 api_token: get("RALPH_CLOUD_API_TOKEN"),
213 run_id: get("RALPH_CLOUD_RUN_ID"),
214 heartbeat_interval_secs: get("RALPH_CLOUD_HEARTBEAT_INTERVAL")
215 .and_then(|v| v.parse().ok())
216 .unwrap_or(30),
217 graceful_degradation: get("RALPH_CLOUD_GRACEFUL_DEGRADATION")
218 .is_none_or(|v| !v.eq_ignore_ascii_case("false") && v != "0"),
219 git_remote: GitRemoteConfig::from_env_fn(|k| get(k)),
220 }
221 }
222
223 #[must_use]
226 pub fn from_env() -> Self {
227 Self::from_env_fn(|k| std::env::var(k).ok())
228 }
229
230 #[must_use]
231 pub fn disabled() -> Self {
232 Self {
233 enabled: false,
234 api_url: None,
235 api_token: None,
236 run_id: None,
237 heartbeat_interval_secs: 30,
238 graceful_degradation: true,
239 git_remote: GitRemoteConfig::default(),
240 }
241 }
242
243 pub fn validate(&self) -> Result<(), String> {
249 if !self.enabled {
250 return Ok(());
251 }
252
253 let Some(api_url) = self.api_url.as_deref() else {
254 return Err("RALPH_CLOUD_API_URL must be set when cloud mode is enabled".to_string());
255 };
256 if !api_url
257 .trim_start()
258 .to_ascii_lowercase()
259 .starts_with("https://")
260 {
261 return Err(
262 "RALPH_CLOUD_API_URL must use https:// when cloud mode is enabled".to_string(),
263 );
264 }
265
266 if self.api_token.as_deref().unwrap_or_default().is_empty() {
267 return Err("RALPH_CLOUD_API_TOKEN must be set when cloud mode is enabled".to_string());
268 }
269
270 if self.run_id.as_deref().unwrap_or_default().is_empty() {
271 return Err("RALPH_CLOUD_RUN_ID must be set when cloud mode is enabled".to_string());
272 }
273
274 self.git_remote.validate()?;
276
277 Ok(())
278 }
279}
280
281impl GitRemoteConfig {
282 pub fn validate(&self) -> Result<(), String> {
289 if self.remote_name.trim().is_empty() {
290 return Err("RALPH_GIT_REMOTE must not be empty".to_string());
291 }
292
293 if let Some(branch) = self.push_branch.as_deref() {
294 let trimmed = branch.trim();
295 if trimmed.is_empty() {
296 return Err("RALPH_GIT_PUSH_BRANCH must not be empty when set".to_string());
297 }
298 if trimmed == "HEAD" {
299 return Err(
300 "RALPH_GIT_PUSH_BRANCH must be a branch name (not literal 'HEAD')".to_string(),
301 );
302 }
303 }
304
305 match &self.auth_method {
306 GitAuthMethod::SshKey { key_path } => {
307 if let Some(path) = key_path.as_deref() {
308 if path.trim().is_empty() {
309 return Err("RALPH_GIT_SSH_KEY_PATH must not be empty when set".to_string());
310 }
311 }
312 }
313 GitAuthMethod::Token { token, username } => {
314 if token.trim().is_empty() {
315 return Err(
316 "RALPH_GIT_TOKEN must be set when RALPH_GIT_AUTH_METHOD=token".to_string(),
317 );
318 }
319 if username.trim().is_empty() {
320 return Err(
321 "RALPH_GIT_TOKEN_USERNAME must not be empty when RALPH_GIT_AUTH_METHOD=token"
322 .to_string(),
323 );
324 }
325 }
326 GitAuthMethod::CredentialHelper { helper } => {
327 if helper.trim().is_empty() {
328 return Err(
329 "RALPH_GIT_CREDENTIAL_HELPER must be set when RALPH_GIT_AUTH_METHOD=credential-helper"
330 .to_string(),
331 );
332 }
333 }
334 }
335
336 Ok(())
337 }
338
339 #[must_use]
342 pub fn from_env_fn(get: impl Fn(&str) -> Option<String>) -> Self {
343 let auth_method = match get("RALPH_GIT_AUTH_METHOD")
344 .unwrap_or_else(|| "ssh".to_string())
345 .to_lowercase()
346 .as_str()
347 {
348 "token" => {
349 let token = get("RALPH_GIT_TOKEN").unwrap_or_default();
350 let username =
351 get("RALPH_GIT_TOKEN_USERNAME").unwrap_or_else(|| "x-access-token".to_string());
352 GitAuthMethod::Token { token, username }
353 }
354 "credential-helper" => {
355 let helper =
356 get("RALPH_GIT_CREDENTIAL_HELPER").unwrap_or_else(|| "gcloud".to_string());
357 GitAuthMethod::CredentialHelper { helper }
358 }
359 _ => {
360 let key_path = get("RALPH_GIT_SSH_KEY_PATH");
361 GitAuthMethod::SshKey { key_path }
362 }
363 };
364
365 Self {
366 auth_method,
367 push_branch: get("RALPH_GIT_PUSH_BRANCH"),
368 create_pr: get("RALPH_GIT_CREATE_PR")
369 .is_some_and(|v| v.eq_ignore_ascii_case("true") || v == "1"),
370 pr_title_template: get("RALPH_GIT_PR_TITLE"),
371 pr_body_template: get("RALPH_GIT_PR_BODY"),
372 pr_base_branch: get("RALPH_GIT_PR_BASE_BRANCH"),
373 force_push: get("RALPH_GIT_FORCE_PUSH")
374 .is_some_and(|v| v.eq_ignore_ascii_case("true") || v == "1"),
375 remote_name: get("RALPH_GIT_REMOTE").unwrap_or_else(|| "origin".to_string()),
376 }
377 }
378
379 #[must_use]
381 pub fn from_env() -> Self {
382 Self::from_env_fn(|k| std::env::var(k).ok())
383 }
384}
385
386#[cfg(test)]
387mod cloud_tests {
388 use super::*;
389
390 #[test]
391 fn test_cloud_disabled_by_default() {
392 let config = CloudConfig::from_env_fn(|_| None);
393 assert!(!config.enabled);
394 }
395
396 #[test]
397 fn test_cloud_enabled_with_env_var() {
398 let env = [
399 ("RALPH_CLOUD_MODE", "true"),
400 ("RALPH_CLOUD_API_URL", "https://api.example.com"),
401 ("RALPH_CLOUD_API_TOKEN", "secret"),
402 ("RALPH_CLOUD_RUN_ID", "run123"),
403 ];
404 let config = CloudConfig::from_env_fn(|k| {
405 env.iter()
406 .find(|(key, _)| *key == k)
407 .map(|(_, v)| (*v).to_string())
408 });
409 assert!(config.enabled);
410 assert_eq!(config.api_url, Some("https://api.example.com".to_string()));
411 assert_eq!(config.run_id, Some("run123".to_string()));
412 }
413
414 #[test]
415 fn test_cloud_validation_requires_fields() {
416 let config = CloudConfig {
417 enabled: true,
418 api_url: None,
419 api_token: None,
420 run_id: None,
421 heartbeat_interval_secs: 30,
422 graceful_degradation: true,
423 git_remote: GitRemoteConfig::default(),
424 };
425
426 assert!(config.validate().is_err());
427 }
428
429 #[test]
430 fn test_git_auth_method_from_env() {
431 let env = [
432 ("RALPH_GIT_AUTH_METHOD", "token"),
433 ("RALPH_GIT_TOKEN", "ghp_test"),
434 ];
435 let config = GitRemoteConfig::from_env_fn(|k| {
436 env.iter()
437 .find(|(key, _)| *key == k)
438 .map(|(_, v)| (*v).to_string())
439 });
440 match config.auth_method {
441 GitAuthMethod::Token { token, .. } => {
442 assert_eq!(token, "ghp_test");
443 }
444 _ => panic!("Expected Token auth method"),
445 }
446 }
447
448 #[test]
449 fn test_cloud_disabled_validation_passes() {
450 let config = CloudConfig::disabled();
451 assert!(
452 config.validate().is_ok(),
453 "Disabled cloud config should always validate"
454 );
455 }
456
457 #[test]
458 fn test_cloud_validation_rejects_non_https_api_url() {
459 let config = CloudConfig {
460 enabled: true,
461 api_url: Some("http://api.example.com".to_string()),
462 api_token: Some("secret".to_string()),
463 run_id: Some("run123".to_string()),
464 heartbeat_interval_secs: 30,
465 graceful_degradation: true,
466 git_remote: GitRemoteConfig::default(),
467 };
468 assert!(
469 config.validate().is_err(),
470 "Cloud API URL must be https:// when cloud mode is enabled"
471 );
472 }
473
474 #[test]
475 fn test_cloud_validation_requires_git_token_for_token_auth() {
476 let config = CloudConfig {
477 enabled: true,
478 api_url: Some("https://api.example.com".to_string()),
479 api_token: Some("secret".to_string()),
480 run_id: Some("run123".to_string()),
481 heartbeat_interval_secs: 30,
482 graceful_degradation: true,
483 git_remote: GitRemoteConfig {
484 auth_method: GitAuthMethod::Token {
485 token: String::new(),
486 username: "x-access-token".to_string(),
487 },
488 ..GitRemoteConfig::default()
489 },
490 };
491 assert!(
492 config.validate().is_err(),
493 "Token auth requires a non-empty RALPH_GIT_TOKEN"
494 );
495 }
496}