use crate::error::{DepsError, Result};
use crate::freshness::PublishTime;
use crate::lsp_helpers::{is_dot_segment, warn_rejected_value};
use bytes::Bytes;
use dashmap::DashMap;
use reqwest::header::{AUTHORIZATION, HeaderName};
use serde::Deserialize;
use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use crate::cache::HttpCache;
pub const GITHUB_API: &str = "https://api.github.com";
pub const MAX_TAG_PAGES: u32 = 30;
#[must_use]
pub fn is_valid_github_identity(name: &str) -> bool {
let Some((owner, repo)) = name.split_once('/') else {
return false;
};
if owner.is_empty() || repo.is_empty() || repo.contains('/') {
return false;
}
let charset_ok = |s: &str| {
s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
};
charset_ok(owner) && charset_ok(repo) && !is_dot_segment(owner) && !is_dot_segment(repo)
}
pub fn validate_owner_repo(name: &str) -> Result<()> {
if is_valid_github_identity(name) {
return Ok(());
}
if let Some((owner, repo)) = name.split_once('/')
&& (is_dot_segment(owner) || is_dot_segment(repo))
{
warn_rejected_value("is_dot_segment", "GitHub owner/repo request URL", name);
}
Err(DepsError::InvalidUri(format!(
"invalid owner/repo format: '{name}'"
)))
}
#[must_use]
pub fn github_rate_limit_error() -> DepsError {
DepsError::RateLimited {
message: "GitHub API rate limit exceeded. Set GITHUB_TOKEN to increase the limit \
(5000 req/h). Run: export GITHUB_TOKEN=$(gh auth token)"
.into(),
}
}
#[derive(Clone, PartialEq, Eq)]
struct AuthToken(String);
impl AuthToken {
fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for AuthToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("AuthToken(***)")
}
}
impl std::fmt::Display for AuthToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("***")
}
}
#[derive(Clone)]
pub struct GithubTagsClient {
cache: Arc<HttpCache>,
auth_headers: Vec<(HeaderName, AuthToken)>,
has_token: bool,
api_base: String,
trusted_origin: String,
}
impl GithubTagsClient {
#[must_use]
pub fn new(cache: Arc<HttpCache>) -> Self {
let token = std::env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty());
let has_token = token.is_some();
let auth_headers = token
.map(|token| {
tracing::info!("GITHUB_TOKEN detected, using authenticated GitHub API requests");
vec![(AUTHORIZATION, AuthToken(format!("Bearer {token}")))]
})
.unwrap_or_default();
Self {
cache,
auth_headers,
has_token,
trusted_origin: format!("{GITHUB_API}/"),
api_base: GITHUB_API.to_string(),
}
}
#[cfg(any(test, feature = "test-util"))]
#[must_use]
pub fn for_test(cache: Arc<HttpCache>, api_base: impl Into<String>, has_token: bool) -> Self {
let auth_headers = if has_token {
vec![(AUTHORIZATION, AuthToken("Bearer test-token".to_string()))]
} else {
Vec::new()
};
let api_base = api_base.into();
Self {
cache,
auth_headers,
has_token,
trusted_origin: format!("{api_base}/"),
api_base,
}
}
#[must_use]
pub const fn has_token(&self) -> bool {
self.has_token
}
#[must_use]
pub fn api_base(&self) -> &str {
&self.api_base
}
#[must_use]
pub(crate) fn headers(&self) -> Vec<(HeaderName, &str)> {
self.auth_headers
.iter()
.map(|(k, v)| (k.clone(), v.as_str()))
.collect()
}
#[must_use]
pub const fn cache(&self) -> &Arc<HttpCache> {
&self.cache
}
pub async fn fetch_authenticated(&self, url: &str) -> Result<Bytes> {
self.cache
.get_cached_trusted_origin_with_headers(url, &self.trusted_origin, &self.headers())
.await
}
pub async fn fetch_tags_page(&self, name: &str, page: u32) -> Result<Bytes> {
let url = format!(
"{}/repos/{name}/tags?per_page=100&page={page}",
self.api_base
);
self.fetch_authenticated(&url).await
}
}
#[derive(Debug, Default, Deserialize)]
pub struct GithubTag {
pub name: String,
#[serde(default)]
pub commit: GithubTagCommit,
}
#[derive(Debug, Default, Deserialize)]
pub struct GithubTagCommit {
#[serde(default)]
pub sha: String,
}
#[derive(Deserialize)]
struct GithubErrorResponse {
message: String,
}
#[must_use]
pub const fn page_has_more(page_len: usize) -> bool {
page_len >= 100
}
pub fn warn_if_pagination_truncated(ecosystem: &str, name: &str, page: u32, page_len: usize) {
if page == MAX_TAG_PAGES && page_has_more(page_len) {
tracing::warn!(
package = name,
pages_fetched = MAX_TAG_PAGES,
"{ecosystem} tags pagination for '{name}' stopped at the {MAX_TAG_PAGES}-page cap \
while GitHub reported more pages available; the fetched version list may be \
truncated"
);
}
}
pub async fn paginate_tags<F, Fut>(
ecosystem: &str,
name: &str,
mut fetch_page: F,
) -> Result<Vec<GithubTag>>
where
F: FnMut(u32) -> Fut,
Fut: Future<Output = Result<Bytes>>,
{
let mut tags = Vec::new();
for page in 1..=MAX_TAG_PAGES {
let data = fetch_page(page).await?;
let page_tags = parse_tags_page(&data)?;
let page_len = page_tags.len();
tags.extend(page_tags);
if !page_has_more(page_len) {
break;
}
warn_if_pagination_truncated(ecosystem, name, page, page_len);
}
Ok(tags)
}
pub fn parse_tags_page(data: &[u8]) -> Result<Vec<GithubTag>> {
match crate::parser::parse_json_checked(data) {
Ok(tags) => Ok(tags),
Err(_) => {
if let Ok(err) = crate::parser::parse_json_checked::<GithubErrorResponse>(data) {
Err(DepsError::CacheError(format!(
"GitHub API error: {}",
err.message
)))
} else {
Ok(vec![])
}
}
}
}
#[must_use]
pub fn normalize_tag(name: &str) -> &str {
name.strip_prefix(['v', 'V']).unwrap_or(name)
}
const RELEASE_DATES_TTL: Duration = Duration::from_mins(15);
const RELEASE_DATES_ERROR_TTL: Duration = Duration::from_secs(90);
const RELEASE_DATES_FETCH_TIMEOUT: Duration = Duration::from_secs(2);
const MAX_RELEASE_DATES_MEMO_ENTRIES: usize = 256;
struct ReleaseDatesEntry {
fetched_at: Instant,
dates: Arc<HashMap<String, PublishTime>>,
ttl: Duration,
}
fn evict_release_dates_if_full(map: &DashMap<String, ReleaseDatesEntry>) {
if map.len() < MAX_RELEASE_DATES_MEMO_ENTRIES {
return;
}
let now = Instant::now();
map.retain(|_, entry| now.duration_since(entry.fetched_at) < entry.ttl);
if map.len() >= MAX_RELEASE_DATES_MEMO_ENTRIES
&& let Some(oldest) = map
.iter()
.min_by_key(|e| e.fetched_at)
.map(|e| e.key().clone())
{
map.remove(&oldest);
}
}
#[derive(Deserialize)]
struct GithubRelease {
tag_name: String,
published_at: Option<String>,
#[serde(default)]
draft: bool,
}
fn parse_releases_page(data: &[u8]) -> Option<HashMap<String, PublishTime>> {
let releases: Vec<GithubRelease> = crate::parser::parse_json_checked(data).ok()?;
let mut dates = HashMap::new();
for release in releases {
if release.draft {
continue;
}
let Some(published) = release
.published_at
.as_deref()
.and_then(PublishTime::parse_rfc3339)
else {
continue;
};
dates
.entry(normalize_tag(&release.tag_name).to_string())
.or_insert(published);
}
Some(dates)
}
fn classify_release_fetch(
outcome: Option<Result<Bytes>>,
) -> (HashMap<String, PublishTime>, Duration) {
match outcome {
Some(Ok(data)) => match parse_releases_page(&data) {
Some(dates) => (dates, RELEASE_DATES_TTL),
None => (HashMap::new(), RELEASE_DATES_ERROR_TTL),
},
Some(Err(_)) | None => (HashMap::new(), RELEASE_DATES_ERROR_TTL),
}
}
#[derive(Default)]
pub struct ReleaseDatesCache {
dates: DashMap<String, ReleaseDatesEntry>,
enrichment_skip_logged: AtomicBool,
}
impl ReleaseDatesCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub async fn fetch(
&self,
github: &GithubTagsClient,
name: &str,
ecosystem: &'static str,
) -> Arc<HashMap<String, PublishTime>> {
if validate_owner_repo(name).is_err() {
return Arc::new(HashMap::new());
}
let key = format!("{}\0{name}", github.api_base());
let now = Instant::now();
if let Some(entry) = self.dates.get(&key)
&& now.duration_since(entry.fetched_at) < entry.ttl
{
return Arc::clone(&entry.dates);
}
if !github.has_token() {
if !self.enrichment_skip_logged.swap(true, Ordering::Relaxed) {
tracing::info!(
"GITHUB_TOKEN not set — {ecosystem} release dates are unavailable; hover \
and completion will omit publish ages. Run: export GITHUB_TOKEN=$(gh auth token)"
);
}
return Arc::new(HashMap::new());
}
let url = format!("{}/repos/{name}/releases?per_page=100", github.api_base());
let fetch_result = tokio::time::timeout(
RELEASE_DATES_FETCH_TIMEOUT,
github.fetch_authenticated(&url),
)
.await;
match &fetch_result {
Ok(Err(e)) => tracing::debug!(package = name, error = %e, "release dates fetch failed"),
Err(_) => tracing::debug!(package = name, "release dates fetch timed out"),
Ok(Ok(_)) => {}
}
let (dates, ttl) = classify_release_fetch(fetch_result.ok());
let dates = Arc::new(dates);
if !self.dates.contains_key(&key) {
evict_release_dates_if_full(&self.dates);
}
self.dates.insert(
key,
ReleaseDatesEntry {
fetched_at: now,
dates: Arc::clone(&dates),
ttl,
},
);
dates
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::{capture_tracing_output, capture_tracing_output_async};
#[test]
fn test_is_valid_github_identity_accepts_owner_repo() {
assert!(is_valid_github_identity("actions/checkout"));
assert!(is_valid_github_identity("org.name/repo_name-v2"));
}
#[test]
fn test_is_valid_github_identity_rejects_malformed() {
assert!(!is_valid_github_identity("no-slash"));
assert!(!is_valid_github_identity(""));
assert!(!is_valid_github_identity("owner/repo/extra"));
assert!(!is_valid_github_identity("owner/ repo"));
assert!(!is_valid_github_identity("../../etc/passwd"));
}
#[test]
fn test_is_valid_github_identity_rejects_dot_segment() {
assert!(!is_valid_github_identity("owner/.."));
assert!(!is_valid_github_identity("owner/."));
assert!(!is_valid_github_identity("../repo"));
assert!(!is_valid_github_identity("./repo"));
}
#[test]
fn test_validate_owner_repo_invalid_format_message() {
let err = validate_owner_repo("no-slash").unwrap_err();
assert!(err.to_string().contains("invalid owner/repo format"));
}
#[test]
fn test_validate_owner_repo_valid() {
assert!(validate_owner_repo("apple/swift-nio").is_ok());
assert!(validate_owner_repo("actions/checkout").is_ok());
assert!(validate_owner_repo("org.name/repo_name-v2").is_ok());
}
#[test]
fn test_validate_owner_repo_invalid() {
assert!(validate_owner_repo("no-slash").is_err());
assert!(validate_owner_repo("../../etc/passwd").is_err());
assert!(validate_owner_repo("owner/repo/extra").is_err());
assert!(validate_owner_repo("owner/ repo").is_err());
assert!(validate_owner_repo("").is_err());
}
#[test]
fn test_validate_owner_repo_rejects_dot_segment_repo() {
assert!(validate_owner_repo("owner/..").is_err());
assert!(validate_owner_repo("owner/.").is_err());
}
#[test]
fn test_validate_owner_repo_rejects_dot_segment_owner() {
assert!(validate_owner_repo("../repo").is_err());
assert!(validate_owner_repo("./repo").is_err());
}
#[test]
fn test_page_has_more_full_page_continues() {
assert!(page_has_more(100));
}
#[test]
fn test_page_has_more_partial_page_stops() {
assert!(!page_has_more(99));
assert!(!page_has_more(0));
}
#[test]
fn test_pagination_warns_when_truncated_at_cap() {
let output = capture_tracing_output(|| {
warn_if_pagination_truncated("Swift", "owner/repo", MAX_TAG_PAGES, 100);
});
assert!(output.contains("owner/repo"), "output was: {output}");
assert!(output.contains("Swift"), "output was: {output}");
assert!(output.contains("cap"), "output was: {output}");
}
#[test]
fn test_pagination_silent_when_under_cap() {
let output = capture_tracing_output(|| {
warn_if_pagination_truncated("Swift", "owner/repo", MAX_TAG_PAGES - 1, 100);
});
assert!(output.is_empty(), "output was: {output}");
}
#[test]
fn test_pagination_silent_when_last_page_at_cap_is_partial() {
let output = capture_tracing_output(|| {
warn_if_pagination_truncated("Swift", "owner/repo", MAX_TAG_PAGES, 42);
});
assert!(output.is_empty(), "output was: {output}");
}
fn tags_page_json(count: usize) -> Bytes {
let entries: Vec<String> = (0..count)
.map(|i| format!(r#"{{"name":"tag{i}"}}"#))
.collect();
Bytes::from(format!("[{}]", entries.join(",")))
}
#[tokio::test]
async fn test_paginate_tags_stops_after_partial_page() {
use std::sync::atomic::{AtomicU32, Ordering};
let calls = AtomicU32::new(0);
let mut tags = Vec::new();
let output = capture_tracing_output_async(async {
let result = paginate_tags("Swift", "owner/repo", |page| {
calls.fetch_add(1, Ordering::SeqCst);
async move {
match page {
1 => Ok(tags_page_json(100)),
2 => Ok(tags_page_json(42)),
_ => panic!("page {page} must not be fetched after a partial page"),
}
}
})
.await
.unwrap();
tags = result;
})
.await;
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"must stop after the partial page 2"
);
assert_eq!(tags.len(), 142);
assert!(
output.is_empty(),
"must not warn below the page cap: {output}"
);
}
#[tokio::test]
async fn test_paginate_tags_warns_when_cap_reached_with_full_last_page() {
use std::sync::atomic::{AtomicU32, Ordering};
let calls = AtomicU32::new(0);
let output = capture_tracing_output_async(async {
let result = paginate_tags("Swift", "owner/repo", |_page| {
calls.fetch_add(1, Ordering::SeqCst);
async move { Ok(tags_page_json(100)) }
})
.await
.unwrap();
assert_eq!(result.len(), 100 * MAX_TAG_PAGES as usize);
})
.await;
assert_eq!(calls.load(Ordering::SeqCst), MAX_TAG_PAGES);
assert!(output.contains("owner/repo"), "output was: {output}");
assert!(output.contains("cap"), "output was: {output}");
}
#[test]
fn test_parse_tags_page_returns_raw_tags() {
let json = r#"[{"name": "v1.0.0"}, {"name": "not-semver"}]"#;
let tags = parse_tags_page(json.as_bytes()).unwrap();
assert_eq!(tags.len(), 2);
assert_eq!(tags[0].name, "v1.0.0");
}
#[test]
fn test_parse_tags_page_missing_commit_defaults() {
let json = r#"[{"name": "1.0.0"}]"#;
let tags = parse_tags_page(json.as_bytes()).unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].commit.sha, "");
}
#[test]
fn test_parse_tags_page_invalid_json_returns_empty() {
let result = parse_tags_page(b"not json").unwrap();
assert!(result.is_empty());
}
#[test]
fn test_parse_tags_page_github_rate_limit_returns_error() {
let json = r#"{"message":"API rate limit exceeded for 1.2.3.4."}"#;
let result = parse_tags_page(json.as_bytes());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("rate limit"));
}
#[test]
fn test_github_tags_client_for_test_sets_token_header() {
let client = GithubTagsClient::for_test(Arc::new(HttpCache::new()), "http://example", true);
assert!(client.has_token());
assert_eq!(client.api_base(), "http://example");
assert_eq!(client.headers().len(), 1);
}
#[test]
fn test_github_tags_client_for_test_no_token_has_no_headers() {
let client =
GithubTagsClient::for_test(Arc::new(HttpCache::new()), "http://example", false);
assert!(!client.has_token());
assert!(client.headers().is_empty());
}
#[test]
fn test_auth_token_debug_redacts_value() {
let token = AuthToken("Bearer super-secret-value".to_string());
assert_eq!(format!("{token:?}"), "AuthToken(***)");
}
#[test]
fn test_auth_token_display_redacts_value() {
let token = AuthToken("Bearer super-secret-value".to_string());
assert_eq!(format!("{token}"), "***");
}
#[test]
fn test_auth_token_debug_redacts_when_embedded_in_header_vec() {
let token = AuthToken("Bearer super-secret-value".to_string());
let headers = vec![(AUTHORIZATION, token)];
let debug_output = format!("{headers:?}");
assert!(
!debug_output.contains("super-secret-value"),
"{debug_output}"
);
assert!(debug_output.contains("AuthToken(***)"), "{debug_output}");
}
#[tokio::test]
async fn test_fetch_authenticated_sends_token_on_the_wire() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/repos/owner/repo/tags?per_page=100&page=1")
.match_header("authorization", "Bearer test-token")
.with_status(200)
.with_body("[]")
.create_async()
.await;
let client = GithubTagsClient::for_test(Arc::new(HttpCache::new()), server.url(), true);
client.fetch_tags_page("owner/repo", 1).await.unwrap();
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_authenticated_blocks_cross_origin_redirect() {
let mut trusted_server = mockito::Server::new_async().await;
let mut other_server = mockito::Server::new_async().await;
let escape_target = format!("{}/stolen", other_server.url());
let _redirect = trusted_server
.mock("GET", "/repos/owner/repo/tags?per_page=100&page=1")
.with_status(302)
.with_header("location", &escape_target)
.create_async()
.await;
let escape = other_server
.mock("GET", "/stolen")
.with_status(200)
.with_body("must not be returned")
.expect(0)
.create_async()
.await;
let client =
GithubTagsClient::for_test(Arc::new(HttpCache::new()), trusted_server.url(), true);
let result = client.fetch_tags_page("owner/repo", 1).await;
let status = match result {
Err(DepsError::HttpStatus { status, .. }) => Some(status),
_ => None,
};
assert_eq!(
status,
Some(302),
"expected the cross-origin redirect to be stopped"
);
escape.assert_async().await;
}
#[test]
fn test_normalize_tag_strips_lowercase_v() {
assert_eq!(normalize_tag("v1.2.3"), "1.2.3");
}
#[test]
fn test_normalize_tag_strips_uppercase_v() {
assert_eq!(normalize_tag("V1.2.3"), "1.2.3");
}
#[test]
fn test_normalize_tag_no_prefix_unchanged() {
assert_eq!(normalize_tag("1.2.3"), "1.2.3");
}
#[test]
fn test_parse_releases_page_happy_path() {
let json = br#"[
{"tag_name": "2.0.0", "published_at": "2026-01-02T08:56:05Z", "draft": false},
{"tag_name": "1.0.0", "published_at": "2025-06-01T00:00:00Z", "draft": false}
]"#;
let dates = parse_releases_page(json).unwrap();
assert_eq!(dates.len(), 2);
assert!(dates.contains_key("2.0.0"));
assert!(dates.contains_key("1.0.0"));
}
#[test]
fn test_parse_releases_page_skips_draft() {
let json = br#"[
{"tag_name": "2.0.0", "published_at": "2026-01-02T08:56:05Z", "draft": true},
{"tag_name": "1.0.0", "published_at": "2025-06-01T00:00:00Z", "draft": false}
]"#;
let dates = parse_releases_page(json).unwrap();
assert_eq!(dates.len(), 1);
assert!(!dates.contains_key("2.0.0"));
assert!(dates.contains_key("1.0.0"));
}
#[test]
fn test_parse_releases_page_skips_null_published_at() {
let json = br#"[
{"tag_name": "2.0.0", "published_at": null, "draft": false}
]"#;
let dates = parse_releases_page(json).unwrap();
assert!(dates.is_empty());
}
#[test]
fn test_parse_releases_page_v_prefix_joins_tag() {
let json =
br#"[{"tag_name": "V1.2.3", "published_at": "2026-01-02T08:56:05Z", "draft": false}]"#;
let dates = parse_releases_page(json).unwrap();
assert!(dates.contains_key("1.2.3"));
assert!(!dates.contains_key("V1.2.3"));
}
#[test]
fn test_parse_releases_page_empty_array_is_a_successful_zero_release_page() {
let dates = parse_releases_page(b"[]");
assert_eq!(dates, Some(HashMap::new()));
}
#[test]
fn test_parse_releases_page_malformed_json_returns_none() {
assert_eq!(parse_releases_page(b"not json"), None);
}
#[test]
fn test_parse_releases_page_github_error_object_returns_none() {
let json = br#"{"message":"API rate limit exceeded for 1.2.3.4."}"#;
assert_eq!(parse_releases_page(json), None);
}
#[test]
fn test_parse_releases_page_duplicate_normalized_keys_first_wins() {
let json = br#"[
{"tag_name": "v1.0.0", "published_at": "2026-06-01T00:00:00Z", "draft": false},
{"tag_name": "1.0.0", "published_at": "2025-01-01T00:00:00Z", "draft": false}
]"#;
let dates = parse_releases_page(json).unwrap();
assert_eq!(dates.len(), 1);
assert_eq!(
dates.get("1.0.0").copied(),
PublishTime::parse_rfc3339("2026-06-01T00:00:00Z")
);
}
#[test]
fn test_classify_release_fetch_success_gets_positive_ttl() {
let json =
br#"[{"tag_name": "1.0.0", "published_at": "2026-01-02T08:56:05Z", "draft": false}]"#;
let (dates, ttl) = classify_release_fetch(Some(Ok(Bytes::from_static(json))));
assert_eq!(dates.len(), 1);
assert_eq!(ttl, RELEASE_DATES_TTL);
}
#[test]
fn test_classify_release_fetch_empty_but_valid_page_gets_positive_ttl() {
let (dates, ttl) = classify_release_fetch(Some(Ok(Bytes::from_static(b"[]"))));
assert!(dates.is_empty());
assert_eq!(ttl, RELEASE_DATES_TTL);
}
#[test]
fn test_classify_release_fetch_unparseable_body_gets_error_ttl() {
let (dates, ttl) = classify_release_fetch(Some(Ok(Bytes::from_static(b"not json"))));
assert!(dates.is_empty());
assert_eq!(ttl, RELEASE_DATES_ERROR_TTL);
}
#[test]
fn test_classify_release_fetch_http_error_gets_error_ttl() {
let (dates, ttl) =
classify_release_fetch(Some(Err(DepsError::CacheError("boom".to_string()))));
assert!(dates.is_empty());
assert_eq!(ttl, RELEASE_DATES_ERROR_TTL);
}
#[test]
fn test_classify_release_fetch_timeout_gets_error_ttl() {
let (dates, ttl) = classify_release_fetch(None);
assert!(dates.is_empty());
assert_eq!(ttl, RELEASE_DATES_ERROR_TTL);
}
fn entry_at(secs_ago: u64, ttl: Duration) -> ReleaseDatesEntry {
ReleaseDatesEntry {
fetched_at: Instant::now()
.checked_sub(Duration::from_secs(secs_ago))
.unwrap(),
dates: Arc::new(HashMap::new()),
ttl,
}
}
#[test]
fn test_evict_release_dates_if_full_noop_under_cap() {
let map = DashMap::new();
map.insert("a/a".to_string(), entry_at(0, RELEASE_DATES_TTL));
evict_release_dates_if_full(&map);
assert_eq!(map.len(), 1);
}
#[test]
fn test_evict_release_dates_if_full_drops_expired_entries_first() {
let map = DashMap::new();
for i in 0..MAX_RELEASE_DATES_MEMO_ENTRIES {
map.insert(
format!("owner/repo{i}"),
entry_at(1000, RELEASE_DATES_ERROR_TTL),
);
}
assert_eq!(map.len(), MAX_RELEASE_DATES_MEMO_ENTRIES);
evict_release_dates_if_full(&map);
assert!(
map.is_empty(),
"all entries were expired and must be dropped"
);
}
#[test]
fn test_evict_release_dates_if_full_drops_oldest_when_none_expired() {
let map = DashMap::new();
for i in 0..MAX_RELEASE_DATES_MEMO_ENTRIES {
map.insert(
format!("owner/repo{i}"),
entry_at(i as u64, RELEASE_DATES_TTL),
);
}
assert_eq!(map.len(), MAX_RELEASE_DATES_MEMO_ENTRIES);
evict_release_dates_if_full(&map);
assert_eq!(
map.len(),
MAX_RELEASE_DATES_MEMO_ENTRIES - 1,
"exactly one entry (the oldest) must be evicted"
);
assert!(!map.contains_key(&format!("owner/repo{}", MAX_RELEASE_DATES_MEMO_ENTRIES - 1)));
assert!(map.contains_key("owner/repo0"));
}
fn untokened_client() -> GithubTagsClient {
GithubTagsClient::for_test(Arc::new(HttpCache::new()), GITHUB_API, false)
}
#[tokio::test]
async fn test_fetch_validate_owner_repo_rejection_issues_zero_requests() {
let cache = ReleaseDatesCache::new();
let github = untokened_client();
let dates = cache.fetch(&github, "../../etc/passwd", "Test").await;
assert!(dates.is_empty());
assert!(cache.dates.is_empty());
}
#[tokio::test]
async fn test_fetch_positive_ttl_hit_returns_memoized_value_without_refetch() {
let cache = ReleaseDatesCache::new();
let github = untokened_client();
let published = PublishTime::parse_rfc3339("2026-01-02T08:56:05Z").unwrap();
cache.dates.insert(
format!("{GITHUB_API}\0owner/repo"),
ReleaseDatesEntry {
fetched_at: Instant::now().checked_sub(Duration::from_secs(60)).unwrap(),
dates: Arc::new(HashMap::from([("9.9.9".to_string(), published)])),
ttl: RELEASE_DATES_TTL,
},
);
let output = capture_tracing_output_async(async {
let dates = cache.fetch(&github, "owner/repo", "Test").await;
assert_eq!(dates.get("9.9.9").copied(), Some(published));
})
.await;
assert!(
output.is_empty(),
"memo hit must not log the token-gate skip: {output}"
);
}
#[tokio::test]
async fn test_fetch_empty_but_successful_fetch_retained_under_positive_ttl() {
let cache = ReleaseDatesCache::new();
let github = untokened_client();
cache.dates.insert(
format!("{GITHUB_API}\0owner/repo"),
ReleaseDatesEntry {
fetched_at: Instant::now().checked_sub(Duration::from_secs(60)).unwrap(),
dates: Arc::new(HashMap::new()),
ttl: RELEASE_DATES_TTL,
},
);
let output = capture_tracing_output_async(async {
let dates = cache.fetch(&github, "owner/repo", "Test").await;
assert!(dates.is_empty());
})
.await;
assert!(
output.is_empty(),
"an empty-but-successful entry within its positive TTL must not refetch: {output}"
);
}
#[tokio::test]
async fn test_fetch_unexpired_error_ttl_entry_is_memo_hit() {
let cache = ReleaseDatesCache::new();
let github = untokened_client();
cache.dates.insert(
format!("{GITHUB_API}\0owner/repo"),
ReleaseDatesEntry {
fetched_at: Instant::now().checked_sub(Duration::from_secs(60)).unwrap(),
dates: Arc::new(HashMap::new()),
ttl: RELEASE_DATES_ERROR_TTL,
},
);
let output = capture_tracing_output_async(async {
let dates = cache.fetch(&github, "owner/repo", "Test").await;
assert!(dates.is_empty());
})
.await;
assert!(
output.is_empty(),
"an unexpired error-TTL entry must not refetch: {output}"
);
}
#[tokio::test]
async fn test_fetch_expired_error_ttl_entry_falls_through_to_token_gate() {
let cache = ReleaseDatesCache::new();
let github = untokened_client();
cache.dates.insert(
format!("{GITHUB_API}\0owner/repo"),
ReleaseDatesEntry {
fetched_at: Instant::now()
.checked_sub(Duration::from_secs(100))
.unwrap(),
dates: Arc::new(HashMap::new()),
ttl: RELEASE_DATES_ERROR_TTL,
},
);
let output = capture_tracing_output_async(async {
let dates = cache.fetch(&github, "owner/repo", "Test").await;
assert!(dates.is_empty());
})
.await;
assert!(
output.contains("GITHUB_TOKEN not set"),
"expiry must trigger a refetch attempt: {output}"
);
}
#[tokio::test]
async fn test_fetch_token_gate_skip_logs_once_per_cache_and_names_ecosystem() {
let cache = ReleaseDatesCache::new();
let github = untokened_client();
let output = capture_tracing_output_async(async {
let _ = cache.fetch(&github, "owner/repo-a", "Test").await;
let _ = cache.fetch(&github, "owner/repo-b", "Test").await;
})
.await;
assert_eq!(
output.matches("GITHUB_TOKEN not set").count(),
1,
"the skip message must fire at most once per cache instance: {output}"
);
assert!(
output.contains("Test release dates are unavailable"),
"{output}"
);
}
#[test]
fn test_error_ttl_is_shorter_than_positive_ttl() {
assert!(RELEASE_DATES_ERROR_TTL < RELEASE_DATES_TTL);
}
#[tokio::test]
async fn test_fetch_does_not_serve_a_hit_seeded_under_a_different_api_base() {
let cache = ReleaseDatesCache::new();
let published = PublishTime::parse_rfc3339("2026-01-02T08:56:05Z").unwrap();
cache.dates.insert(
format!("{GITHUB_API}\0owner/repo"),
ReleaseDatesEntry {
fetched_at: Instant::now().checked_sub(Duration::from_secs(60)).unwrap(),
dates: Arc::new(HashMap::from([("9.9.9".to_string(), published)])),
ttl: RELEASE_DATES_TTL,
},
);
let other_origin_client =
GithubTagsClient::for_test(Arc::new(HttpCache::new()), "http://127.0.0.1:1", false);
let output = capture_tracing_output_async(async {
let dates = cache
.fetch(&other_origin_client, "owner/repo", "Test")
.await;
assert!(dates.is_empty());
})
.await;
assert!(
output.contains("GITHUB_TOKEN not set"),
"a different api_base must miss the memo and fall through: {output}"
);
}
}