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 client = Client::builder()
51            .timeout(Duration::from_secs(30))
52            .default_headers(headers)
53            .build()
54            .map_err(|e| CascadeError::config(format!("Failed to create HTTP client: {e}")))?;
55
56        Ok(Self {
57            client,
58            base_url: config.url.clone(),
59            project_key: config.project.clone(),
60            repo_slug: config.repo.clone(),
61        })
62    }
63
64    /// Get the base API URL for this repository
65    fn api_url(&self, path: &str) -> String {
66        format!(
67            "{}/rest/api/1.0/projects/{}/repos/{}/{}",
68            self.base_url.trim_end_matches('/'),
69            self.project_key,
70            self.repo_slug,
71            path.trim_start_matches('/')
72        )
73    }
74
75    /// Make a GET request to the Bitbucket API
76    pub async fn get<T>(&self, path: &str) -> Result<T>
77    where
78        T: for<'de> Deserialize<'de>,
79    {
80        let url = self.api_url(path);
81        debug!("GET {}", url);
82
83        let response = self
84            .client
85            .get(&url)
86            .send()
87            .await
88            .map_err(|e| CascadeError::bitbucket(format!("GET request failed: {e}")))?;
89
90        self.handle_response(response).await
91    }
92
93    /// Make a POST request to the Bitbucket API
94    pub async fn post<T, U>(&self, path: &str, body: &T) -> Result<U>
95    where
96        T: Serialize,
97        U: for<'de> Deserialize<'de>,
98    {
99        let url = self.api_url(path);
100        debug!("POST {}", url);
101
102        let response = self
103            .client
104            .post(&url)
105            .json(body)
106            .send()
107            .await
108            .map_err(|e| CascadeError::bitbucket(format!("POST request failed: {e}")))?;
109
110        self.handle_response(response).await
111    }
112
113    /// Make a PUT request to the Bitbucket API
114    pub async fn put<T, U>(&self, path: &str, body: &T) -> Result<U>
115    where
116        T: Serialize,
117        U: for<'de> Deserialize<'de>,
118    {
119        let url = self.api_url(path);
120        debug!("PUT {}", url);
121
122        let response = self
123            .client
124            .put(&url)
125            .json(body)
126            .send()
127            .await
128            .map_err(|e| CascadeError::bitbucket(format!("PUT request failed: {e}")))?;
129
130        self.handle_response(response).await
131    }
132
133    /// Make a DELETE request to the Bitbucket API
134    pub async fn delete(&self, path: &str) -> Result<()> {
135        let url = self.api_url(path);
136        debug!("DELETE {}", url);
137
138        let response = self
139            .client
140            .delete(&url)
141            .send()
142            .await
143            .map_err(|e| CascadeError::bitbucket(format!("DELETE request failed: {e}")))?;
144
145        if response.status().is_success() {
146            Ok(())
147        } else {
148            let status = response.status();
149            let text = response
150                .text()
151                .await
152                .unwrap_or_else(|_| "Unknown error".to_string());
153            Err(CascadeError::bitbucket(format!(
154                "DELETE failed with status {status}: {text}"
155            )))
156        }
157    }
158
159    /// Handle HTTP response and deserialize JSON
160    async fn handle_response<T>(&self, response: reqwest::Response) -> Result<T>
161    where
162        T: for<'de> Deserialize<'de>,
163    {
164        let status = response.status();
165
166        if status.is_success() {
167            let text = response.text().await.map_err(|e| {
168                CascadeError::bitbucket(format!("Failed to read response body: {e}"))
169            })?;
170
171            trace!("Response body: {}", text);
172
173            serde_json::from_str(&text)
174                .map_err(|e| CascadeError::bitbucket(format!("Failed to parse JSON response: {e}")))
175        } else {
176            let text = response
177                .text()
178                .await
179                .unwrap_or_else(|_| "Unknown error".to_string());
180            Err(CascadeError::bitbucket(format!(
181                "Request failed with status {status}: {text}"
182            )))
183        }
184    }
185
186    /// Test the connection to Bitbucket Server
187    pub async fn test_connection(&self) -> Result<()> {
188        let url = format!(
189            "{}/rest/api/1.0/projects/{}/repos/{}",
190            self.base_url.trim_end_matches('/'),
191            self.project_key,
192            self.repo_slug
193        );
194
195        debug!("Testing connection to {}", url);
196
197        let response = self
198            .client
199            .get(&url)
200            .send()
201            .await
202            .map_err(|e| CascadeError::bitbucket(format!("Connection test failed: {e}")))?;
203
204        if response.status().is_success() {
205            debug!("Connection test successful");
206            Ok(())
207        } else {
208            let status = response.status();
209            let text = response
210                .text()
211                .await
212                .unwrap_or_else(|_| "Unknown error".to_string());
213            Err(CascadeError::bitbucket(format!(
214                "Connection test failed with status {status}: {text}"
215            )))
216        }
217    }
218
219    /// Get repository information
220    pub async fn get_repository_info(&self) -> Result<RepositoryInfo> {
221        self.get("").await
222    }
223}
224
225/// Repository information from Bitbucket
226#[derive(Debug, Clone, Deserialize)]
227pub struct RepositoryInfo {
228    pub id: u64,
229    pub name: String,
230    pub slug: String,
231    pub description: Option<String>,
232    pub public: bool,
233    pub project: ProjectInfo,
234    pub links: RepositoryLinks,
235}
236
237/// Project information
238#[derive(Debug, Clone, Deserialize)]
239pub struct ProjectInfo {
240    pub id: u64,
241    pub key: String,
242    pub name: String,
243    pub description: Option<String>,
244    pub public: bool,
245}
246
247/// Repository links
248#[derive(Debug, Clone, Deserialize)]
249pub struct RepositoryLinks {
250    pub clone: Vec<CloneLink>,
251    #[serde(rename = "self")]
252    pub self_link: Vec<SelfLink>,
253}
254
255/// Clone link information
256#[derive(Debug, Clone, Deserialize)]
257pub struct CloneLink {
258    pub href: String,
259    pub name: String,
260}
261
262/// Self link information
263#[derive(Debug, Clone, Deserialize)]
264pub struct SelfLink {
265    pub href: String,
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_api_url_generation() {
274        let config = BitbucketConfig {
275            url: "https://bitbucket.example.com".to_string(),
276            project: "TEST".to_string(),
277            repo: "my-repo".to_string(),
278            username: Some("user".to_string()),
279            token: Some("token".to_string()),
280            default_reviewers: Vec::new(),
281        };
282
283        let client = BitbucketClient::new(&config).unwrap();
284
285        assert_eq!(
286            client.api_url("pull-requests"),
287            "https://bitbucket.example.com/rest/api/1.0/projects/TEST/repos/my-repo/pull-requests"
288        );
289
290        assert_eq!(
291            client.api_url("/pull-requests/123"),
292            "https://bitbucket.example.com/rest/api/1.0/projects/TEST/repos/my-repo/pull-requests/123"
293        );
294    }
295
296    #[test]
297    fn test_url_trimming() {
298        let config = BitbucketConfig {
299            url: "https://bitbucket.example.com/".to_string(), // Note trailing slash
300            project: "TEST".to_string(),
301            repo: "my-repo".to_string(),
302            username: Some("user".to_string()),
303            token: Some("token".to_string()),
304            default_reviewers: Vec::new(),
305        };
306
307        let client = BitbucketClient::new(&config).unwrap();
308
309        assert_eq!(
310            client.api_url("pull-requests"),
311            "https://bitbucket.example.com/rest/api/1.0/projects/TEST/repos/my-repo/pull-requests"
312        );
313    }
314}