cascade_cli/bitbucket/
client.rs

1use crate::config::BitbucketConfig;
2use crate::errors::{CascadeError, Result};
3use base64::Engine;
4use reqwest::{
5    header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE},
6    Client,
7};
8use serde::{Deserialize, Serialize};
9use std::time::Duration;
10use tracing::{debug, trace};
11
12/// Bitbucket Server API client
13pub struct BitbucketClient {
14    client: Client,
15    base_url: String,
16    project_key: String,
17    repo_slug: String,
18}
19
20impl BitbucketClient {
21    /// Create a new Bitbucket client
22    pub fn new(config: &BitbucketConfig) -> Result<Self> {
23        let mut headers = HeaderMap::new();
24
25        // Set up authentication
26        let auth_header = match (&config.username, &config.token) {
27            (Some(username), Some(token)) => {
28                let auth_string = format!("{username}:{token}");
29                let auth_encoded = base64::engine::general_purpose::STANDARD.encode(auth_string);
30                format!("Basic {auth_encoded}")
31            }
32            (None, Some(token)) => {
33                format!("Bearer {token}")
34            }
35            _ => {
36                return Err(CascadeError::config(
37                    "Bitbucket authentication credentials not configured",
38                ))
39            }
40        };
41
42        headers.insert(
43            AUTHORIZATION,
44            HeaderValue::from_str(&auth_header)
45                .map_err(|e| CascadeError::config(format!("Invalid auth header: {e}")))?,
46        );
47
48        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
49
50        let mut client_builder = Client::builder()
51            .timeout(Duration::from_secs(30))
52            .default_headers(headers);
53
54        // Add TLS configuration for corporate environments
55        if let Some(accept_invalid_certs) = config.accept_invalid_certs {
56            if accept_invalid_certs {
57                tracing::warn!("⚠️  Accepting invalid TLS certificates - use only in development!");
58                client_builder = client_builder.danger_accept_invalid_certs(true);
59            }
60        }
61
62        // Add custom CA bundle if specified
63        if let Some(ca_bundle_path) = &config.ca_bundle_path {
64            let ca_bundle = std::fs::read(ca_bundle_path).map_err(|e| {
65                CascadeError::config(format!(
66                    "Failed to read CA bundle from {ca_bundle_path}: {e}"
67                ))
68            })?;
69
70            let cert = reqwest::Certificate::from_pem(&ca_bundle).map_err(|e| {
71                CascadeError::config(format!("Invalid CA certificate in {ca_bundle_path}: {e}"))
72            })?;
73
74            client_builder = client_builder.add_root_certificate(cert);
75            tracing::info!("Using custom CA bundle: {ca_bundle_path}");
76        }
77
78        let client = client_builder
79            .build()
80            .map_err(|e| CascadeError::config(format!("Failed to create HTTP client: {e}")))?;
81
82        Ok(Self {
83            client,
84            base_url: config.url.clone(),
85            project_key: config.project.clone(),
86            repo_slug: config.repo.clone(),
87        })
88    }
89
90    /// Get the base API URL for this repository
91    fn api_url(&self, path: &str) -> String {
92        format!(
93            "{}/rest/api/1.0/projects/{}/repos/{}/{}",
94            self.base_url.trim_end_matches('/'),
95            self.project_key,
96            self.repo_slug,
97            path.trim_start_matches('/')
98        )
99    }
100
101    /// Make a GET request to the Bitbucket API
102    pub async fn get<T>(&self, path: &str) -> Result<T>
103    where
104        T: for<'de> Deserialize<'de>,
105    {
106        let url = self.api_url(path);
107        debug!("GET {}", url);
108
109        let response = self
110            .client
111            .get(&url)
112            .send()
113            .await
114            .map_err(|e| CascadeError::bitbucket(format!("GET request failed: {e}")))?;
115
116        self.handle_response(response).await
117    }
118
119    /// Make a POST request to the Bitbucket API
120    pub async fn post<T, U>(&self, path: &str, body: &T) -> Result<U>
121    where
122        T: Serialize,
123        U: for<'de> Deserialize<'de>,
124    {
125        let url = self.api_url(path);
126        debug!("POST {}", url);
127
128        let response = self
129            .client
130            .post(&url)
131            .json(body)
132            .send()
133            .await
134            .map_err(|e| CascadeError::bitbucket(format!("POST request failed: {e}")))?;
135
136        self.handle_response(response).await
137    }
138
139    /// Make a PUT request to the Bitbucket API
140    pub async fn put<T, U>(&self, path: &str, body: &T) -> Result<U>
141    where
142        T: Serialize,
143        U: for<'de> Deserialize<'de>,
144    {
145        let url = self.api_url(path);
146        debug!("PUT {}", url);
147
148        let response = self
149            .client
150            .put(&url)
151            .json(body)
152            .send()
153            .await
154            .map_err(|e| CascadeError::bitbucket(format!("PUT request failed: {e}")))?;
155
156        self.handle_response(response).await
157    }
158
159    /// Make a DELETE request to the Bitbucket API
160    pub async fn delete(&self, path: &str) -> Result<()> {
161        let url = self.api_url(path);
162        debug!("DELETE {}", url);
163
164        let response = self
165            .client
166            .delete(&url)
167            .send()
168            .await
169            .map_err(|e| CascadeError::bitbucket(format!("DELETE request failed: {e}")))?;
170
171        if response.status().is_success() {
172            Ok(())
173        } else {
174            let status = response.status();
175            let text = response
176                .text()
177                .await
178                .unwrap_or_else(|_| "Unknown error".to_string());
179            Err(CascadeError::bitbucket(format!(
180                "DELETE failed with status {status}: {text}"
181            )))
182        }
183    }
184
185    /// Handle HTTP response and deserialize JSON
186    async fn handle_response<T>(&self, response: reqwest::Response) -> Result<T>
187    where
188        T: for<'de> Deserialize<'de>,
189    {
190        let status = response.status();
191
192        if status.is_success() {
193            let text = response.text().await.map_err(|e| {
194                CascadeError::bitbucket(format!("Failed to read response body: {e}"))
195            })?;
196
197            trace!("Response body: {}", text);
198
199            serde_json::from_str(&text)
200                .map_err(|e| CascadeError::bitbucket(format!("Failed to parse JSON response: {e}")))
201        } else {
202            let text = response
203                .text()
204                .await
205                .unwrap_or_else(|_| "Unknown error".to_string());
206            Err(CascadeError::bitbucket(format!(
207                "Request failed with status {status}: {text}"
208            )))
209        }
210    }
211
212    /// Test the connection to Bitbucket Server
213    pub async fn test_connection(&self) -> Result<()> {
214        let url = format!(
215            "{}/rest/api/1.0/projects/{}/repos/{}",
216            self.base_url.trim_end_matches('/'),
217            self.project_key,
218            self.repo_slug
219        );
220
221        debug!("Testing connection to {}", url);
222
223        let response = self
224            .client
225            .get(&url)
226            .send()
227            .await
228            .map_err(|e| CascadeError::bitbucket(format!("Connection test failed: {e}")))?;
229
230        if response.status().is_success() {
231            debug!("Connection test successful");
232            Ok(())
233        } else {
234            let status = response.status();
235            let text = response
236                .text()
237                .await
238                .unwrap_or_else(|_| "Unknown error".to_string());
239            Err(CascadeError::bitbucket(format!(
240                "Connection test failed with status {status}: {text}"
241            )))
242        }
243    }
244
245    /// Get repository information
246    pub async fn get_repository_info(&self) -> Result<RepositoryInfo> {
247        self.get("").await
248    }
249}
250
251/// Repository information from Bitbucket
252#[derive(Debug, Clone, Deserialize)]
253pub struct RepositoryInfo {
254    pub id: u64,
255    pub name: String,
256    pub slug: String,
257    pub description: Option<String>,
258    pub public: bool,
259    pub project: ProjectInfo,
260    pub links: RepositoryLinks,
261}
262
263/// Project information
264#[derive(Debug, Clone, Deserialize)]
265pub struct ProjectInfo {
266    pub id: u64,
267    pub key: String,
268    pub name: String,
269    pub description: Option<String>,
270    pub public: bool,
271}
272
273/// Repository links
274#[derive(Debug, Clone, Deserialize)]
275pub struct RepositoryLinks {
276    pub clone: Vec<CloneLink>,
277    #[serde(rename = "self")]
278    pub self_link: Vec<SelfLink>,
279}
280
281/// Clone link information
282#[derive(Debug, Clone, Deserialize)]
283pub struct CloneLink {
284    pub href: String,
285    pub name: String,
286}
287
288/// Self link information
289#[derive(Debug, Clone, Deserialize)]
290pub struct SelfLink {
291    pub href: String,
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn test_api_url_generation() {
300        let config = BitbucketConfig {
301            url: "https://bitbucket.example.com".to_string(),
302            project: "TEST".to_string(),
303            repo: "my-repo".to_string(),
304            username: Some("user".to_string()),
305            token: Some("token".to_string()),
306            default_reviewers: Vec::new(),
307            accept_invalid_certs: None,
308            ca_bundle_path: None,
309        };
310
311        let client = BitbucketClient::new(&config).unwrap();
312
313        assert_eq!(
314            client.api_url("pull-requests"),
315            "https://bitbucket.example.com/rest/api/1.0/projects/TEST/repos/my-repo/pull-requests"
316        );
317
318        assert_eq!(
319            client.api_url("/pull-requests/123"),
320            "https://bitbucket.example.com/rest/api/1.0/projects/TEST/repos/my-repo/pull-requests/123"
321        );
322    }
323
324    #[test]
325    fn test_url_trimming() {
326        let config = BitbucketConfig {
327            url: "https://bitbucket.example.com/".to_string(), // Note trailing slash
328            project: "TEST".to_string(),
329            repo: "my-repo".to_string(),
330            username: Some("user".to_string()),
331            token: Some("token".to_string()),
332            default_reviewers: Vec::new(),
333            accept_invalid_certs: None,
334            ca_bundle_path: None,
335        };
336
337        let client = BitbucketClient::new(&config).unwrap();
338
339        assert_eq!(
340            client.api_url("pull-requests"),
341            "https://bitbucket.example.com/rest/api/1.0/projects/TEST/repos/my-repo/pull-requests"
342        );
343    }
344}