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
95#[derive(Debug, Clone)]
104pub struct GetReleaseByTagParams {
105 pub owner: String,
106 pub repo: String,
107 pub tag: String,
108}
109
110#[derive(Debug, Clone)]
118pub struct DeleteTagParams {
119 pub owner: String,
120 pub repo: String,
121 pub tag: String,
122}
123
124pub trait GitHubClient {
134 fn create_release(&self, params: &CreateReleaseParams) -> anyhow::Result<ReleaseInfo>;
136
137 fn upload_asset(&self, params: &UploadAssetParams) -> anyhow::Result<AssetInfo>;
139
140 fn list_releases(&self, params: &ListReleasesParams) -> anyhow::Result<Vec<ReleaseInfo>>;
142
143 fn delete_release(&self, params: &DeleteReleaseParams) -> anyhow::Result<()>;
145
146 fn get_release_by_tag(
152 &self,
153 params: &GetReleaseByTagParams,
154 ) -> anyhow::Result<Option<ReleaseInfo>>;
155
156 fn delete_tag(&self, params: &DeleteTagParams) -> anyhow::Result<()>;
164}
165
166#[cfg(feature = "test-helpers")]
177pub struct MockGitHubClient {
178 create_release_calls: Mutex<Vec<CreateReleaseParams>>,
179 upload_asset_calls: Mutex<Vec<UploadAssetParams>>,
180 list_releases_calls: Mutex<Vec<ListReleasesParams>>,
181 delete_release_calls: Mutex<Vec<DeleteReleaseParams>>,
182 get_release_by_tag_calls: Mutex<Vec<GetReleaseByTagParams>>,
183 delete_tag_calls: Mutex<Vec<DeleteTagParams>>,
184
185 create_release_response: Mutex<Option<Result<ReleaseInfo, String>>>,
186 upload_asset_response: Mutex<Option<Result<AssetInfo, String>>>,
187 list_releases_response: Mutex<Option<Result<Vec<ReleaseInfo>, String>>>,
188 delete_release_response: Mutex<Option<Result<(), String>>>,
189 get_release_by_tag_response: Mutex<Option<Result<Option<ReleaseInfo>, String>>>,
190 delete_tag_response: Mutex<Option<Result<(), String>>>,
191}
192
193#[cfg(feature = "test-helpers")]
194impl MockGitHubClient {
195 pub fn new() -> Self {
201 Self {
202 create_release_calls: Mutex::new(Vec::new()),
203 upload_asset_calls: Mutex::new(Vec::new()),
204 list_releases_calls: Mutex::new(Vec::new()),
205 delete_release_calls: Mutex::new(Vec::new()),
206 get_release_by_tag_calls: Mutex::new(Vec::new()),
207 delete_tag_calls: Mutex::new(Vec::new()),
208 create_release_response: Mutex::new(None),
209 upload_asset_response: Mutex::new(None),
210 list_releases_response: Mutex::new(None),
211 delete_release_response: Mutex::new(None),
212 get_release_by_tag_response: Mutex::new(None),
213 delete_tag_response: Mutex::new(None),
214 }
215 }
216
217 pub fn set_create_release_response(&self, response: Result<ReleaseInfo, String>) {
221 *self
222 .create_release_response
223 .lock()
224 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
225 }
226
227 pub fn set_upload_asset_response(&self, response: Result<AssetInfo, String>) {
229 *self
230 .upload_asset_response
231 .lock()
232 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
233 }
234
235 pub fn set_list_releases_response(&self, response: Result<Vec<ReleaseInfo>, String>) {
237 *self
238 .list_releases_response
239 .lock()
240 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
241 }
242
243 pub fn set_delete_release_response(&self, response: Result<(), String>) {
245 *self
246 .delete_release_response
247 .lock()
248 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
249 }
250
251 pub fn set_get_release_by_tag_response(&self, response: Result<Option<ReleaseInfo>, String>) {
257 *self
258 .get_release_by_tag_response
259 .lock()
260 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
261 }
262
263 pub fn set_delete_tag_response(&self, response: Result<(), String>) {
265 *self
266 .delete_tag_response
267 .lock()
268 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(response);
269 }
270
271 pub fn create_release_call_count(&self) -> usize {
275 self.create_release_calls
276 .lock()
277 .unwrap_or_else(std::sync::PoisonError::into_inner)
278 .len()
279 }
280
281 pub fn upload_asset_call_count(&self) -> usize {
283 self.upload_asset_calls
284 .lock()
285 .unwrap_or_else(std::sync::PoisonError::into_inner)
286 .len()
287 }
288
289 pub fn list_releases_call_count(&self) -> usize {
291 self.list_releases_calls
292 .lock()
293 .unwrap_or_else(std::sync::PoisonError::into_inner)
294 .len()
295 }
296
297 pub fn delete_release_call_count(&self) -> usize {
299 self.delete_release_calls
300 .lock()
301 .unwrap_or_else(std::sync::PoisonError::into_inner)
302 .len()
303 }
304
305 pub fn create_release_calls(&self) -> Vec<CreateReleaseParams> {
307 self.create_release_calls
308 .lock()
309 .unwrap_or_else(std::sync::PoisonError::into_inner)
310 .clone()
311 }
312
313 pub fn upload_asset_calls(&self) -> Vec<UploadAssetParams> {
315 self.upload_asset_calls
316 .lock()
317 .unwrap_or_else(std::sync::PoisonError::into_inner)
318 .clone()
319 }
320
321 pub fn list_releases_calls(&self) -> Vec<ListReleasesParams> {
323 self.list_releases_calls
324 .lock()
325 .unwrap_or_else(std::sync::PoisonError::into_inner)
326 .clone()
327 }
328
329 pub fn delete_release_calls(&self) -> Vec<DeleteReleaseParams> {
331 self.delete_release_calls
332 .lock()
333 .unwrap_or_else(std::sync::PoisonError::into_inner)
334 .clone()
335 }
336
337 pub fn get_release_by_tag_call_count(&self) -> usize {
339 self.get_release_by_tag_calls
340 .lock()
341 .unwrap_or_else(std::sync::PoisonError::into_inner)
342 .len()
343 }
344
345 pub fn get_release_by_tag_calls(&self) -> Vec<GetReleaseByTagParams> {
347 self.get_release_by_tag_calls
348 .lock()
349 .unwrap_or_else(std::sync::PoisonError::into_inner)
350 .clone()
351 }
352
353 pub fn delete_tag_call_count(&self) -> usize {
355 self.delete_tag_calls
356 .lock()
357 .unwrap_or_else(std::sync::PoisonError::into_inner)
358 .len()
359 }
360
361 pub fn delete_tag_calls(&self) -> Vec<DeleteTagParams> {
363 self.delete_tag_calls
364 .lock()
365 .unwrap_or_else(std::sync::PoisonError::into_inner)
366 .clone()
367 }
368}
369
370#[cfg(feature = "test-helpers")]
371impl Default for MockGitHubClient {
372 fn default() -> Self {
373 Self::new()
374 }
375}
376
377#[cfg(feature = "test-helpers")]
378impl GitHubClient for MockGitHubClient {
379 fn create_release(&self, params: &CreateReleaseParams) -> anyhow::Result<ReleaseInfo> {
380 self.create_release_calls
381 .lock()
382 .unwrap_or_else(std::sync::PoisonError::into_inner)
383 .push(params.clone());
384
385 match self
386 .create_release_response
387 .lock()
388 .unwrap_or_else(std::sync::PoisonError::into_inner)
389 .as_ref()
390 {
391 Some(Ok(info)) => Ok(info.clone()),
392 Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
393 None => Err(anyhow::anyhow!(
394 "MockGitHubClient: no create_release response configured"
395 )),
396 }
397 }
398
399 fn upload_asset(&self, params: &UploadAssetParams) -> anyhow::Result<AssetInfo> {
400 self.upload_asset_calls
401 .lock()
402 .unwrap_or_else(std::sync::PoisonError::into_inner)
403 .push(params.clone());
404
405 match self
406 .upload_asset_response
407 .lock()
408 .unwrap_or_else(std::sync::PoisonError::into_inner)
409 .as_ref()
410 {
411 Some(Ok(info)) => Ok(info.clone()),
412 Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
413 None => Err(anyhow::anyhow!(
414 "MockGitHubClient: no upload_asset response configured"
415 )),
416 }
417 }
418
419 fn list_releases(&self, params: &ListReleasesParams) -> anyhow::Result<Vec<ReleaseInfo>> {
420 self.list_releases_calls
421 .lock()
422 .unwrap_or_else(std::sync::PoisonError::into_inner)
423 .push(params.clone());
424
425 match self
426 .list_releases_response
427 .lock()
428 .unwrap_or_else(std::sync::PoisonError::into_inner)
429 .as_ref()
430 {
431 Some(Ok(releases)) => Ok(releases.clone()),
432 Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
433 None => Err(anyhow::anyhow!(
434 "MockGitHubClient: no list_releases response configured"
435 )),
436 }
437 }
438
439 fn delete_release(&self, params: &DeleteReleaseParams) -> anyhow::Result<()> {
440 self.delete_release_calls
441 .lock()
442 .unwrap_or_else(std::sync::PoisonError::into_inner)
443 .push(params.clone());
444
445 match self
446 .delete_release_response
447 .lock()
448 .unwrap_or_else(std::sync::PoisonError::into_inner)
449 .as_ref()
450 {
451 Some(Ok(())) => Ok(()),
452 Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
453 None => Err(anyhow::anyhow!(
454 "MockGitHubClient: no delete_release response configured"
455 )),
456 }
457 }
458
459 fn get_release_by_tag(
460 &self,
461 params: &GetReleaseByTagParams,
462 ) -> anyhow::Result<Option<ReleaseInfo>> {
463 self.get_release_by_tag_calls
464 .lock()
465 .unwrap_or_else(std::sync::PoisonError::into_inner)
466 .push(params.clone());
467
468 match self
469 .get_release_by_tag_response
470 .lock()
471 .unwrap_or_else(std::sync::PoisonError::into_inner)
472 .as_ref()
473 {
474 Some(Ok(info)) => Ok(info.clone()),
475 Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
476 None => Err(anyhow::anyhow!(
477 "MockGitHubClient: no get_release_by_tag response configured"
478 )),
479 }
480 }
481
482 fn delete_tag(&self, params: &DeleteTagParams) -> anyhow::Result<()> {
483 self.delete_tag_calls
484 .lock()
485 .unwrap_or_else(std::sync::PoisonError::into_inner)
486 .push(params.clone());
487
488 match self
489 .delete_tag_response
490 .lock()
491 .unwrap_or_else(std::sync::PoisonError::into_inner)
492 .as_ref()
493 {
494 Some(Ok(())) => Ok(()),
495 Some(Err(msg)) => Err(anyhow::anyhow!("{}", msg)),
496 None => Err(anyhow::anyhow!(
497 "MockGitHubClient: no delete_tag response configured"
498 )),
499 }
500 }
501}
502
503#[cfg(all(test, feature = "test-helpers"))]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn test_mock_records_create_release_calls() {
513 let mock = MockGitHubClient::new();
514 mock.set_create_release_response(Ok(ReleaseInfo {
515 id: 42,
516 html_url: "https://github.com/owner/repo/releases/42".to_string(),
517 tag_name: "v1.0.0".to_string(),
518 name: Some("Release v1.0.0".to_string()),
519 draft: false,
520 }));
521
522 let params = CreateReleaseParams {
523 owner: "owner".to_string(),
524 repo: "repo".to_string(),
525 tag_name: "v1.0.0".to_string(),
526 name: "Release v1.0.0".to_string(),
527 body: "Changelog here".to_string(),
528 draft: false,
529 prerelease: false,
530 generate_release_notes: false,
531 make_latest: None,
532 };
533
534 let result = mock.create_release(¶ms).unwrap();
535 assert_eq!(result.id, 42);
536 assert_eq!(result.tag_name, "v1.0.0");
537 assert_eq!(mock.create_release_call_count(), 1);
538
539 let calls = mock.create_release_calls();
540 assert_eq!(calls[0].owner, "owner");
541 assert_eq!(calls[0].tag_name, "v1.0.0");
542 }
543
544 #[test]
545 fn test_mock_records_upload_asset_calls() {
546 let mock = MockGitHubClient::new();
547 mock.set_upload_asset_response(Ok(AssetInfo {
548 id: 100,
549 name: "myapp-linux-amd64.tar.gz".to_string(),
550 size: 4096,
551 }));
552
553 let params = UploadAssetParams {
554 owner: "owner".to_string(),
555 repo: "repo".to_string(),
556 release_id: 42,
557 file_name: "myapp-linux-amd64.tar.gz".to_string(),
558 file_path: PathBuf::from("/tmp/myapp-linux-amd64.tar.gz"),
559 };
560
561 let result = mock.upload_asset(¶ms).unwrap();
562 assert_eq!(result.name, "myapp-linux-amd64.tar.gz");
563 assert_eq!(mock.upload_asset_call_count(), 1);
564 }
565
566 #[test]
567 fn test_mock_records_list_releases_calls() {
568 let mock = MockGitHubClient::new();
569 mock.set_list_releases_response(Ok(vec![ReleaseInfo {
570 id: 1,
571 html_url: "https://github.com/owner/repo/releases/1".to_string(),
572 tag_name: "v0.9.0".to_string(),
573 name: Some("Release v0.9.0".to_string()),
574 draft: false,
575 }]));
576
577 let params = ListReleasesParams {
578 owner: "owner".to_string(),
579 repo: "repo".to_string(),
580 };
581
582 let result = mock.list_releases(¶ms).unwrap();
583 assert_eq!(result.len(), 1);
584 assert_eq!(mock.list_releases_call_count(), 1);
585 }
586
587 #[test]
588 fn test_mock_records_delete_release_calls() {
589 let mock = MockGitHubClient::new();
590 mock.set_delete_release_response(Ok(()));
591
592 let params = DeleteReleaseParams {
593 owner: "owner".to_string(),
594 repo: "repo".to_string(),
595 release_id: 42,
596 };
597
598 mock.delete_release(¶ms).unwrap();
599 assert_eq!(mock.delete_release_call_count(), 1);
600
601 let calls = mock.delete_release_calls();
602 assert_eq!(calls[0].release_id, 42);
603 }
604
605 #[test]
606 fn test_mock_returns_error_when_no_response_configured() {
607 let mock = MockGitHubClient::new();
608
609 let params = CreateReleaseParams {
610 owner: "owner".to_string(),
611 repo: "repo".to_string(),
612 tag_name: "v1.0.0".to_string(),
613 name: "Release".to_string(),
614 body: "".to_string(),
615 draft: false,
616 prerelease: false,
617 generate_release_notes: false,
618 make_latest: None,
619 };
620
621 let result = mock.create_release(¶ms);
622 assert!(result.is_err());
623 assert!(
624 result
625 .unwrap_err()
626 .to_string()
627 .contains("no create_release response configured")
628 );
629 }
630
631 #[test]
632 fn test_mock_returns_configured_error() {
633 let mock = MockGitHubClient::new();
634 mock.set_create_release_response(Err("API rate limit exceeded".to_string()));
635
636 let params = CreateReleaseParams {
637 owner: "owner".to_string(),
638 repo: "repo".to_string(),
639 tag_name: "v1.0.0".to_string(),
640 name: "Release".to_string(),
641 body: "".to_string(),
642 draft: false,
643 prerelease: false,
644 generate_release_notes: false,
645 make_latest: None,
646 };
647
648 let result = mock.create_release(¶ms);
649 assert!(result.is_err());
650 assert!(
651 result
652 .unwrap_err()
653 .to_string()
654 .contains("API rate limit exceeded")
655 );
656 }
657
658 #[test]
659 fn test_mock_multiple_calls_accumulate() {
660 let mock = MockGitHubClient::new();
661 mock.set_delete_release_response(Ok(()));
662
663 for i in 1..=3 {
664 let params = DeleteReleaseParams {
665 owner: "owner".to_string(),
666 repo: "repo".to_string(),
667 release_id: i,
668 };
669 mock.delete_release(¶ms).unwrap();
670 }
671
672 assert_eq!(mock.delete_release_call_count(), 3);
673 let calls = mock.delete_release_calls();
674 assert_eq!(calls[0].release_id, 1);
675 assert_eq!(calls[1].release_id, 2);
676 assert_eq!(calls[2].release_id, 3);
677 }
678
679 #[test]
680 fn test_mock_default_is_same_as_new() {
681 let mock = MockGitHubClient::default();
682 assert_eq!(mock.create_release_call_count(), 0);
683 assert_eq!(mock.upload_asset_call_count(), 0);
684 assert_eq!(mock.list_releases_call_count(), 0);
685 assert_eq!(mock.delete_release_call_count(), 0);
686 }
687}