use std::collections::HashMap;
use std::process::Command;
use std::sync::Mutex;
use std::sync::OnceLock;
use std::time::Duration;
use semver::Version;
use serde::Deserialize;
use thiserror::Error;
use ureq::Agent;
use super::version::parse_ref;
type SourceError = Box<dyn std::error::Error + Send + Sync + 'static>;
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum ApiError {
#[error("request to {url} timed out")]
Timeout {
url: String,
#[source]
source: SourceError,
},
#[error("could not reach {url}")]
ConnectFailed {
url: String,
#[source]
source: SourceError,
},
#[error("{url} not found (HTTP 404)")]
NotFound { url: String },
#[error("{url} returned HTTP {status}")]
HttpStatus { url: String, status: u16 },
#[error("failed to parse JSON response from {url}")]
Json {
url: String,
#[source]
source: serde_json::Error,
},
#[error("unexpected HTTP error for {url}")]
Other {
url: String,
#[source]
source: SourceError,
},
#[error("no tags found for repository")]
NoTagsFound,
#[error("no branches found for repository")]
NoBranchesFound,
}
fn classify_ureq(err: ureq::Error, url: &str) -> ApiError {
let url = url.to_string();
match err {
ureq::Error::StatusCode(404) => ApiError::NotFound { url },
ureq::Error::StatusCode(status) => ApiError::HttpStatus { url, status },
ureq::Error::Timeout(_) => ApiError::Timeout {
url,
source: Box::new(err),
},
ureq::Error::HostNotFound | ureq::Error::ConnectionFailed | ureq::Error::Io(_) => {
ApiError::ConnectFailed {
url,
source: Box::new(err),
}
}
_ => ApiError::Other {
url,
source: Box::new(err),
},
}
}
fn classify_body_read(err: ureq::Error, url: &str) -> ApiError {
let url = url.to_string();
match err {
ureq::Error::Timeout(_) => ApiError::Timeout {
url,
source: Box::new(err),
},
_ => ApiError::Other {
url,
source: Box::new(err),
},
}
}
#[derive(Clone, Default)]
pub(crate) struct Headers {
pub(crate) user_agent: Option<String>,
pub(crate) authorization: Option<String>,
}
impl Headers {
fn for_domain(domain: &str) -> Self {
let authorization = get_forge_token(domain).map(|token| {
tracing::debug!("Found token for {}", domain);
format!("Bearer {token}")
});
Self {
user_agent: Some("flake-edit".to_string()),
authorization,
}
}
}
pub(crate) enum ConditionalResponse {
NotModified,
Body { body: String, etag: Option<String> },
}
pub(crate) struct HttpClient {
agent: Agent,
}
impl Default for HttpClient {
fn default() -> Self {
let config = ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(10)))
.timeout_recv_response(Some(Duration::from_secs(30)))
.timeout_recv_body(Some(Duration::from_secs(30)))
.build();
Self {
agent: Agent::new_with_config(config),
}
}
}
impl HttpClient {
fn build(
&self,
url: &str,
headers: &Headers,
) -> ureq::RequestBuilder<ureq::typestate::WithoutBody> {
let mut request = self.agent.get(url);
if let Some(ref ua) = headers.user_agent {
request = request.header("User-Agent", ua);
}
if let Some(ref auth) = headers.authorization {
request = request.header("Authorization", auth);
}
request
}
pub(crate) fn get(&self, url: &str, headers: &Headers) -> Result<String, ApiError> {
let body = self
.build(url, headers)
.call()
.map_err(|e| classify_ureq(e, url))?
.body_mut()
.read_to_string()
.map_err(|e| classify_body_read(e, url))?;
Ok(body)
}
pub(crate) fn head_status(&self, url: &str, headers: &Headers) -> Result<bool, ApiError> {
match self.build(url, headers).call() {
Ok(_) => Ok(true),
Err(e) => match classify_ureq(e, url) {
ApiError::NotFound { .. } => Ok(false),
other => Err(other),
},
}
}
pub(crate) fn get_conditional(
&self,
url: &str,
headers: &Headers,
etag: Option<&str>,
) -> Result<ConditionalResponse, ApiError> {
let mut request = self.build(url, headers);
if let Some(etag) = etag {
request = request.header("If-None-Match", etag);
}
match request.call() {
Ok(mut response) => {
let new_etag = response
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(String::from);
let body = response
.body_mut()
.read_to_string()
.map_err(|e| classify_body_read(e, url))?;
Ok(ConditionalResponse::Body {
body,
etag: new_etag,
})
}
Err(ureq::Error::StatusCode(304)) => Ok(ConditionalResponse::NotModified),
Err(e) => Err(classify_ureq(e, url)),
}
}
pub(crate) fn post_json(
&self,
url: &str,
headers: &Headers,
body: &str,
) -> Result<String, ApiError> {
let mut request = self
.agent
.post(url)
.header("Content-Type", "application/json");
if let Some(ref ua) = headers.user_agent {
request = request.header("User-Agent", ua);
}
if let Some(ref auth) = headers.authorization {
request = request.header("Authorization", auth);
}
let response_body = request
.send(body)
.map_err(|e| classify_ureq(e, url))?
.body_mut()
.read_to_string()
.map_err(|e| classify_body_read(e, url))?;
Ok(response_body)
}
}
const MAX_PAGES: u32 = 20;
const PER_PAGE: usize = 100;
const GITEA_PER_PAGE: usize = 50;
fn parses_as_semver(name: &str) -> bool {
let parsed = parse_ref(name, false);
Version::parse(&parsed.normalized_for_semver).is_ok()
}
fn paginated<T, F>(per_page: usize, max_pages: u32, mut fetch: F) -> Result<Vec<T>, ApiError>
where
F: FnMut(u32) -> Result<Vec<T>, ApiError>,
{
let mut all = Vec::new();
let mut page: u32 = 1;
loop {
let items = fetch(page)?;
let count = items.len();
all.extend(items);
if count < per_page || page >= max_pages {
break;
}
page += 1;
}
Ok(all)
}
pub struct ForgeClient {
http: super::cache::HttpCache,
tags_cache: Mutex<HashMap<RepoKey, Tags>>,
branches_cache: Mutex<HashMap<RepoKey, Branches>>,
branch_exists_cache: Mutex<HashMap<BranchKey, bool>>,
github_graphql_enabled: bool,
}
#[derive(Debug, Clone)]
pub(crate) enum BatchLookup {
Tags { owner: String, repo: String },
ChannelCandidates {
owner: String,
repo: String,
prefix: String,
candidates: Vec<String>,
},
}
type RepoKey = (String, String, String);
type BranchKey = (String, String, String, String);
impl Default for ForgeClient {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for ForgeClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ForgeClient").finish_non_exhaustive()
}
}
impl ForgeClient {
pub fn new() -> Self {
Self {
http: super::cache::HttpCache::new(),
tags_cache: Mutex::new(HashMap::new()),
branches_cache: Mutex::new(HashMap::new()),
branch_exists_cache: Mutex::new(HashMap::new()),
github_graphql_enabled: get_forge_token("github.com").is_some(),
}
}
fn canonical_domain(domain: Option<&str>) -> String {
domain.unwrap_or("github.com").to_string()
}
pub fn list_tags(
&self,
owner: &str,
repo: &str,
domain: Option<&str>,
) -> Result<Tags, ApiError> {
let key = (
Self::canonical_domain(domain),
owner.to_string(),
repo.to_string(),
);
if let Some(hit) = self
.tags_cache
.lock()
.expect("forge tags cache poisoned")
.get(&key)
.cloned()
{
return Ok(hit);
}
let fresh = if key.0 == "github.com" {
self.fetch_github_tags(owner, repo)?
} else {
self.fetch_gitea_tags(&key.0, owner, repo)?
};
self.tags_cache
.lock()
.expect("forge tags cache poisoned")
.insert(key, fresh.clone());
Ok(fresh)
}
pub fn list_branches(
&self,
owner: &str,
repo: &str,
domain: Option<&str>,
) -> Result<Branches, ApiError> {
let key = (
Self::canonical_domain(domain),
owner.to_string(),
repo.to_string(),
);
if let Some(hit) = self
.branches_cache
.lock()
.expect("forge branches cache poisoned")
.get(&key)
.cloned()
{
return Ok(hit);
}
let fresh = if key.0 == "github.com" {
self.fetch_github_branches(owner, repo)?
} else {
self.fetch_gitea_branches(&key.0, owner, repo)?
};
self.branches_cache
.lock()
.expect("forge branches cache poisoned")
.insert(key, fresh.clone());
Ok(fresh)
}
pub fn branch_exists(
&self,
owner: &str,
repo: &str,
branch: &str,
domain: Option<&str>,
) -> Result<bool, ApiError> {
let key = (
Self::canonical_domain(domain),
owner.to_string(),
repo.to_string(),
branch.to_string(),
);
if let Some(&hit) = self
.branch_exists_cache
.lock()
.expect("forge branch_exists cache poisoned")
.get(&key)
{
return Ok(hit);
}
let fresh = if key.0 == "github.com" {
self.fetch_github_branch_exists(owner, repo, branch)?
} else {
self.fetch_gitea_branch_exists(&key.0, owner, repo, branch)?
};
self.branch_exists_cache
.lock()
.expect("forge branch_exists cache poisoned")
.insert(key, fresh);
Ok(fresh)
}
pub(crate) fn batch_warm_github(&self, lookups: &[BatchLookup]) -> Result<usize, ApiError> {
if !self.github_graphql_enabled || lookups.is_empty() {
return Ok(0);
}
let headers = Headers::for_domain("github.com");
let (query, aliases) = build_graphql_query(lookups);
let payload = serde_json::json!({ "query": query }).to_string();
let url = "https://api.github.com/graphql";
tracing::debug!(
"Batching {} github.com lookup(s) into one GraphQL POST",
aliases.len()
);
let body = self.http.post_json(url, &headers, &payload)?;
let parsed: GraphQlResponse =
serde_json::from_str(&body).map_err(|source| ApiError::Json {
url: url.to_string(),
source,
})?;
let mut primed = 0usize;
for (alias, lookup) in &aliases {
let Some(node) = parsed.data.as_ref().and_then(|d| d.get(alias)) else {
continue;
};
let Some(repo) = node.as_ref() else {
tracing::debug!("GraphQL returned null for alias {}", alias);
continue;
};
let names: Vec<String> = repo
.refs
.as_ref()
.map(|r| r.nodes.iter().map(|n| n.name.clone()).collect())
.unwrap_or_default();
match lookup {
BatchLookup::Tags { owner, repo: r } => {
let inter = IntermediaryTags(
names
.into_iter()
.map(|name| IntermediaryTag { name })
.collect(),
);
let tags: Tags = inter.into();
let key = ("github.com".to_string(), owner.clone(), r.clone());
self.tags_cache
.lock()
.expect("forge tags cache poisoned")
.insert(key, tags);
primed += 1;
}
BatchLookup::ChannelCandidates {
owner,
repo: r,
candidates,
..
} => {
let returned: std::collections::HashSet<&str> =
names.iter().map(|n| n.as_str()).collect();
let mut cache = self
.branch_exists_cache
.lock()
.expect("forge branch_exists cache poisoned");
for candidate in candidates {
let key = (
"github.com".to_string(),
owner.clone(),
r.clone(),
candidate.clone(),
);
cache.insert(key, returned.contains(candidate.as_str()));
}
primed += 1;
}
}
}
Ok(primed)
}
fn fetch_github_tags(&self, owner: &str, repo: &str) -> Result<Tags, ApiError> {
let headers = Headers::for_domain("github.com");
let url = |page: u32| {
format!(
"https://api.github.com/repos/{owner}/{repo}/tags?per_page={PER_PAGE}&page={page}"
)
};
let first_url = url(1);
tracing::debug!("Fetching tags page 1: {}", first_url);
let body = self.http.get(&first_url, &headers)?;
let first: IntermediaryTags =
serde_json::from_str(&body).map_err(|source| ApiError::Json {
url: first_url.clone(),
source,
})?;
let mut all = first.0;
let first_was_full = all.len() >= PER_PAGE;
let first_has_semver = all.iter().any(|t| parses_as_semver(&t.name));
if !first_was_full || first_has_semver {
tracing::debug!(
"Cheap path returned {} tag(s) from page 1 (full={}, has_semver={})",
all.len(),
first_was_full,
first_has_semver
);
return Ok(IntermediaryTags(all).into());
}
tracing::debug!(
"Page 1 had no parseable semver in a full page; falling back to paginated walk"
);
for page in 2..=MAX_PAGES {
let page_url = url(page);
tracing::debug!("Fetching tags page {}: {}", page, page_url);
let body = self.http.get(&page_url, &headers)?;
let next: IntermediaryTags =
serde_json::from_str(&body).map_err(|source| ApiError::Json {
url: page_url.clone(),
source,
})?;
let count = next.0.len();
all.extend(next.0);
if count < PER_PAGE {
break;
}
}
tracing::debug!("Total tags fetched: {}", all.len());
Ok(IntermediaryTags(all).into())
}
fn fetch_github_branches(&self, owner: &str, repo: &str) -> Result<Branches, ApiError> {
let headers = Headers::for_domain("github.com");
let branches = paginated(PER_PAGE, MAX_PAGES, |page| {
let url = format!(
"https://api.github.com/repos/{owner}/{repo}/branches?per_page={PER_PAGE}&page={page}"
);
tracing::debug!("Fetching branches page {}: {}", page, url);
let body = self.http.get(&url, &headers)?;
let page_branches =
serde_json::from_str::<IntermediaryBranches>(&body).map_err(|source| {
ApiError::Json {
url: url.clone(),
source,
}
})?;
tracing::debug!("Got {} branches on page {}", page_branches.0.len(), page);
Ok(page_branches.0)
})?;
tracing::debug!("Total branches fetched: {}", branches.len());
Ok(IntermediaryBranches(branches).into())
}
fn fetch_github_branch_exists(
&self,
owner: &str,
repo: &str,
branch: &str,
) -> Result<bool, ApiError> {
let headers = Headers::for_domain("github.com");
let url = format!("https://api.github.com/repos/{owner}/{repo}/branches/{branch}");
self.http.head_status(&url, &headers)
}
fn fetch_gitea_tags(&self, domain: &str, owner: &str, repo: &str) -> Result<Tags, ApiError> {
let headers = Headers::for_domain(domain);
for scheme in ["https", "http"] {
let url = format!("{scheme}://{domain}/api/v1/repos/{owner}/{repo}/tags");
tracing::debug!("Trying Gitea tags endpoint: {}", url);
if let Ok(body) = self.http.get(&url, &headers) {
tracing::debug!("Body from Gitea API: {body}");
if let Ok(tags) = serde_json::from_str::<IntermediaryTags>(&body) {
return Ok(tags.into());
}
}
}
Err(ApiError::NoTagsFound)
}
fn fetch_gitea_branches(
&self,
domain: &str,
owner: &str,
repo: &str,
) -> Result<Branches, ApiError> {
let headers = Headers::for_domain(domain);
let mut all_branches = Vec::new();
let mut page = 1;
for scheme in ["https", "http"] {
loop {
let url = format!(
"{scheme}://{domain}/api/v1/repos/{owner}/{repo}/branches?limit={GITEA_PER_PAGE}&page={page}"
);
tracing::debug!("Trying Gitea branches endpoint: {}", url);
match self.http.get(&url, &headers) {
Ok(body) => {
tracing::debug!("Body from Gitea API: {body}");
match serde_json::from_str::<IntermediaryBranches>(&body) {
Ok(page_branches) => {
let count = page_branches.0.len();
all_branches.extend(page_branches.0);
if count < GITEA_PER_PAGE || page >= MAX_PAGES {
return Ok(IntermediaryBranches(all_branches).into());
}
page += 1;
}
Err(_) => break, }
}
Err(_) => break, }
}
if !all_branches.is_empty() {
return Ok(IntermediaryBranches(all_branches).into());
}
page = 1; }
Err(ApiError::NoBranchesFound)
}
fn fetch_gitea_branch_exists(
&self,
domain: &str,
owner: &str,
repo: &str,
branch: &str,
) -> Result<bool, ApiError> {
let headers = Headers::for_domain(domain);
let mut last_err: Option<ApiError> = None;
for scheme in ["https", "http"] {
let url = format!("{scheme}://{domain}/api/v1/repos/{owner}/{repo}/branches/{branch}");
match self.http.head_status(&url, &headers) {
Ok(answer) => return Ok(answer),
Err(e) => last_err = Some(e),
}
}
Err(last_err.expect("at least one scheme was attempted"))
}
}
fn build_graphql_query(lookups: &[BatchLookup]) -> (String, Vec<(String, BatchLookup)>) {
let mut query = String::from("query {\n");
let mut aliases = Vec::with_capacity(lookups.len());
for (i, lookup) in lookups.iter().enumerate() {
let alias = format!("r{i}");
let (owner, repo) = match lookup {
BatchLookup::Tags { owner, repo } => (owner, repo),
BatchLookup::ChannelCandidates { owner, repo, .. } => (owner, repo),
};
query.push_str(&format!(
" {alias}: repository(owner:{owner}, name:{repo}) {{\n",
owner = json_string(owner),
repo = json_string(repo),
));
match lookup {
BatchLookup::Tags { .. } => {
query.push_str(
" refs(refPrefix:\"refs/tags/\", first:100, \
orderBy:{field: TAG_COMMIT_DATE, direction: DESC}) {\n",
);
}
BatchLookup::ChannelCandidates { prefix, .. } => {
query.push_str(&format!(
" refs(refPrefix:{p}, first:100) {{\n",
p = json_string(&format!("refs/heads/{prefix}")),
));
}
}
query.push_str(" nodes { name }\n");
query.push_str(" }\n");
query.push_str(" }\n");
aliases.push((alias, lookup.clone()));
}
query.push_str("}\n");
(query, aliases)
}
fn json_string(s: &str) -> String {
serde_json::Value::String(s.to_string()).to_string()
}
#[derive(Deserialize, Debug)]
struct GraphQlResponse {
data: Option<HashMap<String, Option<GraphQlRepo>>>,
}
#[derive(Deserialize, Debug)]
struct GraphQlRepo {
refs: Option<GraphQlRefs>,
}
#[derive(Deserialize, Debug)]
struct GraphQlRefs {
nodes: Vec<GraphQlRefName>,
}
#[derive(Deserialize, Debug)]
struct GraphQlRefName {
name: String,
}
#[derive(Deserialize, Debug)]
struct IntermediaryTags(Vec<IntermediaryTag>);
#[derive(Deserialize, Debug)]
struct IntermediaryBranches(Vec<IntermediaryBranch>);
#[derive(Deserialize, Debug)]
struct IntermediaryBranch {
name: String,
}
#[derive(Debug, Default, Clone)]
pub struct Branches {
pub names: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Tags {
versions: Vec<TagVersion>,
}
impl Tags {
pub fn get_latest_tag(&self) -> Option<String> {
self.versions
.iter()
.max_by(|a, b| a.version.cmp_precedence(&b.version))
.map(|tag| tag.original.clone())
}
}
#[derive(Deserialize, Debug)]
struct IntermediaryTag {
name: String,
}
#[derive(Debug, Clone)]
struct TagVersion {
version: Version,
original: String,
}
#[derive(Deserialize, Debug, Clone)]
struct NixConfig {
#[serde(rename = "access-tokens")]
access_tokens: Option<AccessTokens>,
}
impl NixConfig {
fn forge_token(&self, domain: &str) -> Option<String> {
self.access_tokens.as_ref()?.value.get(domain).cloned()
}
}
#[derive(Deserialize, Debug, Clone)]
struct AccessTokens {
value: HashMap<String, String>,
}
fn token_cache() -> &'static Mutex<HashMap<String, Option<String>>> {
static CACHE: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn get_forge_token(domain: &str) -> Option<String> {
{
let cache = token_cache().lock().expect("forge token cache poisoned");
if let Some(cached) = cache.get(domain) {
return cached.clone();
}
}
let resolved = resolve_forge_token(domain);
let mut cache = token_cache().lock().expect("forge token cache poisoned");
cache
.entry(domain.to_string())
.or_insert_with(|| resolved.clone())
.clone()
}
fn resolve_forge_token(domain: &str) -> Option<String> {
if let Ok(output) = Command::new("nix")
.arg("config")
.arg("show")
.arg("--json")
.output()
&& let Ok(stdout) = String::from_utf8(output.stdout)
&& let Ok(config) = serde_json::from_str::<NixConfig>(&stdout)
&& let Some(token) = config.forge_token(domain)
{
return Some(token);
}
if let Ok(token) = std::env::var("GITEA_TOKEN") {
return Some(token);
}
if let Ok(token) = std::env::var("FORGEJO_TOKEN") {
return Some(token);
}
if domain == "github.com"
&& let Ok(token) = std::env::var("GITHUB_TOKEN")
{
return Some(token);
}
if let Ok(output) = Command::new("gh")
.args(["auth", "token", "--hostname", domain])
.output()
&& output.status.success()
&& let Ok(stdout) = String::from_utf8(output.stdout)
{
let token = stdout.trim();
if !token.is_empty() {
return Some(token.to_string());
}
}
None
}
impl From<IntermediaryTags> for Tags {
fn from(value: IntermediaryTags) -> Self {
let mut versions = vec![];
for itag in value.0 {
let parsed = parse_ref(&itag.name, false);
let normalized = parsed.normalized_for_semver;
match Version::parse(&normalized) {
Ok(semver) => {
versions.push(TagVersion {
version: semver,
original: parsed.original_ref,
});
}
Err(e) => {
tracing::error!("Could not parse version {:?}", e);
}
}
}
Tags { versions }
}
}
impl From<IntermediaryBranches> for Branches {
fn from(value: IntermediaryBranches) -> Self {
Branches {
names: value.0.into_iter().map(|b| b.name).collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const URL: &str = "https://api.github.com/repos/foo/bar/branches/baz";
#[test]
fn paginated_accumulates_until_short_page() {
let pages: Vec<Vec<u32>> = vec![(0..100).collect(), (100..105).collect()];
let mut calls = 0u32;
let result = paginated::<u32, _>(100, MAX_PAGES, |page| {
calls += 1;
Ok(pages[(page - 1) as usize].clone())
})
.unwrap();
assert_eq!(calls, 2, "stops after the short page, no third request");
assert_eq!(result.len(), 105);
assert_eq!(result.first().copied(), Some(0));
assert_eq!(result.last().copied(), Some(104));
}
#[test]
fn paginated_caps_at_max_pages() {
let mut calls = 0u32;
let result = paginated::<u32, _>(2, 3, |_| {
calls += 1;
Ok(vec![1, 2])
})
.unwrap();
assert_eq!(calls, 3, "safety cap halts the loop on always-full pages");
assert_eq!(result.len(), 6);
}
#[test]
fn classify_404_is_not_found() {
let err = classify_ureq(ureq::Error::StatusCode(404), URL);
match err {
ApiError::NotFound { url } => assert_eq!(url, URL),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn classify_500_is_http_status() {
let err = classify_ureq(ureq::Error::StatusCode(503), URL);
match err {
ApiError::HttpStatus { url, status } => {
assert_eq!(url, URL);
assert_eq!(status, 503);
}
other => panic!("expected HttpStatus, got {other:?}"),
}
}
#[test]
fn body_read_io_is_not_connect_failed() {
let io = std::io::Error::other("peer closed");
let err = classify_body_read(ureq::Error::Io(io), URL);
match err {
ApiError::Other { url, .. } => assert_eq!(url, URL),
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn tags_parsing_with_refs_tags_prefix() {
let json = r#"[
{"name": "refs/tags/v1.0.0"},
{"name": "refs/tags/v2.0.0"},
{"name": "refs/tags/v1.5.0"}
]"#;
let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
let tags: Tags = intermediary.into();
assert_eq!(tags.get_latest_tag(), Some("refs/tags/v2.0.0".to_string()));
}
#[test]
fn tags_parsing_with_short_versions() {
let json = r#"[
{"name": "v1"},
{"name": "v1.1"}
]"#;
let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
let tags: Tags = intermediary.into();
assert_eq!(tags.get_latest_tag(), Some("v1.1".to_string()));
}
#[test]
fn tags_parsing_without_prefix() {
let json = r#"[
{"name": "1.0.0"},
{"name": "2.0.0"},
{"name": "1.5.0"}
]"#;
let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
let tags: Tags = intermediary.into();
assert_eq!(tags.get_latest_tag(), Some("2.0.0".to_string()));
}
#[test]
fn tags_parsing_with_dash_prefix() {
let json = r#"[
{"name": "release-1.0.0"},
{"name": "release-2.0.0"},
{"name": "release-1.5.0"}
]"#;
let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
let tags: Tags = intermediary.into();
assert_eq!(tags.get_latest_tag(), Some("release-2.0.0".to_string()));
}
#[test]
fn tags_parsing_mixed_valid_invalid() {
let json = r#"[
{"name": "v1.0.0"},
{"name": "v2.0.0"},
{"name": "invalid-tag"},
{"name": "v1.5.0"}
]"#;
let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
let tags: Tags = intermediary.into();
assert_eq!(tags.get_latest_tag(), Some("v2.0.0".to_string()));
}
#[test]
fn tags_parsing_empty() {
let json = r#"[]"#;
let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
let tags: Tags = intermediary.into();
assert_eq!(tags.get_latest_tag(), None);
}
#[test]
fn tags_parsing_orders_prereleases_by_semver_precedence() {
let json = r#"[
{"name": "v1.0.0"},
{"name": "v2.0.0-beta.1"},
{"name": "v1.5.0"}
]"#;
let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
let tags: Tags = intermediary.into();
assert_eq!(tags.get_latest_tag(), Some("v2.0.0-beta.1".to_string()));
}
#[test]
fn tags_parsing_handles_hl_prefixed_scheme_without_downgrade() {
let json = r#"[
{"name": "hl0.21.0-1"},
{"name": "hl0.33.0-1"},
{"name": "hl0.46.0-1"},
{"name": "hl0.47.0-1"}
]"#;
let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
let tags: Tags = intermediary.into();
assert_eq!(tags.get_latest_tag(), Some("hl0.47.0-1".to_string()));
}
#[test]
fn tags_parsing_combined_prefixes() {
let json = r#"[
{"name": "refs/tags/v1.0.0"},
{"name": "refs/tags/v2.0.0"}
]"#;
let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
let tags: Tags = intermediary.into();
assert_eq!(tags.get_latest_tag(), Some("refs/tags/v2.0.0".to_string()));
}
#[test]
fn tags_sort_by_semver_not_lex() {
let json = r#"[
{"name": "v10.0.0"},
{"name": "v2.0.0"},
{"name": "v1.0.0"}
]"#;
let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
let tags: Tags = intermediary.into();
assert_eq!(tags.get_latest_tag(), Some("v10.0.0".to_string()));
}
#[test]
fn http_client_has_explicit_timeouts() {
let client = HttpClient::default();
let timeouts = client.agent.config().timeouts();
assert!(
timeouts.connect.is_some(),
"connect timeout must be set on the HTTP agent"
);
assert!(
timeouts.recv_response.is_some(),
"recv_response timeout must be set on the HTTP agent"
);
}
#[test]
fn forge_client_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ForgeClient>();
}
#[test]
fn parses_as_semver_recognizes_normalized_shapes() {
assert!(parses_as_semver("v1.2.3"));
assert!(parses_as_semver("refs/tags/v1.2.3"));
assert!(parses_as_semver("release-1.2.3"));
assert!(parses_as_semver("v1")); assert!(parses_as_semver("1.0.0+gitea")); assert!(parses_as_semver("release-1.5")); assert!(!parses_as_semver("invalid-tag"));
assert!(!parses_as_semver("abc"));
assert!(!parses_as_semver(""));
assert!(!parses_as_semver("release-25.05"));
assert!(!parses_as_semver("release-24.05"));
assert!(parses_as_semver("v1.2.3-rc1"));
}
#[test]
fn build_graphql_query_uses_distinct_aliases() {
let lookups = vec![
BatchLookup::Tags {
owner: "same".into(),
repo: "same".into(),
},
BatchLookup::Tags {
owner: "same".into(),
repo: "same".into(),
},
];
let (query, aliases) = build_graphql_query(&lookups);
assert_eq!(aliases.len(), 2);
assert_eq!(aliases[0].0, "r0");
assert_eq!(aliases[1].0, "r1");
assert!(
query.contains("r0:"),
"first lookup must use the r0 alias; query was:\n{query}"
);
assert!(
query.contains("r1:"),
"second lookup must use a distinct r1 alias; query was:\n{query}"
);
}
}