Skip to main content

ralph_workflow/config/
cloud.rs

1//! Cloud and git remote configuration types.
2//!
3//! This module defines the cloud runtime configuration and git remote types
4//! used when Ralph is running in cloud-hosted mode.
5
6use 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/// Typed error for cloud configuration validation.
16///
17/// `CloudConfig::validate()` returns `Result<(), CloudConfigValidationError>`.
18/// Boundary code can call `.to_string()` for human-readable messages.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum CloudConfigValidationError {
21    /// `RALPH_CLOUD_API_URL` must be set when cloud mode is enabled.
22    ApiUrlMissing,
23    /// `RALPH_CLOUD_API_URL` must use `https://` when cloud mode is enabled.
24    ApiUrlNotHttps,
25    /// `RALPH_CLOUD_API_TOKEN` must be set when cloud mode is enabled.
26    ApiTokenMissing,
27    /// `RALPH_CLOUD_RUN_ID` must be set when cloud mode is enabled.
28    RunIdMissing,
29    /// Git remote configuration is invalid.
30    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/// Typed error for git remote configuration validation.
68///
69/// `GitRemoteConfig::validate()` returns `Result<(), GitRemoteValidationError>`.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum GitRemoteValidationError {
72    /// `RALPH_GIT_REMOTE` must not be empty.
73    EmptyRemoteName,
74    /// `RALPH_GIT_PUSH_BRANCH` must not be empty when set.
75    EmptyPushBranch,
76    /// `RALPH_GIT_PUSH_BRANCH` must be a branch name, not literal `HEAD`.
77    PushBranchIsHead,
78    /// `RALPH_GIT_SSH_KEY_PATH` must not be empty when set.
79    EmptySshKeyPath,
80    /// `RALPH_GIT_TOKEN` must be set when token auth is used.
81    EmptyToken,
82    /// `RALPH_GIT_TOKEN_USERNAME` must not be empty when token auth is used.
83    EmptyTokenUsername,
84    /// `RALPH_GIT_CREDENTIAL_HELPER` must be set when credential-helper auth is used.
85    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/// Cloud runtime configuration (internal).
119///
120/// This struct is loaded from environment variables when cloud mode is enabled.
121#[derive(Debug, Clone, Default, PartialEq)]
122pub struct CloudConfig {
123    /// Enable cloud reporting mode (internal env-config).
124    pub enabled: bool,
125    /// Cloud API base URL.
126    pub api_url: Option<String>,
127    /// Bearer token for API authentication.
128    pub api_token: Option<String>,
129    /// Run ID assigned by cloud orchestrator.
130    pub run_id: Option<String>,
131    /// Heartbeat interval in seconds.
132    pub heartbeat_interval_secs: u32,
133    /// Whether to continue on API failures.
134    pub graceful_degradation: bool,
135    /// Git remote configuration
136    pub git_remote: GitRemoteConfig,
137}
138
139/// Git remote configuration (internal).
140///
141/// Loaded from environment variables when cloud mode is enabled.
142#[derive(Debug, Clone, PartialEq)]
143pub struct GitRemoteConfig {
144    /// Authentication method for git operations
145    pub auth_method: GitAuthMethod,
146    /// Branch to push to (defaults to current branch)
147    pub push_branch: Option<String>,
148    /// Whether to create a PR instead of direct push
149    pub create_pr: bool,
150    /// PR title template (supports {`run_id`}, {`prompt_summary`} placeholders)
151    pub pr_title_template: Option<String>,
152    /// PR body template
153    pub pr_body_template: Option<String>,
154    /// Base branch for PR (defaults to main/master)
155    pub pr_base_branch: Option<String>,
156    /// Whether to force push (dangerous, disabled by default)
157    pub force_push: bool,
158    /// Remote name (defaults to "origin")
159    pub remote_name: String,
160}
161
162#[derive(Debug, Clone, PartialEq)]
163pub enum GitAuthMethod {
164    /// Use SSH key (default for containers with mounted keys)
165    SshKey {
166        /// Path to private key (default: /`root/.ssh/id_rsa` or `SSH_AUTH_SOCK`)
167        key_path: Option<String>,
168    },
169    /// Use token-based HTTPS authentication
170    Token {
171        /// Git token (from `RALPH_GIT_TOKEN` env var)
172        token: String,
173        /// Username for token auth (often "oauth2" or "x-access-token")
174        username: String,
175    },
176    /// Use git credential helper (for cloud provider integrations)
177    CredentialHelper {
178        /// Helper command (e.g., "gcloud", "aws codecommit credential-helper")
179        helper: String,
180    },
181}
182
183/// Cloud configuration that is safe to store in reducer state / checkpoints.
184///
185/// This is a *redacted* view of [`CloudConfig`]: it carries only non-sensitive
186/// fields required for pure orchestration.
187///
188/// In particular, it MUST NOT contain API tokens, git tokens, or any other
189/// credential material.
190#[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    /// Load cloud configuration from a caller-supplied env-var accessor.
311    /// This is the canonical implementation; `from_env` is a thin wrapper.
312    #[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    /// Validate that required fields are present when enabled.
349    ///
350    /// # Errors
351    ///
352    /// Returns error if the operation fails.
353    /// # Errors
354    ///
355    /// Returns [`CloudConfigValidationError`] if required fields are missing or invalid.
356    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        // Validate git remote config when cloud mode is enabled.
373        self.git_remote
374            .validate()
375            .map_err(CloudConfigValidationError::GitRemote)?;
376
377        Ok(())
378    }
379}
380
381impl GitRemoteConfig {
382    /// # Errors
383    ///
384    /// Returns an error if:
385    /// - Remote name is empty
386    /// - Push branch is invalid
387    /// - Auth method configuration is invalid
388    /// # Errors
389    ///
390    /// Returns [`GitRemoteValidationError`] if:
391    /// - Remote name is empty
392    /// - Push branch is invalid
393    /// - Auth method configuration is invalid
394    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    /// Load git-remote configuration from a caller-supplied env-var accessor.
428    /// This is the canonical implementation; `from_env` is a thin wrapper.
429    #[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    // --- Typed error RED tests for CloudConfigValidationError ---
579
580    #[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    // --- Typed error RED tests for GitRemoteValidationError ---
659
660    #[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}