mod cache;
mod helpers;
mod news_fetch;
mod rate_limit;
mod updates;
use std::collections::{HashMap, HashSet};
use std::hash::BuildHasher;
use std::path::PathBuf;
use crate::state::types::{NewsFeedItem, NewsSortMode, severity_rank};
use tracing::{info, warn};
use helpers::load_update_versions;
use news_fetch::fetch_slow_sources;
use updates::{fetch_installed_aur_comments, fetch_installed_updates};
type Result<T> = super::Result<T>;
#[must_use]
pub fn optimize_max_age_for_startup(
last_startup: Option<&str>,
default_max_age: Option<u32>,
) -> Option<u32> {
let Some(ts) = last_startup else {
return default_max_age;
};
let parsed = chrono::NaiveDateTime::parse_from_str(ts, "%Y%m%d:%H%M%S").ok();
let Some(last_dt) = parsed else {
tracing::debug!(timestamp = %ts, "failed to parse last startup timestamp");
return default_max_age;
};
let now = chrono::Local::now().naive_local();
let elapsed = now.signed_duration_since(last_dt);
if elapsed.num_hours() < 1 {
info!(
hours_since_last = elapsed.num_hours(),
"recent startup detected, using minimal fetch window"
);
Some(1)
} else if elapsed.num_hours() < 24 {
info!(
hours_since_last = elapsed.num_hours(),
"startup within 24h, using 2-day fetch window"
);
Some(2)
} else if elapsed.num_days() < 7 {
let optimized = default_max_age.map_or(7, |d| d.min(7));
info!(
days_since_last = elapsed.num_days(),
optimized_max_age = optimized,
"startup within 7 days, using optimized fetch window"
);
Some(optimized)
} else {
default_max_age
}
}
#[allow(clippy::struct_excessive_bools)]
pub struct NewsFeedContext<'a, HS, HV, HC>
where
HS: BuildHasher + Send + Sync + 'static,
HV: BuildHasher + Send + Sync + 'static,
HC: BuildHasher + Send + Sync + 'static,
{
pub force_emit_all: bool,
pub updates_list_path: Option<PathBuf>,
pub limit: usize,
pub include_arch_news: bool,
pub include_advisories: bool,
pub include_pkg_updates: bool,
pub include_aur_comments: bool,
pub installed_filter: Option<&'a HashSet<String, HS>>,
pub installed_only: bool,
pub sort_mode: NewsSortMode,
pub seen_pkg_versions: &'a mut HashMap<String, String, HV>,
pub seen_aur_comments: &'a mut HashMap<String, String, HC>,
pub max_age_days: Option<u32>,
}
struct FastSourcesConfig<'a, HS, HV, HC> {
include_pkg_updates: bool,
include_aur_comments: bool,
installed_filter: Option<&'a HashSet<String, HS>>,
limit: usize,
seen_pkg_versions: &'a mut HashMap<String, String, HV>,
seen_aur_comments: &'a mut HashMap<String, String, HC>,
force_emit_all: bool,
updates_versions: Option<&'a HashMap<String, (String, String)>>,
}
async fn fetch_fast_sources<HS, HV, HC>(
config: FastSourcesConfig<'_, HS, HV, HC>,
) -> (
std::result::Result<Vec<NewsFeedItem>, Box<dyn std::error::Error + Send + Sync>>,
std::result::Result<Vec<NewsFeedItem>, Box<dyn std::error::Error + Send + Sync>>,
)
where
HS: BuildHasher + Send + Sync + 'static,
HV: BuildHasher + Send + Sync + 'static,
HC: BuildHasher + Send + Sync + 'static,
{
tokio::join!(
async {
if config.include_pkg_updates {
if let Some(installed) = config.installed_filter {
if installed.is_empty() {
warn!(
"include_pkg_updates set but installed set is empty; skipping updates"
);
Ok::<Vec<NewsFeedItem>, Box<dyn std::error::Error + Send + Sync>>(Vec::new())
} else {
info!(
"fetching package updates: installed_count={}, limit={}",
installed.len(),
config.limit
);
let result = fetch_installed_updates(
installed,
config.limit,
config.seen_pkg_versions,
config.force_emit_all,
config.updates_versions,
)
.await;
match &result {
Ok(updates) => {
info!("package updates fetch completed: items={}", updates.len());
}
Err(e) => {
warn!(error = %e, "installed package updates fetch failed");
}
}
match result {
Ok(updates) => Ok(updates),
Err(_e) => Ok::<
Vec<NewsFeedItem>,
Box<dyn std::error::Error + Send + Sync>,
>(Vec::new()),
}
}
} else {
warn!("include_pkg_updates set but installed_filter missing; skipping updates");
Ok::<Vec<NewsFeedItem>, Box<dyn std::error::Error + Send + Sync>>(Vec::new())
}
} else {
Ok::<Vec<NewsFeedItem>, Box<dyn std::error::Error + Send + Sync>>(Vec::new())
}
},
async {
if config.include_aur_comments {
if let Some(installed) = config.installed_filter {
if installed.is_empty() {
warn!(
"include_aur_comments set but installed set is empty; skipping comments"
);
Ok::<Vec<NewsFeedItem>, Box<dyn std::error::Error + Send + Sync>>(Vec::new())
} else {
info!(
"fetching AUR comments: installed_count={}, limit={}",
installed.len(),
config.limit
);
let result = fetch_installed_aur_comments(
installed,
config.limit,
config.seen_aur_comments,
config.force_emit_all,
)
.await;
match &result {
Ok(comments) => {
info!("AUR comments fetch completed: items={}", comments.len());
}
Err(e) => {
warn!(error = %e, "installed AUR comments fetch failed");
}
}
match result {
Ok(comments) => Ok(comments),
Err(_e) => Ok::<
Vec<NewsFeedItem>,
Box<dyn std::error::Error + Send + Sync>,
>(Vec::new()),
}
}
} else {
warn!(
"include_aur_comments set but installed_filter missing; skipping comments"
);
Ok::<Vec<NewsFeedItem>, Box<dyn std::error::Error + Send + Sync>>(Vec::new())
}
} else {
Ok::<Vec<NewsFeedItem>, Box<dyn std::error::Error + Send + Sync>>(Vec::new())
}
}
)
}
fn combine_feed_results(
arch_result: std::result::Result<Vec<NewsFeedItem>, Box<dyn std::error::Error + Send + Sync>>,
advisories_result: std::result::Result<
Vec<NewsFeedItem>,
Box<dyn std::error::Error + Send + Sync>,
>,
updates_result: std::result::Result<
Vec<NewsFeedItem>,
Box<dyn std::error::Error + Send + Sync>,
>,
comments_result: std::result::Result<
Vec<NewsFeedItem>,
Box<dyn std::error::Error + Send + Sync>,
>,
sort_mode: NewsSortMode,
) -> Vec<NewsFeedItem> {
let mut items: Vec<NewsFeedItem> = Vec::new();
match arch_result {
Ok(mut arch_items) => items.append(&mut arch_items),
Err(e) => warn!(error = %e, "arch news fetch failed; continuing without Arch news"),
}
match advisories_result {
Ok(mut adv_items) => items.append(&mut adv_items),
Err(e) => warn!(error = %e, "advisories fetch failed; continuing without advisories"),
}
match updates_result {
Ok(mut upd_items) => items.append(&mut upd_items),
Err(e) => warn!(error = %e, "updates fetch failed; continuing without updates"),
}
match comments_result {
Ok(mut cmt_items) => items.append(&mut cmt_items),
Err(e) => warn!(error = %e, "comments fetch failed; continuing without comments"),
}
sort_news_items(&mut items, sort_mode);
items
}
type PrepareFetchContextReturn<'a, HS, HV, HC> = (
Option<String>,
Option<HashMap<String, (String, String)>>,
usize,
bool,
bool,
bool,
bool,
Option<&'a HashSet<String, HS>>,
bool,
NewsSortMode,
&'a mut HashMap<String, String, HV>,
&'a mut HashMap<String, String, HC>,
bool,
);
fn prepare_fetch_context<HS, HV, HC>(
ctx: NewsFeedContext<'_, HS, HV, HC>,
) -> PrepareFetchContextReturn<'_, HS, HV, HC>
where
HS: BuildHasher + Send + Sync + 'static,
HV: BuildHasher + Send + Sync + 'static,
HC: BuildHasher + Send + Sync + 'static,
{
let NewsFeedContext {
limit,
include_arch_news,
include_advisories,
include_pkg_updates,
include_aur_comments,
installed_filter,
installed_only,
sort_mode,
seen_pkg_versions,
seen_aur_comments,
force_emit_all,
updates_list_path,
max_age_days,
} = ctx;
info!(
limit,
include_arch_news,
include_advisories,
include_pkg_updates,
include_aur_comments,
installed_only,
installed_filter = installed_filter.is_some(),
sort_mode = ?sort_mode,
max_age_days,
"fetch_news_feed start"
);
let cutoff_date = max_age_days.and_then(|days| {
chrono::Utc::now()
.checked_sub_signed(chrono::Duration::days(i64::from(days)))
.map(|dt| dt.format("%Y-%m-%d").to_string())
});
let updates_versions = if force_emit_all {
load_update_versions(updates_list_path.as_ref())
} else {
None
};
(
cutoff_date,
updates_versions,
limit,
include_arch_news,
include_advisories,
include_pkg_updates,
include_aur_comments,
installed_filter,
installed_only,
sort_mode,
seen_pkg_versions,
seen_aur_comments,
force_emit_all,
)
}
fn sort_news_items(items: &mut [NewsFeedItem], mode: NewsSortMode) {
match mode {
NewsSortMode::DateDesc => items.sort_by(|a, b| b.date.cmp(&a.date)),
NewsSortMode::DateAsc => items.sort_by(|a, b| a.date.cmp(&b.date)),
NewsSortMode::Title => {
items.sort_by(|a, b| {
a.title
.to_lowercase()
.cmp(&b.title.to_lowercase())
.then(b.date.cmp(&a.date))
});
}
NewsSortMode::SourceThenTitle => items.sort_by(|a, b| {
a.source
.cmp(&b.source)
.then(b.date.cmp(&a.date))
.then(a.title.to_lowercase().cmp(&b.title.to_lowercase()))
}),
NewsSortMode::SeverityThenDate => items.sort_by(|a, b| {
let sa = severity_rank(a.severity);
let sb = severity_rank(b.severity);
sb.cmp(&sa)
.then(b.date.cmp(&a.date))
.then(a.title.to_lowercase().cmp(&b.title.to_lowercase()))
}),
NewsSortMode::UnreadThenDate => {
items.sort_by(|a, b| b.date.cmp(&a.date));
}
}
}
pub async fn fetch_news_feed<HS, HV, HC>(
ctx: NewsFeedContext<'_, HS, HV, HC>,
) -> Result<Vec<NewsFeedItem>>
where
HS: BuildHasher + Send + Sync + 'static,
HV: BuildHasher + Send + Sync + 'static,
HC: BuildHasher + Send + Sync + 'static,
{
let (
cutoff_date,
updates_versions,
limit,
include_arch_news,
include_advisories,
include_pkg_updates,
include_aur_comments,
installed_filter,
installed_only,
sort_mode,
seen_pkg_versions,
seen_aur_comments,
force_emit_all,
) = prepare_fetch_context(ctx);
info!(
"starting fetch: arch_news={include_arch_news}, advisories={include_advisories}, pkg_updates={include_pkg_updates}, aur_comments={include_aur_comments}"
);
rate_limit::reset_archlinux_backoff();
let ((updates_result, comments_result), (arch_result, advisories_result)) = tokio::join!(
fetch_fast_sources(FastSourcesConfig {
include_pkg_updates,
include_aur_comments,
installed_filter,
limit,
seen_pkg_versions,
seen_aur_comments,
force_emit_all,
updates_versions: updates_versions.as_ref(),
}),
fetch_slow_sources(
include_arch_news,
include_advisories,
limit,
installed_filter,
installed_only,
cutoff_date.as_deref(),
)
);
info!("fetch completed, combining results...");
let items = combine_feed_results(
arch_result,
advisories_result,
updates_result,
comments_result,
sort_mode,
);
info!(
total = items.len(),
arch = items
.iter()
.filter(|i| matches!(i.source, crate::state::types::NewsFeedSource::ArchNews))
.count(),
advisories = items
.iter()
.filter(|i| matches!(
i.source,
crate::state::types::NewsFeedSource::SecurityAdvisory
))
.count(),
updates = items
.iter()
.filter(|i| {
matches!(
i.source,
crate::state::types::NewsFeedSource::InstalledPackageUpdate
| crate::state::types::NewsFeedSource::AurPackageUpdate
)
})
.count(),
aur_comments = items
.iter()
.filter(|i| matches!(i.source, crate::state::types::NewsFeedSource::AurComment))
.count(),
"fetch_news_feed success"
);
Ok(items)
}
const CONTINUATION_LIMIT: usize = 1000;
pub async fn fetch_continuation_items<HS, HI>(
installed: &HashSet<String, HS>,
initial_ids: &HashSet<String, HI>,
) -> Result<Vec<NewsFeedItem>>
where
HS: std::hash::BuildHasher + Send + Sync + 'static,
HI: std::hash::BuildHasher + Send + Sync,
{
use crate::state::types::NewsFeedSource;
info!(
installed_count = installed.len(),
initial_count = initial_ids.len(),
"starting continuation fetch"
);
let ((updates_result, comments_result), (arch_result, advisories_result)) = tokio::join!(
async {
let mut seen_versions: HashMap<String, String> = HashMap::new();
let mut seen_aur_comments: HashMap<String, String> = HashMap::new();
let updates = fetch_installed_updates(
installed,
CONTINUATION_LIMIT,
&mut seen_versions,
true, None,
)
.await;
let comments = fetch_installed_aur_comments(
installed,
CONTINUATION_LIMIT,
&mut seen_aur_comments,
true, )
.await;
(updates, comments)
},
fetch_slow_sources(
true, true, CONTINUATION_LIMIT,
Some(installed),
false, None, )
);
let mut items = Vec::new();
if let Ok(arch_items) = arch_result {
for item in arch_items {
if !initial_ids.contains(&item.id) {
items.push(item);
}
}
}
if let Ok(adv_items) = advisories_result {
for item in adv_items {
if !initial_ids.contains(&item.id) {
items.push(item);
}
}
}
if let Ok(upd_items) = updates_result {
for item in upd_items {
if !initial_ids.contains(&item.id) {
items.push(item);
}
}
}
if let Ok(comment_items) = comments_result {
for item in comment_items {
if !initial_ids.contains(&item.id) {
items.push(item);
}
}
}
sort_news_items(&mut items, NewsSortMode::DateDesc);
info!(
total = items.len(),
arch = items
.iter()
.filter(|i| matches!(i.source, NewsFeedSource::ArchNews))
.count(),
advisories = items
.iter()
.filter(|i| matches!(i.source, NewsFeedSource::SecurityAdvisory))
.count(),
updates = items
.iter()
.filter(|i| matches!(
i.source,
NewsFeedSource::InstalledPackageUpdate | NewsFeedSource::AurPackageUpdate
))
.count(),
"continuation fetch complete"
);
Ok(items)
}
pub use rate_limit::{
check_circuit_breaker, extract_endpoint_pattern, extract_retry_after_from_error,
increase_archlinux_backoff, rate_limit_archlinux, record_circuit_breaker_outcome,
reset_archlinux_backoff, take_network_error,
};
pub use updates::{
get_aur_json_changes, get_official_json_changes, load_official_json_cache,
official_json_cache_path,
};
#[cfg(test)]
mod tests;