1use crate::common::domain_types::{
7 HttpsUrl, NonEmptyString, PushBranch, PushBranchParseError, RemoteName,
8};
9
10#[must_use]
11pub fn load_cloud_config_from_env() -> CloudConfig {
12 super::boundary::load_cloud_config_from_env()
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum CloudConfigValidationError {
21 ApiUrlMissing,
23 ApiUrlNotHttps,
25 ApiTokenMissing,
27 RunIdMissing,
29 GitRemote(GitRemoteValidationError),
31}
32
33impl std::fmt::Display for CloudConfigValidationError {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 match self {
36 Self::ApiUrlMissing => {
37 write!(
38 f,
39 "RALPH_CLOUD_API_URL must be set when cloud mode is enabled"
40 )
41 }
42 Self::ApiUrlNotHttps => write!(
43 f,
44 "RALPH_CLOUD_API_URL must use https:// when cloud mode is enabled"
45 ),
46 Self::ApiTokenMissing => write!(
47 f,
48 "RALPH_CLOUD_API_TOKEN must be set when cloud mode is enabled"
49 ),
50 Self::RunIdMissing => {
51 write!(
52 f,
53 "RALPH_CLOUD_RUN_ID must be set when cloud mode is enabled"
54 )
55 }
56 Self::GitRemote(e) => write!(f, "{e}"),
57 }
58 }
59}
60
61impl From<GitRemoteValidationError> for CloudConfigValidationError {
62 fn from(e: GitRemoteValidationError) -> Self {
63 Self::GitRemote(e)
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum GitRemoteValidationError {
72 EmptyRemoteName,
74 EmptyPushBranch,
76 PushBranchIsHead,
78 EmptySshKeyPath,
80 EmptyToken,
82 EmptyTokenUsername,
84 EmptyCredentialHelper,
86}
87
88impl std::fmt::Display for GitRemoteValidationError {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 Self::EmptyRemoteName => write!(f, "RALPH_GIT_REMOTE must not be empty"),
92 Self::EmptyPushBranch => {
93 write!(f, "RALPH_GIT_PUSH_BRANCH must not be empty when set")
94 }
95 Self::PushBranchIsHead => write!(
96 f,
97 "RALPH_GIT_PUSH_BRANCH must be a branch name (not literal 'HEAD')"
98 ),
99 Self::EmptySshKeyPath => {
100 write!(f, "RALPH_GIT_SSH_KEY_PATH must not be empty when set")
101 }
102 Self::EmptyToken => write!(
103 f,
104 "RALPH_GIT_TOKEN must be set when RALPH_GIT_AUTH_METHOD=token"
105 ),
106 Self::EmptyTokenUsername => write!(
107 f,
108 "RALPH_GIT_TOKEN_USERNAME must not be empty when RALPH_GIT_AUTH_METHOD=token"
109 ),
110 Self::EmptyCredentialHelper => write!(
111 f,
112 "RALPH_GIT_CREDENTIAL_HELPER must be set when RALPH_GIT_AUTH_METHOD=credential-helper"
113 ),
114 }
115 }
116}
117
118#[derive(Debug, Clone, Default, PartialEq)]
122pub struct CloudConfig {
123 pub enabled: bool,
125 pub api_url: Option<String>,
127 pub api_token: Option<String>,
129 pub run_id: Option<String>,
131 pub heartbeat_interval_secs: u32,
133 pub graceful_degradation: bool,
135 pub git_remote: GitRemoteConfig,
137}
138
139#[derive(Debug, Clone, PartialEq)]
143pub struct GitRemoteConfig {
144 pub auth_method: GitAuthMethod,
146 pub push_branch: Option<String>,
148 pub create_pr: bool,
150 pub pr_title_template: Option<String>,
152 pub pr_body_template: Option<String>,
154 pub pr_base_branch: Option<String>,
156 pub force_push: bool,
158 pub remote_name: String,
160}
161
162#[derive(Debug, Clone, PartialEq)]
163pub enum GitAuthMethod {
164 SshKey {
166 key_path: Option<String>,
168 },
169 Token {
171 token: String,
173 username: String,
175 },
176 CredentialHelper {
178 helper: String,
180 },
181}
182
183#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
191pub struct CloudStateConfig {
192 pub enabled: bool,
193 pub api_url: Option<String>,
194 pub run_id: Option<String>,
195 pub heartbeat_interval_secs: u32,
196 pub graceful_degradation: bool,
197 pub git_remote: GitRemoteStateConfig,
198}
199
200#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
201pub struct GitRemoteStateConfig {
202 pub auth_method: GitAuthStateMethod,
203 pub push_branch: String,
204 pub create_pr: bool,
205 pub pr_title_template: Option<String>,
206 pub pr_body_template: Option<String>,
207 pub pr_base_branch: Option<String>,
208 pub force_push: bool,
209 pub remote_name: String,
210}
211
212impl Default for GitRemoteStateConfig {
213 fn default() -> Self {
214 Self {
215 auth_method: GitAuthStateMethod::SshKey { key_path: None },
216 push_branch: String::new(),
217 create_pr: false,
218 pr_title_template: None,
219 pr_body_template: None,
220 pr_base_branch: None,
221 force_push: false,
222 remote_name: "origin".to_string(),
223 }
224 }
225}
226
227#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
228pub enum GitAuthStateMethod {
229 SshKey { key_path: Option<String> },
230 Token { username: String },
231 CredentialHelper { helper: String },
232}
233
234impl Default for GitAuthStateMethod {
235 fn default() -> Self {
236 Self::SshKey { key_path: None }
237 }
238}
239
240impl CloudStateConfig {
241 #[must_use]
242 pub fn disabled() -> Self {
243 Self {
244 enabled: false,
245 api_url: None,
246 run_id: None,
247 heartbeat_interval_secs: 30,
248 graceful_degradation: true,
249 git_remote: GitRemoteStateConfig::default(),
250 }
251 }
252}
253
254impl From<&CloudConfig> for CloudStateConfig {
255 fn from(cfg: &CloudConfig) -> Self {
256 let auth_method = match &cfg.git_remote.auth_method {
257 GitAuthMethod::SshKey { key_path } => GitAuthStateMethod::SshKey {
258 key_path: key_path.clone(),
259 },
260 GitAuthMethod::Token { username, .. } => GitAuthStateMethod::Token {
261 username: username.clone(),
262 },
263 GitAuthMethod::CredentialHelper { helper } => GitAuthStateMethod::CredentialHelper {
264 helper: helper.clone(),
265 },
266 };
267
268 Self {
269 enabled: cfg.enabled,
270 api_url: cfg.api_url.clone(),
271 run_id: cfg.run_id.clone(),
272 heartbeat_interval_secs: cfg.heartbeat_interval_secs,
273 graceful_degradation: cfg.graceful_degradation,
274 git_remote: GitRemoteStateConfig {
275 auth_method,
276 push_branch: cfg.git_remote.push_branch.clone().unwrap_or_default(),
277 create_pr: cfg.git_remote.create_pr,
278 pr_title_template: cfg.git_remote.pr_title_template.clone(),
279 pr_body_template: cfg.git_remote.pr_body_template.clone(),
280 pr_base_branch: cfg.git_remote.pr_base_branch.clone(),
281 force_push: cfg.git_remote.force_push,
282 remote_name: cfg.git_remote.remote_name.clone(),
283 },
284 }
285 }
286}
287
288impl Default for GitAuthMethod {
289 fn default() -> Self {
290 Self::SshKey { key_path: None }
291 }
292}
293
294impl Default for GitRemoteConfig {
295 fn default() -> Self {
296 Self {
297 auth_method: GitAuthMethod::default(),
298 push_branch: None,
299 create_pr: false,
300 pr_title_template: None,
301 pr_body_template: None,
302 pr_base_branch: None,
303 force_push: false,
304 remote_name: "origin".to_string(),
305 }
306 }
307}
308
309impl CloudConfig {
310 #[must_use]
313 pub fn from_env_fn(get: impl Fn(&str) -> Option<String>) -> Self {
314 let enabled =
315 get("RALPH_CLOUD_MODE").is_some_and(|v| v.eq_ignore_ascii_case("true") || v == "1");
316
317 if !enabled {
318 return Self::disabled();
319 }
320
321 Self {
322 enabled: true,
323 api_url: get("RALPH_CLOUD_API_URL"),
324 api_token: get("RALPH_CLOUD_API_TOKEN"),
325 run_id: get("RALPH_CLOUD_RUN_ID"),
326 heartbeat_interval_secs: get("RALPH_CLOUD_HEARTBEAT_INTERVAL")
327 .and_then(|v| v.parse().ok())
328 .unwrap_or(30),
329 graceful_degradation: get("RALPH_CLOUD_GRACEFUL_DEGRADATION")
330 .is_none_or(|v| !v.eq_ignore_ascii_case("false") && v != "0"),
331 git_remote: GitRemoteConfig::from_env_fn(|k| get(k)),
332 }
333 }
334
335 #[must_use]
336 pub fn disabled() -> Self {
337 Self {
338 enabled: false,
339 api_url: None,
340 api_token: None,
341 run_id: None,
342 heartbeat_interval_secs: 30,
343 graceful_degradation: true,
344 git_remote: GitRemoteConfig::default(),
345 }
346 }
347
348 pub fn validate(&self) -> Result<(), CloudConfigValidationError> {
357 if !self.enabled {
358 return Ok(());
359 }
360
361 let Some(api_url) = self.api_url.as_deref() else {
362 return Err(CloudConfigValidationError::ApiUrlMissing);
363 };
364 HttpsUrl::try_from_str(api_url).map_err(|_| CloudConfigValidationError::ApiUrlNotHttps)?;
365
366 NonEmptyString::try_from_str(self.api_token.as_deref().unwrap_or_default())
367 .map_err(|_| CloudConfigValidationError::ApiTokenMissing)?;
368
369 NonEmptyString::try_from_str(self.run_id.as_deref().unwrap_or_default())
370 .map_err(|_| CloudConfigValidationError::RunIdMissing)?;
371
372 self.git_remote
374 .validate()
375 .map_err(CloudConfigValidationError::GitRemote)?;
376
377 Ok(())
378 }
379}
380
381impl GitRemoteConfig {
382 pub fn validate(&self) -> Result<(), GitRemoteValidationError> {
395 RemoteName::try_from_str(&self.remote_name)
396 .map_err(|_| GitRemoteValidationError::EmptyRemoteName)?;
397
398 if let Some(branch) = self.push_branch.as_deref() {
399 PushBranch::try_from_str(branch).map_err(|err| match err {
400 PushBranchParseError::Empty => GitRemoteValidationError::EmptyPushBranch,
401 PushBranchParseError::IsHead => GitRemoteValidationError::PushBranchIsHead,
402 })?;
403 }
404
405 match &self.auth_method {
406 GitAuthMethod::SshKey { key_path } => {
407 if let Some(path) = key_path.as_deref() {
408 NonEmptyString::try_from_str(path)
409 .map_err(|_| GitRemoteValidationError::EmptySshKeyPath)?;
410 }
411 }
412 GitAuthMethod::Token { token, username } => {
413 NonEmptyString::try_from_str(token)
414 .map_err(|_| GitRemoteValidationError::EmptyToken)?;
415 NonEmptyString::try_from_str(username)
416 .map_err(|_| GitRemoteValidationError::EmptyTokenUsername)?;
417 }
418 GitAuthMethod::CredentialHelper { helper } => {
419 NonEmptyString::try_from_str(helper)
420 .map_err(|_| GitRemoteValidationError::EmptyCredentialHelper)?;
421 }
422 }
423
424 Ok(())
425 }
426
427 #[must_use]
430 pub fn from_env_fn(get: impl Fn(&str) -> Option<String>) -> Self {
431 let auth_method = match get("RALPH_GIT_AUTH_METHOD")
432 .unwrap_or_else(|| "ssh".to_string())
433 .to_lowercase()
434 .as_str()
435 {
436 "token" => {
437 let token = get("RALPH_GIT_TOKEN").unwrap_or_default();
438 let username =
439 get("RALPH_GIT_TOKEN_USERNAME").unwrap_or_else(|| "x-access-token".to_string());
440 GitAuthMethod::Token { token, username }
441 }
442 "credential-helper" => {
443 let helper =
444 get("RALPH_GIT_CREDENTIAL_HELPER").unwrap_or_else(|| "gcloud".to_string());
445 GitAuthMethod::CredentialHelper { helper }
446 }
447 _ => {
448 let key_path = get("RALPH_GIT_SSH_KEY_PATH");
449 GitAuthMethod::SshKey { key_path }
450 }
451 };
452
453 Self {
454 auth_method,
455 push_branch: get("RALPH_GIT_PUSH_BRANCH"),
456 create_pr: get("RALPH_GIT_CREATE_PR")
457 .is_some_and(|v| v.eq_ignore_ascii_case("true") || v == "1"),
458 pr_title_template: get("RALPH_GIT_PR_TITLE"),
459 pr_body_template: get("RALPH_GIT_PR_BODY"),
460 pr_base_branch: get("RALPH_GIT_PR_BASE_BRANCH"),
461 force_push: get("RALPH_GIT_FORCE_PUSH")
462 .is_some_and(|v| v.eq_ignore_ascii_case("true") || v == "1"),
463 remote_name: get("RALPH_GIT_REMOTE").unwrap_or_else(|| "origin".to_string()),
464 }
465 }
466}
467
468#[cfg(test)]
469mod cloud_tests {
470 use super::*;
471
472 #[test]
473 fn test_cloud_disabled_by_default() {
474 let config = CloudConfig::from_env_fn(|_| None);
475 assert!(!config.enabled);
476 }
477
478 #[test]
479 fn test_cloud_enabled_with_env_var() {
480 let env = [
481 ("RALPH_CLOUD_MODE", "true"),
482 ("RALPH_CLOUD_API_URL", "https://api.example.com"),
483 ("RALPH_CLOUD_API_TOKEN", "secret"),
484 ("RALPH_CLOUD_RUN_ID", "run123"),
485 ];
486 let config = CloudConfig::from_env_fn(|k| {
487 env.iter()
488 .find(|(key, _)| *key == k)
489 .map(|(_, v)| (*v).to_string())
490 });
491 assert!(config.enabled);
492 assert_eq!(config.api_url, Some("https://api.example.com".to_string()));
493 assert_eq!(config.run_id, Some("run123".to_string()));
494 }
495
496 #[test]
497 fn test_cloud_validation_requires_fields() {
498 let config = CloudConfig {
499 enabled: true,
500 api_url: None,
501 api_token: None,
502 run_id: None,
503 heartbeat_interval_secs: 30,
504 graceful_degradation: true,
505 git_remote: GitRemoteConfig::default(),
506 };
507
508 assert!(config.validate().is_err());
509 }
510
511 #[test]
512 fn test_git_auth_method_from_env() {
513 let env = [
514 ("RALPH_GIT_AUTH_METHOD", "token"),
515 ("RALPH_GIT_TOKEN", "ghp_test"),
516 ];
517 let config = GitRemoteConfig::from_env_fn(|k| {
518 env.iter()
519 .find(|(key, _)| *key == k)
520 .map(|(_, v)| (*v).to_string())
521 });
522 match config.auth_method {
523 GitAuthMethod::Token { token, .. } => {
524 assert_eq!(token, "ghp_test");
525 }
526 _ => panic!("Expected Token auth method"),
527 }
528 }
529
530 #[test]
531 fn test_cloud_disabled_validation_passes() {
532 let config = CloudConfig::disabled();
533 assert!(
534 config.validate().is_ok(),
535 "Disabled cloud config should always validate"
536 );
537 }
538
539 #[test]
540 fn test_cloud_validation_rejects_non_https_api_url() {
541 let config = CloudConfig {
542 enabled: true,
543 api_url: Some("http://api.example.com".to_string()),
544 api_token: Some("secret".to_string()),
545 run_id: Some("run123".to_string()),
546 heartbeat_interval_secs: 30,
547 graceful_degradation: true,
548 git_remote: GitRemoteConfig::default(),
549 };
550 assert!(
551 config.validate().is_err(),
552 "Cloud API URL must be https:// when cloud mode is enabled"
553 );
554 }
555
556 #[test]
557 fn test_cloud_validation_requires_git_token_for_token_auth() {
558 let config = CloudConfig {
559 enabled: true,
560 api_url: Some("https://api.example.com".to_string()),
561 api_token: Some("secret".to_string()),
562 run_id: Some("run123".to_string()),
563 heartbeat_interval_secs: 30,
564 graceful_degradation: true,
565 git_remote: GitRemoteConfig {
566 auth_method: GitAuthMethod::Token {
567 token: String::new(),
568 username: "x-access-token".to_string(),
569 },
570 ..GitRemoteConfig::default()
571 },
572 };
573 assert!(
574 config.validate().is_err(),
575 "Token auth requires a non-empty RALPH_GIT_TOKEN"
576 );
577 }
578 #[test]
581 fn test_validate_missing_api_url_returns_api_url_missing_variant() {
582 let config = CloudConfig {
583 enabled: true,
584 api_url: None,
585 api_token: Some("token".to_string()),
586 run_id: Some("run".to_string()),
587 ..CloudConfig::default()
588 };
589 assert_eq!(
590 config.validate(),
591 Err(CloudConfigValidationError::ApiUrlMissing)
592 );
593 }
594
595 #[test]
596 fn test_validate_non_https_returns_api_url_not_https_variant() {
597 let config = CloudConfig {
598 enabled: true,
599 api_url: Some("http://api.example.com".to_string()),
600 api_token: Some("token".to_string()),
601 run_id: Some("run".to_string()),
602 ..CloudConfig::default()
603 };
604 assert!(
605 matches!(
606 config.validate(),
607 Err(CloudConfigValidationError::ApiUrlNotHttps)
608 ),
609 "expected ApiUrlNotHttps"
610 );
611 }
612
613 #[test]
614 fn test_validate_missing_token_returns_api_token_missing_variant() {
615 let config = CloudConfig {
616 enabled: true,
617 api_url: Some("https://api.example.com".to_string()),
618 api_token: None,
619 run_id: Some("run".to_string()),
620 ..CloudConfig::default()
621 };
622 assert_eq!(
623 config.validate(),
624 Err(CloudConfigValidationError::ApiTokenMissing)
625 );
626 }
627
628 #[test]
629 fn test_validate_missing_run_id_returns_run_id_missing_variant() {
630 let config = CloudConfig {
631 enabled: true,
632 api_url: Some("https://api.example.com".to_string()),
633 api_token: Some("token".to_string()),
634 run_id: None,
635 ..CloudConfig::default()
636 };
637 assert_eq!(
638 config.validate(),
639 Err(CloudConfigValidationError::RunIdMissing)
640 );
641 }
642
643 #[test]
644 fn test_cloud_config_validation_error_display_not_empty() {
645 for err in [
646 CloudConfigValidationError::ApiUrlMissing,
647 CloudConfigValidationError::ApiUrlNotHttps,
648 CloudConfigValidationError::ApiTokenMissing,
649 CloudConfigValidationError::RunIdMissing,
650 ] {
651 assert!(
652 !err.to_string().is_empty(),
653 "display must not be empty for {err:?}"
654 );
655 }
656 }
657
658 #[test]
661 fn test_git_remote_validate_empty_remote_name_returns_typed_variant() {
662 let config = GitRemoteConfig {
663 remote_name: String::new(),
664 ..GitRemoteConfig::default()
665 };
666 assert_eq!(
667 config.validate(),
668 Err(GitRemoteValidationError::EmptyRemoteName)
669 );
670 }
671
672 #[test]
673 fn test_git_remote_validate_head_push_branch_returns_typed_variant() {
674 let config = GitRemoteConfig {
675 push_branch: Some("HEAD".to_string()),
676 ..GitRemoteConfig::default()
677 };
678 assert_eq!(
679 config.validate(),
680 Err(GitRemoteValidationError::PushBranchIsHead)
681 );
682 }
683
684 #[test]
685 fn test_git_remote_validate_empty_push_branch_returns_typed_variant() {
686 let config = GitRemoteConfig {
687 push_branch: Some(String::new()),
688 ..GitRemoteConfig::default()
689 };
690 assert_eq!(
691 config.validate(),
692 Err(GitRemoteValidationError::EmptyPushBranch)
693 );
694 }
695
696 #[test]
697 fn test_git_remote_validate_empty_ssh_key_path_returns_typed_variant() {
698 let config = GitRemoteConfig {
699 auth_method: GitAuthMethod::SshKey {
700 key_path: Some(String::new()),
701 },
702 ..GitRemoteConfig::default()
703 };
704 assert_eq!(
705 config.validate(),
706 Err(GitRemoteValidationError::EmptySshKeyPath)
707 );
708 }
709
710 #[test]
711 fn test_git_remote_validate_empty_token_returns_typed_variant() {
712 let config = GitRemoteConfig {
713 auth_method: GitAuthMethod::Token {
714 token: String::new(),
715 username: "oauth2".to_string(),
716 },
717 ..GitRemoteConfig::default()
718 };
719 assert_eq!(config.validate(), Err(GitRemoteValidationError::EmptyToken));
720 }
721
722 #[test]
723 fn test_git_remote_validate_empty_token_username_returns_typed_variant() {
724 let config = GitRemoteConfig {
725 auth_method: GitAuthMethod::Token {
726 token: "ghp_valid_token".to_string(),
727 username: String::new(),
728 },
729 ..GitRemoteConfig::default()
730 };
731 assert_eq!(
732 config.validate(),
733 Err(GitRemoteValidationError::EmptyTokenUsername)
734 );
735 }
736
737 #[test]
738 fn test_git_remote_validate_empty_credential_helper_returns_typed_variant() {
739 let config = GitRemoteConfig {
740 auth_method: GitAuthMethod::CredentialHelper {
741 helper: String::new(),
742 },
743 ..GitRemoteConfig::default()
744 };
745 assert_eq!(
746 config.validate(),
747 Err(GitRemoteValidationError::EmptyCredentialHelper)
748 );
749 }
750
751 #[test]
752 fn test_cloud_config_validate_git_remote_error_returns_git_remote_variant() {
753 let config = CloudConfig {
754 enabled: true,
755 api_url: Some("https://api.example.com".to_string()),
756 api_token: Some("token".to_string()),
757 run_id: Some("run-id".to_string()),
758 git_remote: GitRemoteConfig {
759 remote_name: String::new(),
760 ..GitRemoteConfig::default()
761 },
762 ..CloudConfig::default()
763 };
764 assert!(
765 matches!(
766 config.validate(),
767 Err(CloudConfigValidationError::GitRemote(
768 GitRemoteValidationError::EmptyRemoteName
769 ))
770 ),
771 "expected GitRemote(EmptyRemoteName) variant"
772 );
773 }
774}