use std::path::Path;
use async_trait::async_trait;
#[derive(Debug, Clone)]
pub struct PullRequest {
pub number: u64,
pub html_url: String,
}
#[async_trait]
pub trait CodeForgeClient: Send + Sync + std::fmt::Debug {
async fn create_release(
&self,
tag_name: &str,
name: &str,
body: &str,
) -> anyhow::Result<String>;
async fn upload_asset(
&self,
release_id: &str,
file_name: &str,
file_path: &Path,
) -> anyhow::Result<()>;
async fn create_pull_request(
&self,
title: &str,
body: &str,
head: &str,
base: &str,
) -> anyhow::Result<String>;
async fn find_open_pull_request(&self, head: &str) -> anyhow::Result<Option<PullRequest>>;
async fn update_pull_request(
&self,
pull_number: u64,
title: &str,
body: &str,
) -> anyhow::Result<String>;
async fn publish_release(&self, release_id: &str) -> anyhow::Result<()>;
}
#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
use std::path::Path;
use std::sync::Mutex;
use anyhow::bail;
use async_trait::async_trait;
use super::{CodeForgeClient, PullRequest};
#[derive(Debug, Clone)]
pub enum CodeForgeInvocation {
CreateRelease {
tag_name: String,
name: String,
body: String,
},
UploadAsset {
release_id: String,
file_name: String,
file_path: std::path::PathBuf,
},
CreatePullRequest {
title: String,
body: String,
head: String,
base: String,
},
FindOpenPullRequest {
head: String,
},
UpdatePullRequest {
pull_number: u64,
title: String,
body: String,
},
PublishRelease {
release_id: String,
},
}
#[derive(Debug)]
pub struct RecordingCodeForgeClient {
invocations: Mutex<Vec<CodeForgeInvocation>>,
release_id: String,
fail_create: bool,
fail_upload: bool,
fail_create_pr: bool,
existing_pr: Option<PullRequest>,
fail_find_pr: bool,
fail_update_pr: bool,
fail_publish_release: bool,
}
impl RecordingCodeForgeClient {
pub fn new() -> Self {
Self {
invocations: Mutex::new(Vec::new()),
release_id: "release-1".to_string(),
fail_create: false,
fail_upload: false,
fail_create_pr: false,
existing_pr: None,
fail_find_pr: false,
fail_update_pr: false,
fail_publish_release: false,
}
}
pub fn with_release_id(mut self, id: impl Into<String>) -> Self {
self.release_id = id.into();
self
}
pub fn with_create_failure(mut self) -> Self {
self.fail_create = true;
self
}
pub fn with_upload_failure(mut self) -> Self {
self.fail_upload = true;
self
}
pub fn with_create_pr_failure(mut self) -> Self {
self.fail_create_pr = true;
self
}
pub fn with_existing_pr(mut self, pr: PullRequest) -> Self {
self.existing_pr = Some(pr);
self
}
pub fn with_find_pr_failure(mut self) -> Self {
self.fail_find_pr = true;
self
}
pub fn with_update_pr_failure(mut self) -> Self {
self.fail_update_pr = true;
self
}
pub fn with_publish_release_failure(mut self) -> Self {
self.fail_publish_release = true;
self
}
pub fn invocations(&self) -> Vec<CodeForgeInvocation> {
self.invocations.lock().expect("mutex poisoned").clone()
}
}
impl Default for RecordingCodeForgeClient {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl CodeForgeClient for RecordingCodeForgeClient {
async fn create_release(
&self,
tag_name: &str,
name: &str,
body: &str,
) -> anyhow::Result<String> {
self.invocations.lock().expect("mutex poisoned").push(
CodeForgeInvocation::CreateRelease {
tag_name: tag_name.to_string(),
name: name.to_string(),
body: body.to_string(),
},
);
if self.fail_create {
bail!("simulated create_release failure");
}
Ok(self.release_id.clone())
}
async fn upload_asset(
&self,
release_id: &str,
file_name: &str,
file_path: &Path,
) -> anyhow::Result<()> {
self.invocations.lock().expect("mutex poisoned").push(
CodeForgeInvocation::UploadAsset {
release_id: release_id.to_string(),
file_name: file_name.to_string(),
file_path: file_path.to_path_buf(),
},
);
if self.fail_upload {
bail!("simulated upload_asset failure");
}
Ok(())
}
async fn create_pull_request(
&self,
title: &str,
body: &str,
head: &str,
base: &str,
) -> anyhow::Result<String> {
self.invocations.lock().expect("mutex poisoned").push(
CodeForgeInvocation::CreatePullRequest {
title: title.to_string(),
body: body.to_string(),
head: head.to_string(),
base: base.to_string(),
},
);
if self.fail_create_pr {
bail!("simulated create_pull_request failure");
}
Ok("https://example.com/pull/1".to_string())
}
async fn find_open_pull_request(&self, head: &str) -> anyhow::Result<Option<PullRequest>> {
self.invocations.lock().expect("mutex poisoned").push(
CodeForgeInvocation::FindOpenPullRequest {
head: head.to_string(),
},
);
if self.fail_find_pr {
bail!("simulated find_open_pull_request failure");
}
Ok(self.existing_pr.clone())
}
async fn update_pull_request(
&self,
pull_number: u64,
title: &str,
body: &str,
) -> anyhow::Result<String> {
self.invocations.lock().expect("mutex poisoned").push(
CodeForgeInvocation::UpdatePullRequest {
pull_number,
title: title.to_string(),
body: body.to_string(),
},
);
if self.fail_update_pr {
bail!("simulated update_pull_request failure");
}
Ok(format!("https://example.com/pull/{pull_number}"))
}
async fn publish_release(&self, release_id: &str) -> anyhow::Result<()> {
self.invocations.lock().expect("mutex poisoned").push(
CodeForgeInvocation::PublishRelease {
release_id: release_id.to_string(),
},
);
if self.fail_publish_release {
bail!("simulated publish_release failure");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[tokio::test]
async fn recording_client_records_create_release() {
let client = RecordingCodeForgeClient::new().with_release_id("r-42");
let id = client
.create_release("v1.0.0", "Release 1.0.0", "body text")
.await
.unwrap();
assert_eq!(id, "r-42");
let invocations = client.invocations();
assert_eq!(invocations.len(), 1);
assert!(matches!(
&invocations[0],
CodeForgeInvocation::CreateRelease { tag_name, .. } if tag_name == "v1.0.0"
));
}
#[tokio::test]
async fn recording_client_records_upload_asset() {
let client = RecordingCodeForgeClient::new();
let path = PathBuf::from("/tmp/app.tar.gz");
client
.upload_asset("r-1", "app.tar.gz", &path)
.await
.unwrap();
let invocations = client.invocations();
assert_eq!(invocations.len(), 1);
assert!(matches!(
&invocations[0],
CodeForgeInvocation::UploadAsset { file_name, .. } if file_name == "app.tar.gz"
));
}
#[tokio::test]
async fn recording_client_create_failure_returns_error() {
let client = RecordingCodeForgeClient::new().with_create_failure();
let result = client.create_release("v1.0.0", "Release", "body").await;
assert!(result.is_err());
assert_eq!(client.invocations().len(), 1);
}
#[tokio::test]
async fn recording_client_upload_failure_returns_error() {
let client = RecordingCodeForgeClient::new().with_upload_failure();
let result = client
.upload_asset("r-1", "file.tar.gz", Path::new("/tmp/file.tar.gz"))
.await;
assert!(result.is_err());
assert_eq!(client.invocations().len(), 1);
}
#[tokio::test]
async fn recording_client_records_create_pull_request() {
let client = RecordingCodeForgeClient::new();
let url = client
.create_pull_request(
"Release updates",
"Release:\n\n- my-pkg@1.0.0",
"cursus-release/main",
"main",
)
.await
.unwrap();
assert!(url.contains("pull/1"), "URL should contain pull/1: {url}");
let invocations = client.invocations();
assert_eq!(invocations.len(), 1);
assert!(matches!(
&invocations[0],
CodeForgeInvocation::CreatePullRequest { title, head, base, .. }
if title == "Release updates" && head == "cursus-release/main" && base == "main"
));
}
#[tokio::test]
async fn recording_client_create_pr_failure_returns_error() {
let client = RecordingCodeForgeClient::new().with_create_pr_failure();
let result = client
.create_pull_request("Release", "body", "release-branch", "main")
.await;
assert!(result.is_err());
assert_eq!(client.invocations().len(), 1);
}
#[tokio::test]
async fn recording_client_find_open_pr_returns_none_when_not_configured() {
let client = RecordingCodeForgeClient::new();
let result = client
.find_open_pull_request("cursus-release/main")
.await
.unwrap();
assert!(result.is_none());
let invocations = client.invocations();
assert_eq!(invocations.len(), 1);
assert!(matches!(
&invocations[0],
CodeForgeInvocation::FindOpenPullRequest { head, .. }
if head == "cursus-release/main"
));
}
#[tokio::test]
async fn recording_client_find_open_pr_returns_configured_pr() {
let pr = PullRequest {
number: 42,
html_url: "https://github.com/acme/app/pull/42".to_string(),
};
let client = RecordingCodeForgeClient::new().with_existing_pr(pr);
let result = client
.find_open_pull_request("cursus-release/main")
.await
.unwrap();
assert!(result.is_some());
let found = result.unwrap();
assert_eq!(found.number, 42);
assert!(found.html_url.contains("pull/42"));
}
#[tokio::test]
async fn recording_client_find_pr_failure_returns_error() {
let client = RecordingCodeForgeClient::new().with_find_pr_failure();
let result = client.find_open_pull_request("release-branch").await;
assert!(result.is_err());
assert_eq!(client.invocations().len(), 1);
}
#[tokio::test]
async fn recording_client_update_pull_request_records_invocation() {
let client = RecordingCodeForgeClient::new();
let url = client
.update_pull_request(42, "Updated Title", "Updated body")
.await
.unwrap();
assert!(url.contains("pull/42"), "URL should contain pull/42: {url}");
let invocations = client.invocations();
assert_eq!(invocations.len(), 1);
assert!(matches!(
&invocations[0],
CodeForgeInvocation::UpdatePullRequest { pull_number, title, .. }
if *pull_number == 42 && title == "Updated Title"
));
}
#[tokio::test]
async fn recording_client_update_pr_failure_returns_error() {
let client = RecordingCodeForgeClient::new().with_update_pr_failure();
let result = client.update_pull_request(1, "Title", "body").await;
assert!(result.is_err());
assert_eq!(client.invocations().len(), 1);
}
#[tokio::test]
async fn recording_client_records_publish_release() {
let client = RecordingCodeForgeClient::new();
client.publish_release("release-1").await.unwrap();
let invocations = client.invocations();
assert_eq!(invocations.len(), 1);
assert!(matches!(
&invocations[0],
CodeForgeInvocation::PublishRelease { release_id, .. } if release_id == "release-1"
));
}
#[tokio::test]
async fn recording_client_publish_release_failure_returns_error() {
let client = RecordingCodeForgeClient::new().with_publish_release_failure();
let result = client.publish_release("release-1").await;
assert!(result.is_err());
assert_eq!(client.invocations().len(), 1);
}
}
}