use std::collections::HashMap;
use std::fs;
use std::hash::BuildHasher;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde_json::Value;
use crate::state::types::{AurComment, NewsFeedItem, NewsFeedSource};
use crate::util::parse_update_entry;
use super::rate_limit::{
check_circuit_breaker, extract_retry_after_from_error, increase_archlinux_backoff,
rate_limit_archlinux, record_circuit_breaker_outcome, reset_archlinux_backoff,
};
use super::updates::{AurVersionInfo, FetchDateResult};
pub(super) fn build_official_update_item(
pkg: &crate::state::PackageItem,
last_seen: Option<&String>,
old_version: Option<&str>,
remote_version: &str,
pkg_date: Option<String>,
) -> NewsFeedItem {
let date = pkg_date.unwrap_or_else(|| Utc::now().date_naive().to_string());
let url = if let crate::state::Source::Official { repo, arch } = &pkg.source {
let repo_lc = repo.to_lowercase();
let arch_slug = if arch.is_empty() {
std::env::consts::ARCH
} else {
arch.as_str()
};
Some(format!(
"https://archlinux.org/packages/{repo}/{arch}/{name}/",
repo = repo_lc,
arch = arch_slug,
name = pkg.name
))
} else {
None
};
let summary = old_version
.and_then(|prev| {
if prev == remote_version {
None
} else {
Some(format!("{prev} → {remote_version}"))
}
})
.or_else(|| {
last_seen.and_then(|prev| {
if prev == remote_version {
None
} else {
Some(format!("{prev} → {remote_version}"))
}
})
});
NewsFeedItem {
id: format!("pkg-update:official:{}:{remote_version}", pkg.name),
date,
title: pkg.name.clone(),
summary,
url,
source: NewsFeedSource::InstalledPackageUpdate,
severity: None,
packages: vec![pkg.name.clone()],
}
}
pub(super) fn build_aur_update_item(
pkg: &AurVersionInfo,
last_seen: Option<&String>,
old_version: Option<&str>,
remote_version: &str,
) -> NewsFeedItem {
let date = pkg
.last_modified
.and_then(ts_to_date_string)
.unwrap_or_else(|| Utc::now().date_naive().to_string());
let summary = old_version
.map(|prev| format!("{prev} → {remote_version}"))
.or_else(|| last_seen.map(|prev| format!("{prev} → {remote_version}")))
.or_else(|| Some(remote_version.to_string()));
NewsFeedItem {
id: format!("pkg-update:aur:{}:{remote_version}", pkg.name),
date,
title: format!("{} updated to {remote_version}", pkg.name),
summary,
url: Some(format!("https://aur.archlinux.org/packages/{}", pkg.name)),
source: NewsFeedSource::AurPackageUpdate,
severity: None,
packages: vec![pkg.name.clone()],
}
}
pub(super) fn ts_to_date_string(ts: i64) -> Option<String> {
DateTime::<Utc>::from_timestamp(ts, 0).map(|dt| dt.date_naive().to_string())
}
fn build_arch_candidates(arch: &str) -> Vec<String> {
if arch.is_empty() || arch.eq_ignore_ascii_case("x86_64") {
vec!["x86_64".to_string(), "any".to_string()]
} else if arch.eq_ignore_ascii_case("any") {
vec!["any".to_string()]
} else {
vec![arch.to_string(), "any".to_string()]
}
}
fn build_repo_candidates(repo: &str) -> Vec<String> {
if repo.is_empty() {
vec!["core".to_string(), "extra".to_string()]
} else {
let repo_lower = repo.to_lowercase();
if repo_lower == "core" {
vec!["core".to_string(), "extra".to_string()]
} else if repo_lower == "extra" {
vec!["extra".to_string(), "core".to_string()]
} else {
vec![repo_lower, "extra".to_string(), "core".to_string()]
}
}
}
async fn try_fetch_package_json(
name: &str,
repo_candidates: &[String],
arch_candidates: &[String],
) -> Result<serde_json::Value, String> {
let mut last_error = String::new();
for repo in repo_candidates {
for arch in arch_candidates {
let url = format!("https://archlinux.org/packages/{repo}/{arch}/{name}/json/",);
let fetch_result = tokio::time::timeout(
tokio::time::Duration::from_millis(2000),
tokio::task::spawn_blocking({
let url = url.clone();
move || crate::util::curl::curl_json(&url)
}),
)
.await;
match fetch_result {
Ok(Ok(Ok(json))) => {
tracing::debug!(
package = %name,
repo = %repo,
arch = %arch,
"successfully fetched package JSON"
);
return Ok(json);
}
Ok(Ok(Err(e))) => {
let error_str = e.to_string();
if error_str.contains("404") {
tracing::debug!(
package = %name,
repo = %repo,
arch = %arch,
"package not found at this URL, trying next candidate"
);
last_error = error_str;
continue;
}
return Err(error_str);
}
Ok(Err(e)) => {
return Err(format!("task join error: {e}"));
}
Err(_) => {
return Err("timeout".to_string());
}
}
}
}
Err(last_error)
}
pub(super) async fn fetch_official_package_date(
pkg: &crate::state::PackageItem,
) -> FetchDateResult {
let crate::state::Source::Official { repo, arch } = &pkg.source else {
return FetchDateResult::Success(None);
};
let endpoint_pattern = "/packages/*/json/";
let repo_slug = repo.to_lowercase();
let arch_candidates = build_arch_candidates(arch);
let repo_candidates = build_repo_candidates(&repo_slug);
let first_arch = arch_candidates.first().map_or("x86_64", String::as_str);
let first_repo = repo_candidates
.first()
.map_or("extra", |s| s.as_str())
.to_lowercase();
let cache_path = crate::sources::feeds::updates::official_json_cache_path(
&first_repo,
first_arch,
&pkg.name,
);
if let Err(e) = check_circuit_breaker(endpoint_pattern) {
tracing::debug!(
package = %pkg.name,
error = %e,
"circuit breaker blocking package date fetch, trying cached JSON"
);
return extract_date_from_cached_json(&cache_path)
.map_or(FetchDateResult::NeedsRetry, |date| {
FetchDateResult::CachedFallback(Some(date))
});
}
let _permit = rate_limit_archlinux().await;
let result = try_fetch_package_json(&pkg.name, &repo_candidates, &arch_candidates).await;
match result {
Ok(json) => {
reset_archlinux_backoff();
record_circuit_breaker_outcome(endpoint_pattern, true);
let old_json = crate::sources::feeds::updates::load_official_json_cache(&cache_path);
if let Some(old_json) = old_json
&& let Some(change_desc) =
crate::sources::feeds::updates::compare_official_json_changes(
&old_json, &json, &pkg.name,
)
{
if let Ok(mut cache) =
crate::sources::feeds::updates::OFFICIAL_JSON_CHANGES_CACHE.lock()
{
cache.insert(pkg.name.clone(), change_desc);
}
}
if let Err(e) =
crate::sources::feeds::updates::save_official_json_cache(&cache_path, &json)
{
tracing::debug!(
error = %e,
path = ?cache_path,
"failed to save official package JSON cache"
);
}
let date = extract_date_from_pkg_json(&json)
.or_else(|| json.get("pkg").and_then(extract_date_from_pkg_json));
FetchDateResult::Success(date)
}
Err(error_str) => {
if error_str.contains("404") {
tracing::debug!(
package = %pkg.name,
"package not found in any repository JSON API (may be a virtual package)"
);
record_circuit_breaker_outcome(endpoint_pattern, true);
return FetchDateResult::Success(None);
}
if error_str.contains("429")
|| error_str.contains("502")
|| error_str.contains("503")
|| error_str.contains("504")
{
let retry_after = extract_retry_after_from_error(&error_str);
increase_archlinux_backoff(retry_after);
tracing::warn!(
package = %pkg.name,
error = %error_str,
"rate limited fetching official package date"
);
} else if error_str.contains("timeout") {
increase_archlinux_backoff(None);
tracing::debug!(
package = %pkg.name,
"timeout fetching official package date, trying cached JSON"
);
} else {
increase_archlinux_backoff(None);
tracing::warn!(
package = %pkg.name,
error = %error_str,
"failed to fetch official package date"
);
}
record_circuit_breaker_outcome(endpoint_pattern, false);
cached_fallback_or_retry(&cache_path)
}
}
}
fn cached_fallback_or_retry(cache_path: &std::path::Path) -> FetchDateResult {
extract_date_from_cached_json(cache_path).map_or(FetchDateResult::NeedsRetry, |date| {
FetchDateResult::CachedFallback(Some(date))
})
}
fn extract_date_from_cached_json(cache_path: &std::path::Path) -> Option<String> {
let cached_json = crate::sources::feeds::updates::load_official_json_cache(cache_path)?;
extract_date_from_pkg_json(&cached_json)
.or_else(|| cached_json.get("pkg").and_then(extract_date_from_pkg_json))
}
pub(super) fn extract_date_from_pkg_json(obj: &Value) -> Option<String> {
obj.get("last_update")
.and_then(Value::as_str)
.and_then(normalize_pkg_date)
.or_else(|| {
obj.get("build_date")
.and_then(Value::as_str)
.and_then(normalize_pkg_date)
})
}
pub(super) fn normalize_pkg_date(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
return Some(dt.date_naive().to_string());
}
if let Ok(dt) = DateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.fZ") {
return Some(dt.date_naive().to_string());
}
if let Ok(dt) = DateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M %Z") {
return Some(dt.date_naive().to_string());
}
let prefix = trimmed.chars().take(10).collect::<String>();
if prefix.len() == 10
&& prefix.as_bytes()[4] == b'-'
&& prefix.as_bytes()[7] == b'-'
&& prefix[..4].chars().all(|c| c.is_ascii_digit())
&& prefix[5..7].chars().all(|c| c.is_ascii_digit())
&& prefix[8..10].chars().all(|c| c.is_ascii_digit())
{
return Some(prefix);
}
None
}
pub(super) fn normalize_comment_date(date: &str) -> String {
date.split_whitespace().next().map_or_else(
|| Utc::now().date_naive().to_string(),
std::string::ToString::to_string,
)
}
pub(super) fn summarize_comment(content: &str) -> String {
const MAX: usize = 180;
if content.chars().count() <= MAX {
return content.to_string();
}
let mut out = content.chars().take(MAX).collect::<String>();
out.push('…');
out
}
pub(super) fn load_update_versions(
path: Option<&PathBuf>,
) -> Option<HashMap<String, (String, String)>> {
let path = path?;
let data = fs::read_to_string(path).ok()?;
let mut map: HashMap<String, (String, String)> = HashMap::new();
for line in data.lines() {
if let Some((name, old_v, new_v)) = parse_update_entry(line) {
map.insert(name, (old_v, new_v));
}
}
if map.is_empty() { None } else { Some(map) }
}
pub(super) fn update_seen_for_comments<H>(
pkgname: &str,
comments: &[AurComment],
seen_aur_comments: &mut HashMap<String, String, H>,
remaining_allowance: usize,
force_emit_all: bool,
) -> Vec<NewsFeedItem>
where
H: BuildHasher + Send + Sync + 'static,
{
let mut emitted = Vec::new();
let latest_id = comments
.first()
.and_then(|c| c.id.clone().or_else(|| c.date_url.clone()));
let prev_seen = seen_aur_comments.get(pkgname).cloned();
if let Some(ref latest) = latest_id {
seen_aur_comments.insert(pkgname.to_string(), latest.clone());
}
for comment in comments {
if emitted.len() >= remaining_allowance {
break;
}
let cid = comment
.id
.as_ref()
.or(comment.date_url.as_ref())
.unwrap_or(&comment.date);
if !force_emit_all && prev_seen.as_deref() == Some(cid) {
break;
}
emitted.push(NewsFeedItem {
id: format!("aur-comment:{pkgname}:{cid}"),
date: normalize_comment_date(&comment.date),
title: format!("New AUR comment on {pkgname}"),
summary: Some(summarize_comment(&comment.content)),
url: comment.date_url.clone(),
source: NewsFeedSource::AurComment,
severity: None,
packages: vec![pkgname.to_string()],
});
}
emitted
}