Skip to main content

anodizer_core/
github_client.rs

1//! GitHub client trait and mock implementation.
2//!
3//! Defines the [`GitHubClient`] trait that abstracts GitHub API operations
4//! needed by the release stage. The real octocrab-based implementation lives
5//! in `crates/stage-release`; this module provides only the trait definition
6//! and a [`MockGitHubClient`] for testing.
7//!
8//! # Usage
9//!
10//! The mock client records every call and returns configurable responses:
11//!
12//! ```rust,ignore
13//! use anodizer_core::github_client::{MockGitHubClient, ReleaseInfo, GitHubClient};
14//!
15//! let mock = MockGitHubClient::new();
16//! mock.set_create_release_response(Ok(ReleaseInfo {
17//!     id: 42,
18//!     html_url: "https://github.com/owner/repo/releases/42".to_string(),
19//!     tag_name: "v1.0.0".to_string(),
20//!     name: Some("Release v1.0.0".to_string()),
21//!     draft: false,
22//! }));
23//!
24//! let result = mock.create_release(&params)?;
25//! assert_eq!(mock.create_release_calls(), 1);
26//! # Ok::<(), anyhow::Error>(())
27//! ```
28
29use std::path::PathBuf;
30
31#[cfg(feature = "test-helpers")]
32use std::sync::Mutex;
33
34// ---------------------------------------------------------------------------
35// Data types
36// ---------------------------------------------------------------------------
37
38/// Minimal release metadata returned by GitHub API operations.
39#[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/// Parameters for creating a GitHub release.
49#[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/// Parameters for uploading a release asset.
63#[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/// Minimal asset metadata returned by upload operations.
73#[derive(Debug, Clone)]
74pub struct AssetInfo {
75    pub id: u64,
76    pub name: String,
77    pub size: u64,
78}
79
80/// Parameters for listing releases.
81#[derive(Debug, Clone)]
82pub struct ListReleasesParams {
83    pub owner: String,
84    pub repo: String,
85}
86
87/// Parameters for deleting a release.
88#[derive(Debug, Clone)]
89pub struct DeleteReleaseParams {
90    pub owner: String,
91    pub repo: String,
92    pub release_id: u64,
93}
94
95// ---------------------------------------------------------------------------
96// Trait
97// ---------------------------------------------------------------------------
98
99/// Abstraction over GitHub API operations used by the release stage.
100///
101/// Implementations:
102/// - Real: wraps octocrab (lives in `crates/stage-release`)
103/// - Mock: [`MockGitHubClient`] for tests (records calls, configurable responses)
104pub trait GitHubClient {
105    /// Create a new GitHub release.
106    fn create_release(&self, params: &CreateReleaseParams) -> anyhow::Result<ReleaseInfo>;
107
108    /// Upload an asset to an existing release.
109    fn upload_asset(&self, params: &UploadAssetParams) -> anyhow::Result<AssetInfo>;
110
111    /// List all releases for a repository.
112    fn list_releases(&self, params: &ListReleasesParams) -> anyhow::Result<Vec<ReleaseInfo>>;
113
114    /// Delete a release by ID.
115    fn delete_release(&self, params: &DeleteReleaseParams) -> anyhow::Result<()>;
116}
117
118// ---------------------------------------------------------------------------
119// MockGitHubClient (test-only)
120// ---------------------------------------------------------------------------
121
122/// A mock GitHub client that records calls and returns configurable responses.
123///
124/// All response setters use interior mutability (Mutex) so the mock can be
125/// shared across test code without requiring `&mut`.
126///
127/// Only available when the `test-helpers` feature is enabled.
128#[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    /// Create a new mock with no pre-configured responses.
144    ///
145    /// By default, all operations will return an error saying
146    /// "no mock response configured". Use the `set_*_response` methods
147    /// to configure what each operation returns.
148    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    // -- Response setters --
162
163    /// Configure the response for `create_release` calls.
164    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    /// Configure the response for `upload_asset` calls.
172    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    /// Configure the response for `list_releases` calls.
180    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    /// Configure the response for `delete_release` calls.
188    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    // -- Call counters / accessors --
196
197    /// Number of times `create_release` was called.
198    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    /// Number of times `upload_asset` was called.
206    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    /// Number of times `list_releases` was called.
214    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    /// Number of times `delete_release` was called.
222    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    /// Get a clone of all recorded `create_release` call parameters.
230    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    /// Get a clone of all recorded `upload_asset` call parameters.
238    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    /// Get a clone of all recorded `list_releases` call parameters.
246    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    /// Get a clone of all recorded `delete_release` call parameters.
254    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// ---------------------------------------------------------------------------
353// Tests
354// ---------------------------------------------------------------------------
355
356#[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(&params).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(&params).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(&params).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(&params).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(&params);
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(&params);
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(&params).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}