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