shopify_approver_opencode/
client.rs

1//! OpenCode API client
2//!
3//! Client for communicating with the OpenCode service for codebase
4//! indexing and semantic search.
5
6use crate::config::OpenCodeConfig;
7use crate::error::{OpenCodeError, Result};
8use crate::models::*;
9use crate::packager::{PackageResult, Packager};
10use reqwest::{multipart, Client, StatusCode};
11use std::path::Path;
12use std::time::Instant;
13use tokio::fs::File;
14use tokio::io::AsyncReadExt;
15use tracing::{debug, info, warn};
16
17/// Client for OpenCode service
18pub struct OpenCodeClient {
19    client: Client,
20    config: OpenCodeConfig,
21}
22
23impl OpenCodeClient {
24    /// Create a new OpenCode client
25    pub fn new(config: OpenCodeConfig) -> Result<Self> {
26        config.validate()?;
27
28        let client = Client::builder()
29            .timeout(config.timeout)
30            .build()
31            .map_err(OpenCodeError::Http)?;
32
33        Ok(Self { client, config })
34    }
35
36    /// Create client from environment variables
37    pub fn from_env() -> Result<Self> {
38        let config = OpenCodeConfig::from_env()?;
39        Self::new(config)
40    }
41
42    /// Get the base URL
43    pub fn base_url(&self) -> &str {
44        &self.config.base_url
45    }
46
47    // =========================================================================
48    // INDEX OPERATIONS
49    // =========================================================================
50
51    /// Index a codebase and wait for completion
52    ///
53    /// This is the main entry point for indexing. It:
54    /// 1. Packages the codebase
55    /// 2. Uploads to OpenCode
56    /// 3. Waits for indexing to complete
57    /// 4. Returns the index ID
58    pub async fn index_codebase(&self, codebase_path: &Path) -> Result<IndexResponse> {
59        info!("Indexing codebase: {}", codebase_path.display());
60        let start = Instant::now();
61
62        // Package the codebase
63        let packager = Packager::new()
64            .with_max_file_size(self.config.max_file_size)
65            .with_max_total_size(self.config.max_upload_size);
66
67        let package = packager.package(codebase_path)?;
68        info!(
69            "Packaged {} files ({} bytes compressed)",
70            package.file_count, package.compressed_size
71        );
72
73        // Upload and create index
74        let index = self.upload_and_index(&package).await?;
75        info!("Created index: {}", index.id);
76
77        // Wait for indexing to complete
78        let result = self.wait_for_index(&index.id).await?;
79
80        let elapsed = start.elapsed();
81        info!("Indexing completed in {:?}", elapsed);
82
83        // Cleanup temp file
84        if let Err(e) = std::fs::remove_file(&package.archive_path) {
85            warn!("Failed to cleanup temp file: {}", e);
86        }
87
88        Ok(result)
89    }
90
91    /// Upload a packaged codebase and create an index
92    async fn upload_and_index(&self, package: &PackageResult) -> Result<IndexResponse> {
93        let url = format!("{}/api/v1/index", self.config.base_url);
94
95        // Read archive file
96        let mut file = File::open(&package.archive_path).await?;
97        let mut buffer = Vec::new();
98        file.read_to_end(&mut buffer).await?;
99
100        // Create multipart form
101        let part = multipart::Part::bytes(buffer)
102            .file_name("codebase.tar.gz")
103            .mime_str("application/gzip")
104            .map_err(|e| OpenCodeError::Packaging(e.to_string()))?;
105
106        let form = multipart::Form::new()
107            .part("archive", part)
108            .text("file_count", package.file_count.to_string())
109            .text("total_size", package.total_size.to_string());
110
111        debug!("Uploading to: {}", url);
112
113        let mut request = self.client.post(&url).multipart(form);
114
115        if let Some(ref api_key) = self.config.api_key {
116            request = request.header("Authorization", format!("Bearer {}", api_key));
117        }
118
119        let response = request.send().await?;
120        self.handle_response(response).await
121    }
122
123    /// Wait for an index to become ready
124    pub async fn wait_for_index(&self, index_id: &str) -> Result<IndexResponse> {
125        let start = Instant::now();
126        let timeout = self.config.indexing_timeout;
127        let poll_interval = self.config.poll_interval;
128
129        loop {
130            let status = self.get_index_status(index_id).await?;
131
132            match status.status {
133                IndexStatus::Ready => return Ok(status),
134                IndexStatus::Failed => {
135                    return Err(OpenCodeError::ServiceError {
136                        status: 500,
137                        message: status.error.unwrap_or_else(|| "Indexing failed".to_string()),
138                    });
139                }
140                IndexStatus::Pending | IndexStatus::Processing => {
141                    if start.elapsed() > timeout {
142                        return Err(OpenCodeError::IndexingTimeout);
143                    }
144                    debug!("Index {} still processing, waiting...", index_id);
145                    tokio::time::sleep(poll_interval).await;
146                }
147                IndexStatus::Deleted => {
148                    return Err(OpenCodeError::IndexNotFound(index_id.to_string()));
149                }
150            }
151        }
152    }
153
154    /// Get status of an index
155    pub async fn get_index_status(&self, index_id: &str) -> Result<IndexResponse> {
156        let url = format!("{}/api/v1/index/{}", self.config.base_url, index_id);
157
158        let mut request = self.client.get(&url);
159        if let Some(ref api_key) = self.config.api_key {
160            request = request.header("Authorization", format!("Bearer {}", api_key));
161        }
162
163        let response = request.send().await?;
164        self.handle_response(response).await
165    }
166
167    /// Delete an index
168    pub async fn delete_index(&self, index_id: &str) -> Result<()> {
169        let url = format!("{}/api/v1/index/{}", self.config.base_url, index_id);
170
171        let mut request = self.client.delete(&url);
172        if let Some(ref api_key) = self.config.api_key {
173            request = request.header("Authorization", format!("Bearer {}", api_key));
174        }
175
176        let response = request.send().await?;
177
178        if response.status().is_success() {
179            Ok(())
180        } else {
181            let status = response.status().as_u16();
182            let message = response.text().await.unwrap_or_default();
183            Err(OpenCodeError::service_error(status, message))
184        }
185    }
186
187    /// Get statistics about an index
188    pub async fn get_index_stats(&self, index_id: &str) -> Result<IndexStats> {
189        let url = format!("{}/api/v1/index/{}/stats", self.config.base_url, index_id);
190
191        let mut request = self.client.get(&url);
192        if let Some(ref api_key) = self.config.api_key {
193            request = request.header("Authorization", format!("Bearer {}", api_key));
194        }
195
196        let response = request.send().await?;
197        self.handle_response(response).await
198    }
199
200    // =========================================================================
201    // SEARCH OPERATIONS
202    // =========================================================================
203
204    /// Search for code in an indexed codebase
205    pub async fn search(&self, index_id: &str, request: SearchRequest) -> Result<SearchResponse> {
206        let url = format!("{}/api/v1/index/{}/search", self.config.base_url, index_id);
207
208        let mut http_request = self.client.post(&url).json(&request);
209        if let Some(ref api_key) = self.config.api_key {
210            http_request = http_request.header("Authorization", format!("Bearer {}", api_key));
211        }
212
213        let response = http_request.send().await?;
214        self.handle_response(response).await
215    }
216
217    /// Simple search with just a query string
218    pub async fn search_code(
219        &self,
220        index_id: &str,
221        query: &str,
222        limit: usize,
223    ) -> Result<Vec<CodeChunk>> {
224        let request = SearchRequest::new(query).with_limit(limit);
225        let response = self.search(index_id, request).await?;
226        Ok(response.results)
227    }
228
229    /// Search for specific patterns (webhooks, API calls, etc.)
230    pub async fn search_pattern(
231        &self,
232        index_id: &str,
233        pattern: &str,
234        extensions: Vec<String>,
235    ) -> Result<Vec<CodeChunk>> {
236        let request = SearchRequest::new(pattern)
237            .with_limit(20)
238            .with_extensions(extensions)
239            .with_context_lines(5);
240
241        let response = self.search(index_id, request).await?;
242        Ok(response.results)
243    }
244
245    // =========================================================================
246    // CONTEXT OPERATIONS
247    // =========================================================================
248
249    /// Get context for a specific file
250    pub async fn get_file_context(
251        &self,
252        index_id: &str,
253        file_path: &str,
254    ) -> Result<ContextResponse> {
255        let request = ContextRequest::new(file_path);
256        self.get_context(index_id, request).await
257    }
258
259    /// Get context with custom options
260    pub async fn get_context(
261        &self,
262        index_id: &str,
263        request: ContextRequest,
264    ) -> Result<ContextResponse> {
265        let url = format!("{}/api/v1/index/{}/context", self.config.base_url, index_id);
266
267        let mut http_request = self.client.post(&url).json(&request);
268        if let Some(ref api_key) = self.config.api_key {
269            http_request = http_request.header("Authorization", format!("Bearer {}", api_key));
270        }
271
272        let response = http_request.send().await?;
273        self.handle_response(response).await
274    }
275
276    /// Get a file's content directly
277    pub async fn get_file(&self, index_id: &str, file_path: &str) -> Result<FileInfo> {
278        let url = format!(
279            "{}/api/v1/index/{}/file/{}",
280            self.config.base_url, index_id, file_path
281        );
282
283        let mut request = self.client.get(&url);
284        if let Some(ref api_key) = self.config.api_key {
285            request = request.header("Authorization", format!("Bearer {}", api_key));
286        }
287
288        let response = request.send().await?;
289        self.handle_response(response).await
290    }
291
292    /// List all files in an index
293    pub async fn list_files(&self, index_id: &str) -> Result<Vec<String>> {
294        let url = format!("{}/api/v1/index/{}/files", self.config.base_url, index_id);
295
296        let mut request = self.client.get(&url);
297        if let Some(ref api_key) = self.config.api_key {
298            request = request.header("Authorization", format!("Bearer {}", api_key));
299        }
300
301        let response = request.send().await?;
302        self.handle_response(response).await
303    }
304
305    // =========================================================================
306    // VALIDATION HELPERS
307    // =========================================================================
308
309    /// Search for webhook handlers in the codebase
310    pub async fn find_webhooks(&self, index_id: &str) -> Result<Vec<CodeChunk>> {
311        self.search_pattern(
312            index_id,
313            "webhook handler GDPR customers data request redact",
314            vec!["ts".into(), "js".into(), "rb".into(), "py".into(), "php".into()],
315        )
316        .await
317    }
318
319    /// Search for Shopify API calls
320    pub async fn find_api_calls(&self, index_id: &str) -> Result<Vec<CodeChunk>> {
321        self.search_pattern(
322            index_id,
323            "shopify api graphql rest admin",
324            vec!["ts".into(), "js".into(), "rb".into(), "py".into(), "php".into()],
325        )
326        .await
327    }
328
329    /// Search for billing-related code
330    pub async fn find_billing_code(&self, index_id: &str) -> Result<Vec<CodeChunk>> {
331        self.search_pattern(
332            index_id,
333            "billing payment subscription charge stripe",
334            vec!["ts".into(), "js".into(), "rb".into(), "py".into(), "php".into()],
335        )
336        .await
337    }
338
339    /// Search for authentication/OAuth code
340    pub async fn find_auth_code(&self, index_id: &str) -> Result<Vec<CodeChunk>> {
341        self.search_pattern(
342            index_id,
343            "oauth authentication access token session",
344            vec!["ts".into(), "js".into(), "rb".into(), "py".into(), "php".into()],
345        )
346        .await
347    }
348
349    // =========================================================================
350    // INTERNAL HELPERS
351    // =========================================================================
352
353    /// Handle API response
354    async fn handle_response<T: serde::de::DeserializeOwned>(
355        &self,
356        response: reqwest::Response,
357    ) -> Result<T> {
358        let status = response.status();
359
360        if status.is_success() {
361            let body = response.json().await?;
362            Ok(body)
363        } else {
364            let status_code = status.as_u16();
365            let message = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
366
367            match status {
368                StatusCode::NOT_FOUND => Err(OpenCodeError::IndexNotFound(message)),
369                StatusCode::ACCEPTED => Err(OpenCodeError::IndexPending(message)),
370                _ => Err(OpenCodeError::service_error(status_code, message)),
371            }
372        }
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_client_creation() {
382        let config = OpenCodeConfig::new("http://localhost:3000");
383        let client = OpenCodeClient::new(config);
384        assert!(client.is_ok());
385    }
386
387    #[test]
388    fn test_invalid_config() {
389        let config = OpenCodeConfig::new("");
390        let client = OpenCodeClient::new(config);
391        assert!(client.is_err());
392    }
393}