use crate::state::{AppState, PackageItem, SortMode, Source};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
#[cfg(test)]
use std::sync::atomic::{AtomicUsize, Ordering};
#[cfg(test)]
static COMPUTE_REPO_INDICES_CALLS: AtomicUsize = AtomicUsize::new(0);
#[cfg(test)]
static COMPUTE_AUR_INDICES_CALLS: AtomicUsize = AtomicUsize::new(0);
fn compute_results_signature(results: &[PackageItem]) -> u64 {
let mut names: Vec<&str> = results.iter().map(|p| p.name.as_str()).collect();
names.sort_unstable();
let mut hasher = DefaultHasher::new();
names.len().hash(&mut hasher);
if let Some(first) = names.first() {
first.hash(&mut hasher);
}
if let Some(last) = names.last() {
last.hash(&mut hasher);
}
let mut aggregate: u64 = 0;
for name in names {
let mut nh = DefaultHasher::new();
name.hash(&mut nh);
aggregate ^= nh.finish();
}
aggregate.hash(&mut hasher);
hasher.finish()
}
fn reorder_from_indices(results: &mut Vec<PackageItem>, indices: &[usize]) {
let reordered: Vec<PackageItem> = indices
.iter()
.filter_map(|&i| results.get(i).cloned())
.collect();
*results = reordered;
}
fn sort_best_matches(results: &mut [PackageItem], query: &str) {
let ql = query.trim().to_lowercase();
results.sort_by(|a, b| {
let ra = crate::util::match_rank(&a.name, &ql);
let rb = crate::util::match_rank(&b.name, &ql);
if ra != rb {
return ra.cmp(&rb);
}
let oa = crate::util::repo_order(&a.source);
let ob = crate::util::repo_order(&b.source);
if oa != ob {
return oa.cmp(&ob);
}
a.name.to_lowercase().cmp(&b.name.to_lowercase())
});
}
fn compute_repo_then_name_indices(results: &[PackageItem]) -> Vec<usize> {
#[cfg(test)]
COMPUTE_REPO_INDICES_CALLS.fetch_add(1, Ordering::Relaxed);
let mut indices: Vec<usize> = (0..results.len()).collect();
indices.sort_by(|&i, &j| {
let a = &results[i];
let b = &results[j];
let oa = crate::util::repo_order(&a.source);
let ob = crate::util::repo_order(&b.source);
if oa != ob {
return oa.cmp(&ob);
}
a.name.to_lowercase().cmp(&b.name.to_lowercase())
});
indices
}
fn compute_aur_popularity_then_official_indices(results: &[PackageItem]) -> Vec<usize> {
#[cfg(test)]
COMPUTE_AUR_INDICES_CALLS.fetch_add(1, Ordering::Relaxed);
let mut indices: Vec<usize> = (0..results.len()).collect();
indices.sort_by(|&i, &j| {
let a = &results[i];
let b = &results[j];
let aur_a = matches!(a.source, Source::Aur);
let aur_b = matches!(b.source, Source::Aur);
if aur_a != aur_b {
return aur_b.cmp(&aur_a); }
if aur_a && aur_b {
let pa = a.popularity.unwrap_or(0.0);
let pb = b.popularity.unwrap_or(0.0);
if (pa - pb).abs() > f64::EPSILON {
return pb.partial_cmp(&pa).unwrap_or(std::cmp::Ordering::Equal);
}
} else {
let oa = crate::util::repo_order(&a.source);
let ob = crate::util::repo_order(&b.source);
if oa != ob {
return oa.cmp(&ob);
}
}
a.name.to_lowercase().cmp(&b.name.to_lowercase())
});
indices
}
pub fn sort_results_preserve_selection(app: &mut AppState) {
if app.results.is_empty() {
return;
}
let prev_name = app.results.get(app.selected).map(|p| p.name.clone());
let current_sig = compute_results_signature(&app.results);
let cache_valid = app.sort_cache_signature == Some(current_sig);
match app.sort_mode {
SortMode::RepoThenName => {
if cache_valid {
if let Some(ref indices) = app.sort_cache_repo_name {
reorder_from_indices(&mut app.results, indices);
} else {
let indices = compute_repo_then_name_indices(&app.results);
reorder_from_indices(&mut app.results, &indices);
}
} else {
let indices = compute_repo_then_name_indices(&app.results);
reorder_from_indices(&mut app.results, &indices);
}
app.sort_cache_repo_name = Some((0..app.results.len()).collect());
app.sort_cache_aur_popularity =
Some(compute_aur_popularity_then_official_indices(&app.results));
app.sort_cache_signature = Some(current_sig);
}
SortMode::AurPopularityThenOfficial => {
if cache_valid {
if let Some(ref indices) = app.sort_cache_aur_popularity {
reorder_from_indices(&mut app.results, indices);
} else {
let indices = compute_aur_popularity_then_official_indices(&app.results);
reorder_from_indices(&mut app.results, &indices);
}
} else {
let indices = compute_aur_popularity_then_official_indices(&app.results);
reorder_from_indices(&mut app.results, &indices);
}
app.sort_cache_repo_name = Some(compute_repo_then_name_indices(&app.results));
app.sort_cache_aur_popularity = Some((0..app.results.len()).collect());
app.sort_cache_signature = Some(current_sig);
}
SortMode::BestMatches => {
sort_best_matches(&mut app.results, &app.input);
app.sort_cache_repo_name = None;
app.sort_cache_aur_popularity = None;
app.sort_cache_signature = None;
}
}
if let Some(name) = prev_name {
if let Some(pos) = app.results.iter().position(|p| p.name == name) {
app.selected = pos;
app.list_state.select(Some(pos));
} else {
app.selected = app.selected.min(app.results.len().saturating_sub(1));
app.list_state.select(Some(app.selected));
}
}
}
pub fn invalidate_sort_caches(app: &mut AppState) {
app.sort_cache_repo_name = None;
app.sort_cache_aur_popularity = None;
app.sort_cache_signature = None;
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(test)]
fn reset_compute_counters() {
COMPUTE_REPO_INDICES_CALLS.store(0, Ordering::SeqCst);
COMPUTE_AUR_INDICES_CALLS.store(0, Ordering::SeqCst);
}
fn item_official(name: &str, repo: &str) -> crate::state::PackageItem {
crate::state::PackageItem {
name: name.to_string(),
version: "1.0".to_string(),
description: format!("{name} desc"),
source: crate::state::Source::Official {
repo: repo.to_string(),
arch: "x86_64".to_string(),
},
popularity: None,
out_of_date: None,
orphaned: false,
}
}
fn item_aur(name: &str, pop: Option<f64>) -> crate::state::PackageItem {
crate::state::PackageItem {
name: name.to_string(),
version: "1.0".to_string(),
description: format!("{name} desc"),
source: crate::state::Source::Aur,
popularity: pop,
out_of_date: None,
orphaned: false,
}
}
#[test]
fn sort_preserve_selection_and_best_matches() {
let mut app = AppState {
results: vec![
item_aur("zzz", Some(1.0)),
item_official("aaa", "core"),
item_official("bbb", "extra"),
item_aur("ccc", Some(10.0)),
],
selected: 2,
sort_mode: SortMode::RepoThenName,
..Default::default()
};
app.list_state.select(Some(2));
sort_results_preserve_selection(&mut app);
assert_eq!(
app.results
.iter()
.filter(|p| matches!(p.source, Source::Official { .. }))
.count(),
2
);
assert_eq!(app.results[app.selected].name, "bbb");
app.sort_mode = SortMode::AurPopularityThenOfficial;
sort_results_preserve_selection(&mut app);
let aur_first = &app.results[0];
assert!(matches!(aur_first.source, Source::Aur));
app.input = "bb".into();
app.sort_mode = SortMode::BestMatches;
sort_results_preserve_selection(&mut app);
assert!(
app.results
.iter()
.position(|p| p.name.contains("bb"))
.expect("should find package containing 'bb' in test data")
<= 1
);
}
#[test]
fn sort_bestmatches_tiebreak_repo_then_name() {
let mut app = AppState {
results: vec![
item_official("alpha2", "extra"),
item_official("alpha1", "extra"),
item_official("alpha_core", "core"),
],
input: "alpha".into(),
sort_mode: SortMode::BestMatches,
..Default::default()
};
sort_results_preserve_selection(&mut app);
let names: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, vec!["alpha_core", "alpha1", "alpha2"]);
}
#[test]
fn results_signature_is_order_insensitive() {
let base = vec![
item_official("aaa", "core"),
item_official("bbb", "extra"),
item_official("ccc", "community"),
];
let permuted = vec![
item_official("ccc", "community"),
item_official("aaa", "core"),
item_official("bbb", "extra"),
];
let mut extended = permuted.clone();
extended.push(item_official("ddd", "community"));
let sig_base = compute_results_signature(&base);
let sig_permuted = compute_results_signature(&permuted);
let sig_extended = compute_results_signature(&extended);
assert_eq!(sig_base, sig_permuted);
assert_ne!(sig_base, sig_extended);
}
#[test]
fn sort_aur_popularity_and_official_tiebreaks() {
let mut app = AppState {
results: vec![
item_aur("aurB", Some(1.0)),
item_aur("aurA", Some(1.0)),
item_official("z_off", "core"),
item_official("a_off", "extra"),
],
sort_mode: SortMode::AurPopularityThenOfficial,
..Default::default()
};
sort_results_preserve_selection(&mut app);
let names: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, vec!["aurA", "aurB", "z_off", "a_off"]);
}
#[test]
fn sort_cache_invalidation() {
let mut app = AppState {
results: vec![
item_official("pkg1", "core"),
item_official("pkg2", "extra"),
],
sort_mode: SortMode::RepoThenName,
sort_cache_signature: Some(12345),
sort_cache_repo_name: Some(vec![0, 1]),
sort_cache_aur_popularity: Some(vec![1, 0]),
..Default::default()
};
invalidate_sort_caches(&mut app);
assert!(app.sort_cache_signature.is_none());
assert!(app.sort_cache_repo_name.is_none());
assert!(app.sort_cache_aur_popularity.is_none());
}
#[test]
fn sort_bestmatches_no_mode_cache() {
let mut app = AppState {
results: vec![
item_official("alpha", "core"),
item_official("beta", "extra"),
],
input: "alph".into(),
sort_mode: SortMode::BestMatches,
..Default::default()
};
sort_results_preserve_selection(&mut app);
assert!(app.sort_cache_repo_name.is_none());
assert!(app.sort_cache_aur_popularity.is_none());
}
#[test]
fn sort_cache_hit_repo_then_name() {
let mut app = AppState {
results: vec![
item_official("zzz", "extra"),
item_official("aaa", "core"),
item_official("bbb", "core"),
],
sort_mode: SortMode::RepoThenName,
..Default::default()
};
sort_results_preserve_selection(&mut app);
let first_sort_order: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
let cached_sig = app.sort_cache_signature;
app.sort_mode = SortMode::AurPopularityThenOfficial;
sort_results_preserve_selection(&mut app);
app.sort_mode = SortMode::RepoThenName;
sort_results_preserve_selection(&mut app);
let second_sort_order: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
assert_eq!(first_sort_order, second_sort_order);
assert_eq!(app.sort_cache_signature, cached_sig);
}
#[test]
fn sort_cache_miss_on_results_change() {
let mut app = AppState {
results: vec![item_official("aaa", "core"), item_official("bbb", "extra")],
sort_mode: SortMode::RepoThenName,
..Default::default()
};
sort_results_preserve_selection(&mut app);
let old_sig = app.sort_cache_signature;
app.results = vec![item_official("ccc", "core"), item_official("ddd", "extra")];
sort_results_preserve_selection(&mut app);
let new_sig = app.sort_cache_signature;
assert_ne!(old_sig, new_sig);
assert!(app.sort_cache_repo_name.is_some());
assert!(app.sort_cache_aur_popularity.is_some());
}
#[test]
fn sort_cache_invalid_computes_indices_once() {
reset_compute_counters();
let mut app = AppState {
results: vec![item_official("bbb", "extra"), item_official("aaa", "core")],
sort_mode: SortMode::RepoThenName,
..Default::default()
};
let sig = compute_results_signature(&app.results);
app.sort_cache_signature = Some(sig.wrapping_add(1));
sort_results_preserve_selection(&mut app);
assert_eq!(
COMPUTE_REPO_INDICES_CALLS.load(Ordering::SeqCst),
1,
"repo indices should be computed exactly once on cache invalidation"
);
assert_eq!(
COMPUTE_AUR_INDICES_CALLS.load(Ordering::SeqCst),
1,
"aur indices should be recomputed once to re-anchor caches"
);
let names: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, vec!["aaa", "bbb"]);
}
#[test]
fn sort_cache_mode_switching() {
let mut app = AppState {
results: vec![
item_aur("low_pop", Some(1.0)),
item_official("core_pkg", "core"),
item_aur("high_pop", Some(10.0)),
item_official("extra_pkg", "extra"),
],
sort_mode: SortMode::RepoThenName,
..Default::default()
};
sort_results_preserve_selection(&mut app);
assert!(app.sort_cache_repo_name.is_some());
assert!(app.sort_cache_aur_popularity.is_some());
let repo_order: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
app.sort_mode = SortMode::AurPopularityThenOfficial;
sort_results_preserve_selection(&mut app);
let _aur_order: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
assert!(matches!(app.results[0].source, Source::Aur));
app.sort_mode = SortMode::RepoThenName;
sort_results_preserve_selection(&mut app);
let repo_order_again: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
assert_eq!(repo_order, repo_order_again);
}
}