use chrono::{DateTime, Utc};
use reqwest::header::HeaderMap;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[expect(clippy::struct_field_names, reason = "field names match GitHub API exactly")]
pub struct Repository {
#[serde(alias = "stars_count")]
pub stargazers_count: Option<u32>,
pub forks_count: Option<u32>,
#[serde(default)]
pub subscribers_count: Option<i64>,
#[serde(default)]
pub watchers_count: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct Issue {
pub created_at: DateTime<Utc>,
pub closed_at: Option<DateTime<Utc>>,
pub state: IssueState,
pub pull_request: Option<PullRequestMarker>,
}
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum IssueState {
Open,
Closed,
}
#[derive(Debug, Deserialize)]
#[expect(
clippy::empty_structs_with_brackets,
reason = "empty braces needed for serde to deserialize PR objects"
)]
pub struct PullRequestMarker {}
#[derive(Debug, Clone, Copy)]
pub struct RateLimitInfo {
pub remaining: usize,
pub reset_at: DateTime<Utc>,
}
pub enum HostingApiResult<T> {
Success(T, Option<RateLimitInfo>),
RateLimited(RateLimitInfo),
Failed(ohno::AppError, Option<RateLimitInfo>),
}
#[derive(Debug, Clone)]
#[expect(clippy::struct_field_names, reason = "client field stores the underlying HTTP client")]
pub struct Client {
client: reqwest::Client,
base_url: String,
now: DateTime<Utc>,
}
impl Client {
pub fn new(token: Option<&str>, base_url: impl Into<String>, now: DateTime<Utc>) -> crate::Result<Self> {
use reqwest::header::{AUTHORIZATION, HeaderValue};
let mut client_builder = reqwest::Client::builder().user_agent("cargo-aprz");
if let Some(t) = token {
let mut auth_val = HeaderValue::from_str(&format!("token {t}"))?;
auth_val.set_sensitive(true);
let mut headers = HeaderMap::new();
let _ = headers.insert(AUTHORIZATION, auth_val);
client_builder = client_builder.default_headers(headers);
}
Ok(Self {
client: client_builder.build()?,
base_url: base_url.into(),
now,
})
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn api_call(&self, url: &str) -> HostingApiResult<reqwest::Response> {
let resp = match self.client.get(url).send().await {
Ok(r) => r,
Err(e) => return HostingApiResult::Failed(e.into(), None),
};
let rate_limit = extract_rate_limit_from_headers(resp.headers());
let status = resp.status();
if status.is_success() {
return HostingApiResult::Success(resp, rate_limit);
}
let status_code = status.as_u16();
if matches!(status_code, 403 | 429) {
let rate_limit = rate_limit.unwrap_or_else(|| RateLimitInfo {
remaining: 0,
reset_at: self.now + chrono::Duration::hours(1),
});
return HostingApiResult::RateLimited(rate_limit);
}
let error = resp.error_for_status().expect_err("status is not successful at this point");
HostingApiResult::Failed(error.into(), rate_limit)
}
}
fn extract_rate_limit_from_headers(headers: &HeaderMap) -> Option<RateLimitInfo> {
let remaining = headers.get("x-ratelimit-remaining")?.to_str().ok()?.parse::<usize>().ok()?;
let reset_timestamp = headers.get("x-ratelimit-reset")?.to_str().ok()?.parse::<i64>().ok()?;
let reset_at = DateTime::from_timestamp(reset_timestamp, 0)?;
Some(RateLimitInfo { remaining, reset_at })
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::HeaderValue;
#[test]
fn test_repository_deserialize_github() {
let json = r#"{
"stargazers_count": 1000,
"forks_count": 200,
"subscribers_count": 50
}"#;
let repo: Repository = serde_json::from_str(json).unwrap();
assert_eq!(repo.stargazers_count, Some(1000));
assert_eq!(repo.forks_count, Some(200));
assert_eq!(repo.subscribers_count, Some(50));
}
#[test]
fn test_repository_deserialize_codeberg() {
let json = r#"{
"stars_count": 500,
"forks_count": 100,
"watchers_count": 25
}"#;
let repo: Repository = serde_json::from_str(json).unwrap();
assert_eq!(repo.stargazers_count, Some(500)); assert_eq!(repo.forks_count, Some(100));
assert_eq!(repo.watchers_count, Some(25));
}
#[test]
fn test_repository_deserialize_optional_fields() {
let json = r#"{
"stargazers_count": 1000
}"#;
let repo: Repository = serde_json::from_str(json).unwrap();
assert_eq!(repo.stargazers_count, Some(1000));
assert_eq!(repo.forks_count, None);
assert_eq!(repo.subscribers_count, None);
assert_eq!(repo.watchers_count, None);
}
#[test]
fn test_issue_deserialize() {
let json = r#"{
"created_at": "2024-01-01T00:00:00Z",
"closed_at": "2024-01-02T00:00:00Z",
"state": "closed"
}"#;
let issue: Issue = serde_json::from_str(json).unwrap();
assert_eq!(issue.state, IssueState::Closed);
assert!(issue.closed_at.is_some());
assert!(issue.pull_request.is_none());
}
#[test]
fn test_issue_deserialize_with_pull_request() {
let json = r#"{
"created_at": "2024-01-01T00:00:00Z",
"closed_at": null,
"state": "open",
"pull_request": {
"url": "https://api.github.com/repos/owner/repo/pulls/1"
}
}"#;
let issue: Issue = serde_json::from_str(json).unwrap();
assert_eq!(issue.state, IssueState::Open);
assert!(issue.closed_at.is_none());
assert!(issue.pull_request.is_some());
}
#[test]
fn test_issue_state_open() {
let json = r#""open""#;
let state: IssueState = serde_json::from_str(json).unwrap();
assert_eq!(state, IssueState::Open);
}
#[test]
fn test_issue_state_closed() {
let json = r#""closed""#;
let state: IssueState = serde_json::from_str(json).unwrap();
assert_eq!(state, IssueState::Closed);
}
#[test]
fn test_pull_request_marker_deserialize() {
let json = r#"{
"url": "https://api.github.com/repos/owner/repo/pulls/1",
"html_url": "https://github.com/owner/repo/pull/1"
}"#;
let _marker: PullRequestMarker = serde_json::from_str(json).unwrap();
}
#[test]
fn test_rate_limit_info_copy() {
let info1 = RateLimitInfo {
remaining: 5000,
reset_at: DateTime::from_timestamp(1_234_567_890, 0).unwrap(),
};
let info2 = info1;
assert_eq!(info1.remaining, 5000);
assert_eq!(info2.remaining, 5000);
}
#[test]
fn test_extract_rate_limit_from_headers() {
let mut headers = HeaderMap::new();
let _ = headers.insert("x-ratelimit-remaining", HeaderValue::from_static("4999"));
let _ = headers.insert("x-ratelimit-reset", HeaderValue::from_static("1704067200"));
let rate_limit = extract_rate_limit_from_headers(&headers).unwrap();
assert_eq!(rate_limit.remaining, 4999);
assert_eq!(rate_limit.reset_at.timestamp(), 1_704_067_200);
}
#[test]
fn test_extract_rate_limit_missing_headers() {
let headers = HeaderMap::new();
let rate_limit = extract_rate_limit_from_headers(&headers);
assert!(rate_limit.is_none());
}
#[test]
fn test_extract_rate_limit_invalid_remaining() {
let mut headers = HeaderMap::new();
let _ = headers.insert("x-ratelimit-remaining", HeaderValue::from_static("invalid"));
let _ = headers.insert("x-ratelimit-reset", HeaderValue::from_static("1704067200"));
let rate_limit = extract_rate_limit_from_headers(&headers);
assert!(rate_limit.is_none());
}
#[test]
fn test_extract_rate_limit_invalid_reset() {
let mut headers = HeaderMap::new();
let _ = headers.insert("x-ratelimit-remaining", HeaderValue::from_static("4999"));
let _ = headers.insert("x-ratelimit-reset", HeaderValue::from_static("invalid"));
let rate_limit = extract_rate_limit_from_headers(&headers);
assert!(rate_limit.is_none());
}
#[test]
fn test_client_new_without_token() {
let client = Client::new(None, "https://api.github.com", Utc::now()).unwrap();
assert_eq!(client.base_url(), "https://api.github.com");
}
#[test]
fn test_client_new_with_token() {
let client = Client::new(Some("test_token"), "https://api.github.com", Utc::now()).unwrap();
assert_eq!(client.base_url(), "https://api.github.com");
}
#[test]
fn test_client_base_url() {
let client = Client::new(None, "https://codeberg.org/api/v1", Utc::now()).unwrap();
assert_eq!(client.base_url(), "https://codeberg.org/api/v1");
}
#[test]
fn test_hosting_api_result_success() {
let rate_limit = Some(RateLimitInfo {
remaining: 5000,
reset_at: DateTime::from_timestamp(1_234_567_890, 0).unwrap(),
});
match rate_limit {
Some(info) => assert_eq!(info.remaining, 5000),
None => panic!("Expected Some"),
}
}
#[test]
fn test_rate_limit_info_fields() {
let reset_time = DateTime::from_timestamp(1_704_067_200, 0).unwrap();
let info = RateLimitInfo {
remaining: 100,
reset_at: reset_time,
};
assert_eq!(info.remaining, 100);
assert_eq!(info.reset_at.timestamp(), 1_704_067_200);
}
}