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
12pub struct BitbucketClient {
14 client: Client,
15 base_url: String,
16 project_key: String,
17 repo_slug: String,
18}
19
20impl BitbucketClient {
21 pub fn new(config: &BitbucketConfig) -> Result<Self> {
23 let mut headers = HeaderMap::new();
24
25 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 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 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 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 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 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 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 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 pub async fn get_repository_info(&self) -> Result<RepositoryInfo> {
221 self.get("").await
222 }
223}
224
225#[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#[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#[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#[derive(Debug, Clone, Deserialize)]
257pub struct CloneLink {
258 pub href: String,
259 pub name: String,
260}
261
262#[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(), 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}