use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet};
use std::hash::{BuildHasher, Hash, Hasher};
use std::path::PathBuf;
use std::sync::{LazyLock, Mutex};
use std::time::Instant;
use futures::stream::{self, StreamExt};
use serde_json::Value;
use tracing::{debug, info, warn};
use crate::state::types::NewsFeedItem;
use super::Result;
use super::cache::{AUR_COMMENTS_CACHE, SKIP_CACHE_TTL_SECONDS, UPDATES_CACHE};
use super::helpers::{
build_aur_update_item, build_official_update_item, fetch_official_package_date,
normalize_pkg_date, update_seen_for_comments,
};
use super::rate_limit::rate_limit;
#[derive(Debug, Clone)]
pub(super) enum FetchDateResult {
Success(Option<String>),
CachedFallback(Option<String>),
NeedsRetry,
}
static AUR_JSON_CHANGES_CACHE: LazyLock<Mutex<HashMap<String, String>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub(super) static OFFICIAL_JSON_CHANGES_CACHE: LazyLock<Mutex<HashMap<String, String>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone)]
pub(super) struct AurVersionInfo {
pub name: String,
pub version: String,
pub last_modified: Option<i64>,
}
#[derive(Clone)]
struct OfficialCandidate {
order: usize,
pkg: crate::state::PackageItem,
last_seen: Option<String>,
old_version: Option<String>,
remote_version: String,
}
fn process_official_packages<HV>(
installed_sorted: &[String],
seen_pkg_versions: &mut HashMap<String, String, HV>,
updates_versions: Option<&HashMap<String, (String, String)>>,
force_emit_all: bool,
mut remaining: usize,
) -> (
Vec<OfficialCandidate>,
Vec<String>,
usize,
usize,
usize,
usize,
)
where
HV: BuildHasher,
{
let mut aur_candidates: Vec<String> = Vec::new();
let mut official_candidates: Vec<OfficialCandidate> = Vec::new();
let mut baseline_only = 0usize;
let mut new_packages = 0usize;
let mut updated_packages = 0usize;
for name in installed_sorted {
if let Some(pkg) = crate::index::find_package_by_name(name) {
let (old_version_opt, remote_version) = updates_versions
.and_then(|m| m.get(&pkg.name))
.map_or((None, pkg.version.as_str()), |(old_v, new_v)| {
(Some(old_v.as_str()), new_v.as_str())
});
let remote_version = remote_version.to_string();
let last_seen = seen_pkg_versions.insert(pkg.name.clone(), remote_version.clone());
let is_new_package = last_seen.is_none();
let has_version_change = last_seen.as_ref() != Some(&remote_version);
let should_emit = remaining > 0 && (force_emit_all || has_version_change);
if should_emit {
if is_new_package {
new_packages = new_packages.saturating_add(1);
} else if has_version_change {
updated_packages = updated_packages.saturating_add(1);
}
let order = official_candidates.len();
official_candidates.push(OfficialCandidate {
order,
pkg: pkg.clone(),
last_seen,
old_version: old_version_opt.map(str::to_string),
remote_version,
});
remaining = remaining.saturating_sub(1);
} else {
baseline_only = baseline_only.saturating_add(1);
}
} else {
aur_candidates.push(name.clone());
}
}
(
official_candidates,
aur_candidates,
new_packages,
updated_packages,
baseline_only,
remaining,
)
}
fn process_aur_packages<HV>(
aur_info: Vec<AurVersionInfo>,
seen_pkg_versions: &mut HashMap<String, String, HV>,
updates_versions: Option<&HashMap<String, (String, String)>>,
force_emit_all: bool,
mut remaining: usize,
) -> (Vec<NewsFeedItem>, usize, usize, usize, usize)
where
HV: BuildHasher,
{
let mut items = Vec::new();
let mut aur_new_packages = 0usize;
let mut aur_updated_packages = 0usize;
let mut baseline_only = 0usize;
for pkg in aur_info {
if remaining == 0 {
break;
}
let (old_version_opt, remote_version) = updates_versions
.and_then(|m| m.get(&pkg.name))
.map_or((None, pkg.version.as_str()), |(old_v, new_v)| {
(Some(old_v.as_str()), new_v.as_str())
});
let remote_version = remote_version.to_string();
let last_seen = seen_pkg_versions.insert(pkg.name.clone(), remote_version.clone());
let is_new_package = last_seen.is_none();
let has_version_change = last_seen.as_ref() != Some(&remote_version);
let should_emit = remaining > 0 && (force_emit_all || has_version_change);
if should_emit {
if is_new_package {
aur_new_packages = aur_new_packages.saturating_add(1);
} else if has_version_change {
aur_updated_packages = aur_updated_packages.saturating_add(1);
}
items.push(build_aur_update_item(
&pkg,
last_seen.as_ref(),
old_version_opt,
&remote_version,
));
remaining = remaining.saturating_sub(1);
} else {
baseline_only = baseline_only.saturating_add(1);
}
}
(
items,
aur_new_packages,
aur_updated_packages,
baseline_only,
remaining,
)
}
const MAX_RETRIES_PER_PACKAGE: u8 = 3;
const RETRY_BASE_DELAY_MS: u64 = 10_000;
const RETRY_DELAY_MULTIPLIER: u64 = 2;
#[derive(Clone)]
struct BackgroundRetryCandidate {
pkg_name: String,
repo_slug: String,
arch_slug: String,
retry_count: u8,
}
async fn fetch_official_dates_with_retry(
candidates: Vec<OfficialCandidate>,
) -> Vec<(usize, NewsFeedItem)> {
let mut retry_queue: Vec<BackgroundRetryCandidate> = Vec::new();
let mut official_items: Vec<(usize, NewsFeedItem)> = Vec::new();
let fetch_results: Vec<(OfficialCandidate, FetchDateResult)> = stream::iter(candidates)
.map(|candidate| async move {
let result = fetch_official_package_date(&candidate.pkg).await;
(candidate, result)
})
.buffer_unordered(5)
.collect::<Vec<_>>()
.await;
for (candidate, result) in fetch_results {
match result {
FetchDateResult::Success(date) | FetchDateResult::CachedFallback(date) => {
let item = build_official_update_item(
&candidate.pkg,
candidate.last_seen.as_ref(),
candidate.old_version.as_deref(),
&candidate.remote_version,
date,
);
official_items.push((candidate.order, item));
}
FetchDateResult::NeedsRetry => {
debug!(
package = %candidate.pkg.name,
"package needs retry, using today's date and queuing for background retry"
);
let item = build_official_update_item(
&candidate.pkg,
candidate.last_seen.as_ref(),
candidate.old_version.as_deref(),
&candidate.remote_version,
None, );
official_items.push((candidate.order, item));
if let crate::state::Source::Official { repo, arch } = &candidate.pkg.source {
let repo_slug = repo.to_lowercase();
let arch_slug = if arch.is_empty() {
std::env::consts::ARCH.to_string()
} else {
arch.clone()
};
retry_queue.push(BackgroundRetryCandidate {
pkg_name: candidate.pkg.name.clone(),
repo_slug,
arch_slug,
retry_count: 0,
});
}
}
}
}
if !retry_queue.is_empty() {
info!(
"spawning background retry task for {} packages",
retry_queue.len()
);
tokio::spawn(process_retry_queue_background(retry_queue));
}
official_items
}
async fn process_retry_queue_background(initial_queue: Vec<BackgroundRetryCandidate>) {
use std::collections::VecDeque;
let mut retry_queue: VecDeque<BackgroundRetryCandidate> = initial_queue.into_iter().collect();
info!(
"background retry task started with {} packages",
retry_queue.len()
);
while let Some(mut retry_item) = retry_queue.pop_front() {
retry_item.retry_count += 1;
let delay_ms = RETRY_BASE_DELAY_MS
* RETRY_DELAY_MULTIPLIER
.saturating_pow(u32::from(retry_item.retry_count).saturating_sub(1));
info!(
package = %retry_item.pkg_name,
retry_attempt = retry_item.retry_count,
queue_remaining = retry_queue.len(),
delay_ms,
"background retry: waiting before attempt"
);
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
let result = fetch_official_json_for_cache(
&retry_item.pkg_name,
&retry_item.repo_slug,
&retry_item.arch_slug,
)
.await;
match result {
Ok(()) => {
info!(
package = %retry_item.pkg_name,
retry_attempt = retry_item.retry_count,
"background retry succeeded, cache updated"
);
}
Err(needs_retry) if needs_retry => {
if retry_item.retry_count < MAX_RETRIES_PER_PACKAGE {
debug!(
package = %retry_item.pkg_name,
retry_attempt = retry_item.retry_count,
"background retry failed, adding back to end of queue"
);
retry_queue.push_back(retry_item);
} else {
warn!(
package = %retry_item.pkg_name,
max_retries = MAX_RETRIES_PER_PACKAGE,
"background retry: all attempts exhausted"
);
}
}
Err(_) => {
debug!(
package = %retry_item.pkg_name,
"background retry: completed (cache or non-retryable)"
);
}
}
}
info!("background retry task completed");
}
async fn fetch_official_json_for_cache(
pkg_name: &str,
repo_slug: &str,
arch_slug: &str,
) -> std::result::Result<(), bool> {
use super::rate_limit::{
check_circuit_breaker, increase_archlinux_backoff, rate_limit_archlinux,
record_circuit_breaker_outcome, reset_archlinux_backoff,
};
let url = format!("https://archlinux.org/packages/{repo_slug}/{arch_slug}/{pkg_name}/json/",);
let endpoint_pattern = "/packages/*/json/";
let cache_path = official_json_cache_path(repo_slug, arch_slug, pkg_name);
if check_circuit_breaker(endpoint_pattern).is_err() {
debug!(
package = %pkg_name,
"background retry: circuit breaker blocking"
);
return Err(true); }
let _permit = rate_limit_archlinux().await;
let result = tokio::time::timeout(
tokio::time::Duration::from_millis(5000),
tokio::task::spawn_blocking({
let url = url.clone();
move || crate::util::curl::curl_json(&url)
}),
)
.await;
match result {
Ok(Ok(Ok(json))) => {
reset_archlinux_backoff();
record_circuit_breaker_outcome(endpoint_pattern, true);
if let Err(e) = save_official_json_cache(&cache_path, &json) {
debug!(
error = %e,
package = %pkg_name,
"background retry: failed to save cache"
);
}
Ok(())
}
Ok(Ok(Err(e))) => {
increase_archlinux_backoff(None);
record_circuit_breaker_outcome(endpoint_pattern, false);
debug!(
package = %pkg_name,
error = %e,
"background retry: fetch failed"
);
Err(true) }
Ok(Err(e)) => {
increase_archlinux_backoff(None);
record_circuit_breaker_outcome(endpoint_pattern, false);
debug!(
package = %pkg_name,
error = ?e,
"background retry: task join failed"
);
Err(true) }
Err(_) => {
increase_archlinux_backoff(None);
record_circuit_breaker_outcome(endpoint_pattern, false);
debug!(package = %pkg_name, "background retry: timeout");
Err(true) }
}
}
#[must_use]
fn aur_json_cache_dir() -> PathBuf {
crate::theme::lists_dir().join("aur_json_cache")
}
const AUR_CACHE_KEY_MAX_LEN: usize = 200;
fn aur_json_cache_path(pkgnames: &[String]) -> PathBuf {
let mut sorted = pkgnames.to_vec();
sorted.sort();
let key = sorted.join(",");
let safe_key = key
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ',' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect::<String>();
let filename_key = if safe_key.len() <= AUR_CACHE_KEY_MAX_LEN {
safe_key
} else {
let mut hasher = DefaultHasher::new();
key.hash(&mut hasher);
format!("{:016x}", hasher.finish())
};
aur_json_cache_dir().join(format!("{filename_key}.json"))
}
fn load_aur_json_cache(cache_path: &PathBuf) -> Option<Value> {
let data = std::fs::read_to_string(cache_path).ok()?;
serde_json::from_str::<Value>(&data).ok()
}
fn save_aur_json_cache(cache_path: &PathBuf, json: &Value) -> std::io::Result<()> {
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent)?;
}
let pretty = serde_json::to_string_pretty(json)?;
std::fs::write(cache_path, pretty)
}
fn compare_aur_json_changes(old_json: &Value, new_json: &Value, pkg_name: &str) -> Option<String> {
let mut changes = Vec::new();
let old_version = old_json.get("Version").and_then(Value::as_str);
let new_version = new_json.get("Version").and_then(Value::as_str);
if old_version != new_version
&& let (Some(old_v), Some(new_v)) = (old_version, new_version)
&& old_v != new_v
{
changes.push(format!("Version: {old_v} → {new_v}"));
}
let old_desc = old_json.get("Description").and_then(Value::as_str);
let new_desc = new_json.get("Description").and_then(Value::as_str);
if old_desc != new_desc
&& let (Some(old_d), Some(new_d)) = (old_desc, new_desc)
&& old_d != new_d
{
changes.push("Description changed".to_string());
}
let old_maintainer = old_json.get("Maintainer").and_then(Value::as_str);
let new_maintainer = new_json.get("Maintainer").and_then(Value::as_str);
if old_maintainer != new_maintainer
&& let (Some(old_m), Some(new_m)) = (old_maintainer, new_maintainer)
&& old_m != new_m
{
changes.push(format!("Maintainer: {old_m} → {new_m}"));
}
let old_url = old_json.get("URL").and_then(Value::as_str);
let new_url = new_json.get("URL").and_then(Value::as_str);
if old_url != new_url
&& let (Some(old_u), Some(new_u)) = (old_url, new_url)
&& old_u != new_u
{
changes.push("URL changed".to_string());
}
let old_license = old_json.get("License").and_then(Value::as_array);
let new_license = new_json.get("License").and_then(Value::as_array);
if old_license != new_license {
changes.push("License changed".to_string());
}
let old_keywords = old_json.get("Keywords").and_then(Value::as_array);
let new_keywords = new_json.get("Keywords").and_then(Value::as_array);
if old_keywords != new_keywords {
changes.push("Keywords changed".to_string());
}
if changes.is_empty() {
None
} else {
Some(format!(
"Changes detected for {pkg_name}:\n{}",
changes.join("\n")
))
}
}
#[must_use]
fn official_json_cache_dir() -> PathBuf {
crate::theme::lists_dir().join("official_json_cache")
}
#[must_use]
pub fn official_json_cache_path(repo: &str, arch: &str, pkg_name: &str) -> PathBuf {
let safe_repo = repo
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect::<String>();
let safe_arch = arch
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect::<String>();
let safe_name = pkg_name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect::<String>();
official_json_cache_dir().join(format!("{safe_repo}_{safe_arch}_{safe_name}.json"))
}
#[must_use]
pub fn load_official_json_cache(cache_path: &std::path::Path) -> Option<Value> {
let data = std::fs::read_to_string(cache_path).ok()?;
serde_json::from_str::<Value>(&data).ok()
}
pub(super) fn save_official_json_cache(cache_path: &PathBuf, json: &Value) -> std::io::Result<()> {
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent)?;
}
let pretty = serde_json::to_string_pretty(json)?;
std::fs::write(cache_path, pretty)
}
pub(super) fn compare_official_json_changes(
old_json: &Value,
new_json: &Value,
pkg_name: &str,
) -> Option<String> {
let mut changes = Vec::new();
let old_pkg = old_json.get("pkg").unwrap_or(old_json);
let new_pkg = new_json.get("pkg").unwrap_or(new_json);
let old_version = old_pkg.get("pkgver").and_then(Value::as_str);
let new_version = new_pkg.get("pkgver").and_then(Value::as_str);
if old_version != new_version
&& let (Some(old_v), Some(new_v)) = (old_version, new_version)
&& old_v != new_v
{
changes.push(format!("Version: {old_v} → {new_v}"));
}
let old_desc = old_pkg.get("pkgdesc").and_then(Value::as_str);
let new_desc = new_pkg.get("pkgdesc").and_then(Value::as_str);
if old_desc != new_desc
&& let (Some(old_d), Some(new_d)) = (old_desc, new_desc)
&& old_d != new_d
{
changes.push("Description changed".to_string());
}
let old_licenses = old_pkg.get("licenses").and_then(Value::as_array);
let new_licenses = new_pkg.get("licenses").and_then(Value::as_array);
if old_licenses != new_licenses {
changes.push("Licenses changed".to_string());
}
let old_url = old_pkg.get("url").and_then(Value::as_str);
let new_url = new_pkg.get("url").and_then(Value::as_str);
if old_url != new_url
&& let (Some(old_u), Some(new_u)) = (old_url, new_url)
&& old_u != new_u
{
changes.push("URL changed".to_string());
}
let old_groups = old_pkg.get("groups").and_then(Value::as_array);
let new_groups = new_pkg.get("groups").and_then(Value::as_array);
if old_groups != new_groups {
changes.push("Groups changed".to_string());
}
let old_depends = old_pkg.get("depends").and_then(Value::as_array);
let new_depends = new_pkg.get("depends").and_then(Value::as_array);
if old_depends != new_depends {
changes.push("Dependencies changed".to_string());
}
let old_last_update = old_json.get("last_update").and_then(Value::as_str);
let new_last_update = new_json.get("last_update").and_then(Value::as_str);
if old_last_update != new_last_update
&& let (Some(old_date), Some(new_date)) = (old_last_update, new_last_update)
&& old_date != new_date
{
if let (Some(old_norm), Some(new_norm)) =
(normalize_pkg_date(old_date), normalize_pkg_date(new_date))
&& old_norm != new_norm
{
changes.push(format!("Last update: {old_norm} → {new_norm}"));
}
}
if changes.is_empty() {
None
} else {
Some(format!(
"Changes detected for {pkg_name}:\n{}",
changes.join("\n")
))
}
}
#[must_use]
pub fn get_aur_json_changes(pkg_name: &str) -> Option<String> {
AUR_JSON_CHANGES_CACHE
.lock()
.ok()
.and_then(|cache| cache.get(pkg_name).cloned())
}
#[must_use]
pub fn get_official_json_changes(pkg_name: &str) -> Option<String> {
OFFICIAL_JSON_CHANGES_CACHE
.lock()
.ok()
.and_then(|cache| cache.get(pkg_name).cloned())
}
async fn fetch_aur_versions(pkgnames: &[String]) -> Result<Vec<AurVersionInfo>> {
if pkgnames.is_empty() {
return Ok(Vec::new());
}
let args: String = pkgnames
.iter()
.map(|n| format!("arg[]={}", crate::util::percent_encode(n)))
.collect::<Vec<String>>()
.join("&");
let url = format!("https://aur.archlinux.org/rpc/v5/info?{args}");
rate_limit().await;
let cache_path = aur_json_cache_path(pkgnames);
let old_json = load_aur_json_cache(&cache_path);
let resp = tokio::task::spawn_blocking(move || crate::util::curl::curl_json(&url)).await??;
if let Some(old_json) = old_json
&& let Some(results_old) = old_json.get("results").and_then(Value::as_array)
&& let Some(results_new) = resp.get("results").and_then(Value::as_array)
{
let old_map: HashMap<String, &Value> = results_old
.iter()
.filter_map(|obj| {
obj.get("Name")
.and_then(Value::as_str)
.map(|name| (name.to_string(), obj))
})
.collect();
let new_map: HashMap<String, &Value> = results_new
.iter()
.filter_map(|obj| {
obj.get("Name")
.and_then(Value::as_str)
.map(|name| (name.to_string(), obj))
})
.collect();
let mut changes_cache = AUR_JSON_CHANGES_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for (pkg_name, new_obj) in &new_map {
if let Some(old_obj) = old_map.get(pkg_name)
&& let Some(change_desc) = compare_aur_json_changes(old_obj, new_obj, pkg_name)
{
changes_cache.insert(pkg_name.clone(), change_desc);
}
}
}
if let Err(e) = save_aur_json_cache(&cache_path, &resp) {
warn!(error = %e, path = ?cache_path, "failed to save AUR JSON cache");
} else {
debug!(path = ?cache_path, "saved AUR JSON cache");
}
let results = resp
.get("results")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut out = Vec::new();
for obj in results {
if let Some(name) = obj.get("Name").and_then(serde_json::Value::as_str) {
let version = obj
.get("Version")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let last_modified = obj.get("LastModified").and_then(serde_json::Value::as_i64);
out.push(AurVersionInfo {
name: name.to_string(),
version,
last_modified,
});
}
}
Ok(out)
}
pub(super) async fn fetch_installed_updates<HS, HV>(
installed: &HashSet<String, HS>,
limit: usize,
seen_pkg_versions: &mut HashMap<String, String, HV>,
force_emit_all: bool,
updates_versions: Option<&HashMap<String, (String, String)>>,
) -> Result<Vec<NewsFeedItem>>
where
HS: BuildHasher + Send + Sync + 'static,
HV: BuildHasher + Send + Sync + 'static,
{
if let Ok(cache_guard) = UPDATES_CACHE.lock()
&& let Some((cached_items, last_fetch)) = cache_guard.as_ref()
&& last_fetch.elapsed().as_secs() < SKIP_CACHE_TTL_SECONDS
{
info!(
"fetch_installed_updates: using cached results (age={}s, items={})",
last_fetch.elapsed().as_secs(),
cached_items.len()
);
return Ok(cached_items.clone());
}
debug!(
"fetch_installed_updates: starting, installed_count={}, limit={}, force_emit_all={}",
installed.len(),
limit,
force_emit_all
);
let mut items = Vec::new();
let mut installed_sorted: Vec<String> = installed.iter().cloned().collect();
installed_sorted.sort();
debug!(
"fetch_installed_updates: processing {} installed packages",
installed_sorted.len()
);
let (
official_candidates,
aur_candidates,
new_packages,
updated_packages,
baseline_only,
remaining,
) = process_official_packages(
&installed_sorted,
seen_pkg_versions,
updates_versions,
force_emit_all,
limit,
);
info!(
"fetch_installed_updates: official scan complete, new_packages={}, updated_packages={}, baseline_only={}",
new_packages, updated_packages, baseline_only
);
if !official_candidates.is_empty() {
debug!(
"fetch_installed_updates: fetching dates for {} official packages (rate-limited)",
official_candidates.len()
);
let mut official_items = fetch_official_dates_with_retry(official_candidates).await;
official_items.sort_by_key(|(order, _)| *order);
for (_, item) in official_items {
items.push(item);
}
debug!(
"fetch_installed_updates: official packages processed, items={}, aur_candidates={}, remaining={}",
items.len(),
aur_candidates.len(),
remaining
);
}
if aur_candidates.is_empty() {
debug!("fetch_installed_updates: no AUR candidates, skipping AUR fetch");
return Ok(items);
}
debug!(
"fetch_installed_updates: fetching AUR versions for {} candidates",
aur_candidates.len()
);
let aur_info = fetch_aur_versions(&aur_candidates).await?;
debug!(
"fetch_installed_updates: fetched {} AUR package versions",
aur_info.len()
);
let aur_remaining = limit / 2;
let (mut aur_items, aur_new_packages, aur_updated_packages, aur_baseline_only, _remaining) =
process_aur_packages(
aur_info,
seen_pkg_versions,
updates_versions,
force_emit_all,
aur_remaining,
);
items.append(&mut aur_items);
let baseline_only = baseline_only.saturating_add(aur_baseline_only);
info!(
emitted = items.len(),
new_packages,
updated_packages,
aur_new_packages,
aur_updated_packages,
baseline_only,
installed_total = installed.len(),
aur_candidates = aur_candidates.len(),
"installed update feed built"
);
if let Ok(mut cache_guard) = UPDATES_CACHE.lock() {
*cache_guard = Some((items.clone(), Instant::now()));
}
Ok(items)
}
pub(super) async fn fetch_installed_aur_comments<HS, HC>(
installed: &HashSet<String, HS>,
limit: usize,
seen_aur_comments: &mut HashMap<String, String, HC>,
force_emit_all: bool,
) -> Result<Vec<NewsFeedItem>>
where
HS: BuildHasher + Send + Sync + 'static,
HC: BuildHasher + Send + Sync + 'static,
{
if let Ok(cache_guard) = AUR_COMMENTS_CACHE.lock()
&& let Some((cached_items, last_fetch)) = cache_guard.as_ref()
&& last_fetch.elapsed().as_secs() < SKIP_CACHE_TTL_SECONDS
{
info!(
"fetch_installed_aur_comments: using cached results (age={}s, items={})",
last_fetch.elapsed().as_secs(),
cached_items.len()
);
return Ok(cached_items.clone());
}
let mut items = Vec::new();
if limit == 0 {
return Ok(items);
}
let mut aur_names: Vec<String> = installed
.iter()
.filter_map(|name| {
if crate::index::find_package_by_name(name).is_some() {
None
} else {
Some(name.clone())
}
})
.collect();
aur_names.sort();
let mut baseline_only = 0usize;
for pkgname in &aur_names {
if items.len() >= limit {
break;
}
match crate::sources::fetch_aur_comments(pkgname.clone()).await {
Ok(comments) => {
if comments.is_empty() {
continue;
}
let newly_seen = update_seen_for_comments(
pkgname,
&comments,
seen_aur_comments,
limit.saturating_sub(items.len()),
force_emit_all,
);
if newly_seen.is_empty() {
baseline_only = baseline_only.saturating_add(1);
}
items.extend(newly_seen);
}
Err(e) => warn!(error = %e, pkg = %pkgname, "failed to fetch AUR comments"),
}
}
debug!(
candidates = aur_names.len(),
emitted = items.len(),
baseline_only,
"installed AUR comments feed built"
);
if let Ok(mut cache_guard) = AUR_COMMENTS_CACHE.lock() {
*cache_guard = Some((items.clone(), Instant::now()));
}
Ok(items)
}
#[cfg(test)]
mod tests {
use super::aur_json_cache_path;
#[test]
fn aur_json_cache_path_long_list_uses_short_filename() {
let many: Vec<String> = (0..60).map(|i| format!("pkg-{i}")).collect();
let path = aur_json_cache_path(&many);
let name = path.file_name().expect("has filename").to_string_lossy();
assert!(
name.len() <= 255,
"filename must not exceed NAME_MAX: len={}",
name.len()
);
assert!(name.ends_with(".json"));
assert!(
name.len() <= 25,
"long key should use hash (short name): {name}"
);
}
#[test]
fn aur_json_cache_path_deterministic() {
let a = vec!["b".into(), "a".into()];
let b = vec!["a".into(), "b".into()];
assert_eq!(aur_json_cache_path(&a), aur_json_cache_path(&b));
}
}