Skip to main content

ralph_workflow/cloud/
reporter.rs

1//! Cloud reporter trait and implementations.
2
3use super::types::{CloudError, PipelineResult, ProgressUpdate};
4use crate::config::types::CloudConfig;
5
6/// Reports pipeline progress to a cloud API.
7///
8/// This trait abstracts cloud API communication to support multiple
9/// implementations (production HTTP, mock for testing, noop for CLI).
10pub trait CloudReporter: Send + Sync {
11    /// Report a progress update to the cloud.
12    ///
13    /// # Errors
14    ///
15    /// Returns error if the operation fails.
16    fn report_progress(&self, update: &ProgressUpdate) -> Result<(), CloudError>;
17
18    /// Send a heartbeat to indicate the container is alive.
19    ///
20    /// # Errors
21    ///
22    /// Returns error if the operation fails.
23    fn heartbeat(&self) -> Result<(), CloudError>;
24
25    /// Report pipeline completion with final results.
26    ///
27    /// # Errors
28    ///
29    /// Returns error if the operation fails.
30    fn report_completion(&self, result: &PipelineResult) -> Result<(), CloudError>;
31}
32
33/// No-op cloud reporter for CLI mode.
34///
35/// This is the default reporter when cloud mode is disabled.
36/// All methods are no-ops that return Ok immediately.
37pub struct NoopCloudReporter;
38
39impl CloudReporter for NoopCloudReporter {
40    fn report_progress(&self, _update: &ProgressUpdate) -> Result<(), CloudError> {
41        Ok(())
42    }
43
44    fn heartbeat(&self) -> Result<(), CloudError> {
45        Ok(())
46    }
47
48    fn report_completion(&self, _result: &PipelineResult) -> Result<(), CloudError> {
49        Ok(())
50    }
51}
52
53/// HTTP cloud reporter for production use.
54///
55/// Sends progress updates to a cloud API via HTTP POST requests.
56pub struct HttpCloudReporter {
57    config: CloudConfig,
58}
59
60impl HttpCloudReporter {
61    #[must_use]
62    pub const fn new(config: CloudConfig) -> Self {
63        Self { config }
64    }
65
66    fn build_url(api_url: &str, path: &str) -> Result<String, CloudError> {
67        let base = api_url.trim();
68        if !base.to_ascii_lowercase().starts_with("https://") {
69            return Err(CloudError::Configuration(
70                "Cloud API URL must use https://".to_string(),
71            ));
72        }
73
74        let base = base.trim_end_matches('/');
75        let path = path.trim_start_matches('/');
76
77        if path.is_empty() {
78            return Ok(base.to_string());
79        }
80
81        Ok(format!("{base}/{path}"))
82    }
83
84    fn post_json<T: serde::Serialize>(&self, path: &str, body: &T) -> Result<(), CloudError> {
85        let api_url = self
86            .config
87            .api_url
88            .as_ref()
89            .ok_or_else(|| CloudError::Configuration("API URL not configured".to_string()))?;
90        let api_token = self
91            .config
92            .api_token
93            .as_ref()
94            .ok_or_else(|| CloudError::Configuration("API token not configured".to_string()))?;
95
96        let url = Self::build_url(api_url, path)?;
97
98        // Build HTTP agent with timeout
99        let agent = ureq::Agent::new_with_config(
100            ureq::config::Config::builder()
101                .timeout_global(Some(std::time::Duration::from_secs(30)))
102                // Always return a Response so we can map status + body ourselves.
103                .http_status_as_error(false)
104                .build(),
105        );
106
107        let json_body =
108            serde_json::to_value(body).map_err(|e| CloudError::Serialization(e.to_string()))?;
109
110        let response = agent
111            .post(&url)
112            .header("Authorization", &format!("Bearer {api_token}"))
113            .header("Content-Type", "application/json")
114            .send_json(json_body);
115
116        match response {
117            Ok(mut resp) => {
118                let status = resp.status();
119                if status.is_success() {
120                    Ok(())
121                } else {
122                    let body = resp.body_mut().read_to_string().unwrap_or_default();
123                    Err(CloudError::HttpError(status.as_u16(), body))
124                }
125            }
126            Err(e) => Err(CloudError::NetworkError(e.to_string())),
127        }
128    }
129}
130
131impl CloudReporter for HttpCloudReporter {
132    fn report_progress(&self, update: &ProgressUpdate) -> Result<(), CloudError> {
133        let run_id = self
134            .config
135            .run_id
136            .as_ref()
137            .ok_or_else(|| CloudError::Configuration("Run ID not configured".to_string()))?;
138
139        let path = format!("runs/{run_id}/progress");
140        self.post_json(&path, update)
141    }
142
143    fn heartbeat(&self) -> Result<(), CloudError> {
144        let run_id = self
145            .config
146            .run_id
147            .as_ref()
148            .ok_or_else(|| CloudError::Configuration("Run ID not configured".to_string()))?;
149
150        let path = format!("runs/{run_id}/heartbeat");
151        let body = serde_json::json!({
152            "timestamp": chrono::Utc::now().to_rfc3339(),
153        });
154        self.post_json(&path, &body)
155    }
156
157    fn report_completion(&self, result: &PipelineResult) -> Result<(), CloudError> {
158        let run_id = self
159            .config
160            .run_id
161            .as_ref()
162            .ok_or_else(|| CloudError::Configuration("Run ID not configured".to_string()))?;
163
164        let path = format!("runs/{run_id}/complete");
165        self.post_json(&path, result)
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::config::types::CloudConfig;
173
174    #[test]
175    fn test_build_url_trims_slashes_and_joins_paths() {
176        let base = "https://api.example.com/v1/";
177        let url = HttpCloudReporter::build_url(base, "/runs/run_1/progress").unwrap();
178        assert_eq!(
179            url, "https://api.example.com/v1/runs/run_1/progress",
180            "URL join should avoid double slashes"
181        );
182    }
183
184    #[test]
185    fn test_build_url_rejects_non_https() {
186        let err = HttpCloudReporter::build_url("http://api.example.com", "/runs/x").unwrap_err();
187        match err {
188            CloudError::Configuration(_) => {}
189            other => panic!("expected Configuration error, got: {other:?}"),
190        }
191    }
192
193    #[test]
194    fn test_noop_reporter_returns_ok() {
195        let reporter = NoopCloudReporter;
196        let update = ProgressUpdate {
197            timestamp: "2025-02-15T10:00:00Z".to_string(),
198            phase: "Planning".to_string(),
199            previous_phase: None,
200            iteration: Some(1),
201            total_iterations: Some(3),
202            review_pass: None,
203            total_review_passes: None,
204            message: "Test".to_string(),
205            event_type: super::super::types::ProgressEventType::PipelineStarted,
206        };
207
208        let result = super::super::types::PipelineResult {
209            success: true,
210            commit_sha: None,
211            pr_url: None,
212            push_count: 0,
213            last_pushed_commit: None,
214            unpushed_commits: Vec::new(),
215            last_push_error: None,
216            iterations_used: 1,
217            review_passes_used: 0,
218            issues_found: false,
219            duration_secs: 100,
220            error_message: None,
221        };
222
223        assert!(reporter.report_progress(&update).is_ok());
224        assert!(reporter.heartbeat().is_ok());
225        assert!(reporter.report_completion(&result).is_ok());
226    }
227
228    #[test]
229    fn test_http_reporter_requires_config() {
230        let config = CloudConfig::disabled();
231        let reporter = HttpCloudReporter::new(config);
232
233        let update = ProgressUpdate {
234            timestamp: "2025-02-15T10:00:00Z".to_string(),
235            phase: "Planning".to_string(),
236            previous_phase: None,
237            iteration: Some(1),
238            total_iterations: Some(3),
239            review_pass: None,
240            total_review_passes: None,
241            message: "Test".to_string(),
242            event_type: super::super::types::ProgressEventType::PipelineStarted,
243        };
244
245        // Should fail because config is disabled (no URL/token)
246        assert!(reporter.report_progress(&update).is_err());
247    }
248}