1use std::path::PathBuf;
30
31#[cfg(feature = "test-helpers")]
32use std::sync::Mutex;
33
34#[derive(Debug, Clone)]
40pub struct ReleaseInfo {
41 pub id: u64,
42 pub html_url: String,
43 pub tag_name: String,
44 pub name: Option<String>,
45 pub draft: bool,
46}
47
48#[derive(Debug, Clone)]
50pub struct CreateReleaseParams {
51 pub owner: String,
52 pub repo: String,
53 pub tag_name: String,
54 pub name: String,
55 pub body: String,
56 pub draft: bool,
57 pub prerelease: bool,
58 pub generate_release_notes: bool,
59 pub make_latest: Option<String>,
60}
61
62#[derive(Debug, Clone)]
64pub struct UploadAssetParams {
65 pub owner: String,
66 pub repo: String,
67 pub release_id: u64,
68 pub file_name: String,
69 pub file_path: PathBuf,
70}
71
72#[derive(Debug, Clone)]
74pub struct AssetInfo {
75 pub id: u64,
76 pub name: String,
77 pub size: u64,
78}
79
80#[derive(Debug, Clone)]
82pub struct ListReleasesParams {
83 pub owner: String,
84 pub repo: String,
85}
86
87#[derive(Debug, Clone)]
89pub struct DeleteReleaseParams {
90 pub owner: String,
91 pub repo: String,
92 pub release_id: u64,
93}
94
95pub trait GitHubClient {
105 fn create_release(&self, params: &CreateReleaseParams) -> anyhow::Result<ReleaseInfo>;
107
108 fn upload_asset(&self, params: &UploadAssetParams) -> anyhow::Result<AssetInfo>;
110
111 fn list_releases(&self, params: &ListReleasesParams) -> anyhow::Result<Vec<ReleaseInfo>>;
113
114 fn delete_release(&self, params: &DeleteReleaseParams) -> anyhow::Result<()>;
116}
117
118#[cfg(feature = "test-helpers")]
129pub struct MockGitHubClient {
130 create_release_calls: Mutex<Vec<CreateReleaseParams>>,
131 upload_asset_calls: Mutex<Vec<UploadAssetParams>>,
132 list_releases_calls: Mutex<Vec<ListReleasesParams>>,
133 delete_release_calls: Mutex<Vec<DeleteReleaseParams>>,
134
135 create_release_response: Mutex<Option<Result<ReleaseInfo, String>>>,
136 upload_asset_response: Mutex<Option<Result<AssetInfo, String>>>,
137 list_releases_response: Mutex<Option<Result<Vec<ReleaseInfo>, String>>>,
138 delete_release_response: Mutex<Option<Result<(), String>>>,
139}
140
141#[cfg(feature = "test-helpers")]
142impl MockGitHubClient {
143 pub fn new() -> Self {
149 Self {
150 create_release_calls: Mutex::new(Vec::new()),
151 upload_asset_calls: Mutex::new(Vec::new()),
152 list_releases_calls: Mutex::new(Vec::new()),
153 delete_release_calls: Mutex::new(Vec::new()),
154 create_release_response: Mutex::new(None),
155 upload_asset_response: Mutex::new(None),
156 list_releases_response: Mutex::new(None),
157 delete_release_response: Mutex::new(None),
158 }
159 }
160
161 pub fn set_create_release_response(&self, response: Result<ReleaseInfo, String>) {
165 *self
166 .create_release_response
167 .lock()
168 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
169 }
170
171 pub fn set_upload_asset_response(&self, response: Result<AssetInfo, String>) {
173 *self
174 .upload_asset_response
175 .lock()
176 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
177 }
178
179 pub fn set_list_releases_response(&self, response: Result<Vec<ReleaseInfo>, String>) {
181 *self
182 .list_releases_response
183 .lock()
184 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
185 }
186
187 pub fn set_delete_release_response(&self, response: Result<(), String>) {
189 *self
190 .delete_release_response
191 .lock()
192 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
193 }
194
195 pub fn create_release_call_count(&self) -> usize {
199 self.create_release_calls
200 .lock()
201 .unwrap_or_else(std::sync::PoisonError::into_inner)
202 .len()
203 }
204
205 pub fn upload_asset_call_count(&self) -> usize {
207 self.upload_asset_calls
208 .lock()
209 .unwrap_or_else(std::sync::PoisonError::into_inner)
210 .len()
211 }
212
213 pub fn list_releases_call_count(&self) -> usize {
215 self.list_releases_calls
216 .lock()
217 .unwrap_or_else(std::sync::PoisonError::into_inner)
218 .len()
219 }
220
221 pub fn delete_release_call_count(&self) -> usize {
223 self.delete_release_calls
224 .lock()
225 .unwrap_or_else(std::sync::PoisonError::into_inner)
226 .len()
227 }
228
229 pub fn create_release_calls(&self) -> Vec<CreateReleaseParams> {
231 self.create_release_calls
232 .lock()
233 .unwrap_or_else(std::sync::PoisonError::into_inner)
234 .clone()
235 }
236
237 pub fn upload_asset_calls(&self) -> Vec<UploadAssetParams> {
239 self.upload_asset_calls
240 .lock()
241 .unwrap_or_else(std::sync::PoisonError::into_inner)
242 .clone()
243 }
244
245 pub fn list_releases_calls(&self) -> Vec<ListReleasesParams> {
247 self.list_releases_calls
248 .lock()
249 .unwrap_or_else(std::sync::PoisonError::into_inner)
250 .clone()
251 }
252
253 pub fn delete_release_calls(&self) -> Vec<DeleteReleaseParams> {
255 self.delete_release_calls
256 .lock()
257 .unwrap_or_else(std::sync::PoisonError::into_inner)
258 .clone()
259 }
260}
261
262#[cfg(feature = "test-helpers")]
263impl Default for MockGitHubClient {
264 fn default() -> Self {
265 Self::new()
266 }
267}
268
269#[cfg(feature = "test-helpers")]
270impl GitHubClient for MockGitHubClient {
271 fn create_release(&self, params: &CreateReleaseParams) -> anyhow::Result<ReleaseInfo> {
272 self.create_release_calls
273 .lock()
274 .unwrap_or_else(std::sync::PoisonError::into_inner)
275 .push(params.clone());
276
277 match self
278 .create_release_response
279 .lock()
280 .unwrap_or_else(std::sync::PoisonError::into_inner)
281 .as_ref()
282 {
283 Some(Ok(info)) => Ok(info.clone()),
284 Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
285 None => Err(anyhow::anyhow!(
286 "MockGitHubClient: no create_release response configured"
287 )),
288 }
289 }
290
291 fn upload_asset(&self, params: &UploadAssetParams) -> anyhow::Result<AssetInfo> {
292 self.upload_asset_calls
293 .lock()
294 .unwrap_or_else(std::sync::PoisonError::into_inner)
295 .push(params.clone());
296
297 match self
298 .upload_asset_response
299 .lock()
300 .unwrap_or_else(std::sync::PoisonError::into_inner)
301 .as_ref()
302 {
303 Some(Ok(info)) => Ok(info.clone()),
304 Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
305 None => Err(anyhow::anyhow!(
306 "MockGitHubClient: no upload_asset response configured"
307 )),
308 }
309 }
310
311 fn list_releases(&self, params: &ListReleasesParams) -> anyhow::Result<Vec<ReleaseInfo>> {
312 self.list_releases_calls
313 .lock()
314 .unwrap_or_else(std::sync::PoisonError::into_inner)
315 .push(params.clone());
316
317 match self
318 .list_releases_response
319 .lock()
320 .unwrap_or_else(std::sync::PoisonError::into_inner)
321 .as_ref()
322 {
323 Some(Ok(releases)) => Ok(releases.clone()),
324 Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
325 None => Err(anyhow::anyhow!(
326 "MockGitHubClient: no list_releases response configured"
327 )),
328 }
329 }
330
331 fn delete_release(&self, params: &DeleteReleaseParams) -> anyhow::Result<()> {
332 self.delete_release_calls
333 .lock()
334 .unwrap_or_else(std::sync::PoisonError::into_inner)
335 .push(params.clone());
336
337 match self
338 .delete_release_response
339 .lock()
340 .unwrap_or_else(std::sync::PoisonError::into_inner)
341 .as_ref()
342 {
343 Some(Ok(())) => Ok(()),
344 Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
345 None => Err(anyhow::anyhow!(
346 "MockGitHubClient: no delete_release response configured"
347 )),
348 }
349 }
350}
351
352#[cfg(all(test, feature = "test-helpers"))]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn test_mock_records_create_release_calls() {
362 let mock = MockGitHubClient::new();
363 mock.set_create_release_response(Ok(ReleaseInfo {
364 id: 42,
365 html_url: "https://github.com/owner/repo/releases/42".to_string(),
366 tag_name: "v1.0.0".to_string(),
367 name: Some("Release v1.0.0".to_string()),
368 draft: false,
369 }));
370
371 let params = CreateReleaseParams {
372 owner: "owner".to_string(),
373 repo: "repo".to_string(),
374 tag_name: "v1.0.0".to_string(),
375 name: "Release v1.0.0".to_string(),
376 body: "Changelog here".to_string(),
377 draft: false,
378 prerelease: false,
379 generate_release_notes: false,
380 make_latest: None,
381 };
382
383 let result = mock.create_release(¶ms).unwrap();
384 assert_eq!(result.id, 42);
385 assert_eq!(result.tag_name, "v1.0.0");
386 assert_eq!(mock.create_release_call_count(), 1);
387
388 let calls = mock.create_release_calls();
389 assert_eq!(calls[0].owner, "owner");
390 assert_eq!(calls[0].tag_name, "v1.0.0");
391 }
392
393 #[test]
394 fn test_mock_records_upload_asset_calls() {
395 let mock = MockGitHubClient::new();
396 mock.set_upload_asset_response(Ok(AssetInfo {
397 id: 100,
398 name: "myapp-linux-amd64.tar.gz".to_string(),
399 size: 4096,
400 }));
401
402 let params = UploadAssetParams {
403 owner: "owner".to_string(),
404 repo: "repo".to_string(),
405 release_id: 42,
406 file_name: "myapp-linux-amd64.tar.gz".to_string(),
407 file_path: PathBuf::from("/tmp/myapp-linux-amd64.tar.gz"),
408 };
409
410 let result = mock.upload_asset(¶ms).unwrap();
411 assert_eq!(result.name, "myapp-linux-amd64.tar.gz");
412 assert_eq!(mock.upload_asset_call_count(), 1);
413 }
414
415 #[test]
416 fn test_mock_records_list_releases_calls() {
417 let mock = MockGitHubClient::new();
418 mock.set_list_releases_response(Ok(vec![ReleaseInfo {
419 id: 1,
420 html_url: "https://github.com/owner/repo/releases/1".to_string(),
421 tag_name: "v0.9.0".to_string(),
422 name: Some("Release v0.9.0".to_string()),
423 draft: false,
424 }]));
425
426 let params = ListReleasesParams {
427 owner: "owner".to_string(),
428 repo: "repo".to_string(),
429 };
430
431 let result = mock.list_releases(¶ms).unwrap();
432 assert_eq!(result.len(), 1);
433 assert_eq!(mock.list_releases_call_count(), 1);
434 }
435
436 #[test]
437 fn test_mock_records_delete_release_calls() {
438 let mock = MockGitHubClient::new();
439 mock.set_delete_release_response(Ok(()));
440
441 let params = DeleteReleaseParams {
442 owner: "owner".to_string(),
443 repo: "repo".to_string(),
444 release_id: 42,
445 };
446
447 mock.delete_release(¶ms).unwrap();
448 assert_eq!(mock.delete_release_call_count(), 1);
449
450 let calls = mock.delete_release_calls();
451 assert_eq!(calls[0].release_id, 42);
452 }
453
454 #[test]
455 fn test_mock_returns_error_when_no_response_configured() {
456 let mock = MockGitHubClient::new();
457
458 let params = CreateReleaseParams {
459 owner: "owner".to_string(),
460 repo: "repo".to_string(),
461 tag_name: "v1.0.0".to_string(),
462 name: "Release".to_string(),
463 body: "".to_string(),
464 draft: false,
465 prerelease: false,
466 generate_release_notes: false,
467 make_latest: None,
468 };
469
470 let result = mock.create_release(¶ms);
471 assert!(result.is_err());
472 assert!(
473 result
474 .unwrap_err()
475 .to_string()
476 .contains("no create_release response configured")
477 );
478 }
479
480 #[test]
481 fn test_mock_returns_configured_error() {
482 let mock = MockGitHubClient::new();
483 mock.set_create_release_response(Err("API rate limit exceeded".to_string()));
484
485 let params = CreateReleaseParams {
486 owner: "owner".to_string(),
487 repo: "repo".to_string(),
488 tag_name: "v1.0.0".to_string(),
489 name: "Release".to_string(),
490 body: "".to_string(),
491 draft: false,
492 prerelease: false,
493 generate_release_notes: false,
494 make_latest: None,
495 };
496
497 let result = mock.create_release(¶ms);
498 assert!(result.is_err());
499 assert!(
500 result
501 .unwrap_err()
502 .to_string()
503 .contains("API rate limit exceeded")
504 );
505 }
506
507 #[test]
508 fn test_mock_multiple_calls_accumulate() {
509 let mock = MockGitHubClient::new();
510 mock.set_delete_release_response(Ok(()));
511
512 for i in 1..=3 {
513 let params = DeleteReleaseParams {
514 owner: "owner".to_string(),
515 repo: "repo".to_string(),
516 release_id: i,
517 };
518 mock.delete_release(¶ms).unwrap();
519 }
520
521 assert_eq!(mock.delete_release_call_count(), 3);
522 let calls = mock.delete_release_calls();
523 assert_eq!(calls[0].release_id, 1);
524 assert_eq!(calls[1].release_id, 2);
525 assert_eq!(calls[2].release_id, 3);
526 }
527
528 #[test]
529 fn test_mock_default_is_same_as_new() {
530 let mock = MockGitHubClient::default();
531 assert_eq!(mock.create_release_call_count(), 0);
532 assert_eq!(mock.upload_asset_call_count(), 0);
533 assert_eq!(mock.list_releases_call_count(), 0);
534 assert_eq!(mock.delete_release_call_count(), 0);
535 }
536}