1use crate::state::{AppState, PackageItem, SortMode, Source};
7use std::collections::hash_map::DefaultHasher;
8use std::hash::{Hash, Hasher};
9#[cfg(test)]
10use std::sync::atomic::{AtomicUsize, Ordering};
11
12#[cfg(test)]
13static COMPUTE_REPO_INDICES_CALLS: AtomicUsize = AtomicUsize::new(0);
14#[cfg(test)]
15static COMPUTE_AUR_INDICES_CALLS: AtomicUsize = AtomicUsize::new(0);
16
17fn compute_results_signature(results: &[PackageItem]) -> u64 {
29 let mut names: Vec<&str> = results.iter().map(|p| p.name.as_str()).collect();
31 names.sort_unstable();
32
33 let mut hasher = DefaultHasher::new();
34 names.len().hash(&mut hasher);
35
36 if let Some(first) = names.first() {
38 first.hash(&mut hasher);
39 }
40 if let Some(last) = names.last() {
41 last.hash(&mut hasher);
42 }
43
44 let mut aggregate: u64 = 0;
46 for name in names {
47 let mut nh = DefaultHasher::new();
48 name.hash(&mut nh);
49 aggregate ^= nh.finish();
50 }
51 aggregate.hash(&mut hasher);
52
53 hasher.finish()
54}
55
56fn reorder_from_indices(results: &mut Vec<PackageItem>, indices: &[usize]) {
69 let reordered: Vec<PackageItem> = indices
70 .iter()
71 .filter_map(|&i| results.get(i).cloned())
72 .collect();
73 *results = reordered;
74}
75
76fn sort_best_matches(results: &mut [PackageItem], query: &str) {
88 let ql = query.trim().to_lowercase();
89 results.sort_by(|a, b| {
90 let ra = crate::util::match_rank(&a.name, &ql);
91 let rb = crate::util::match_rank(&b.name, &ql);
92 if ra != rb {
93 return ra.cmp(&rb);
94 }
95 let oa = crate::util::repo_order(&a.source);
97 let ob = crate::util::repo_order(&b.source);
98 if oa != ob {
99 return oa.cmp(&ob);
100 }
101 a.name.to_lowercase().cmp(&b.name.to_lowercase())
102 });
103}
104
105fn compute_repo_then_name_indices(results: &[PackageItem]) -> Vec<usize> {
116 #[cfg(test)]
117 COMPUTE_REPO_INDICES_CALLS.fetch_add(1, Ordering::Relaxed);
118
119 let mut indices: Vec<usize> = (0..results.len()).collect();
120 indices.sort_by(|&i, &j| {
121 let a = &results[i];
122 let b = &results[j];
123 let oa = crate::util::repo_order(&a.source);
124 let ob = crate::util::repo_order(&b.source);
125 if oa != ob {
126 return oa.cmp(&ob);
127 }
128 a.name.to_lowercase().cmp(&b.name.to_lowercase())
129 });
130 indices
131}
132
133fn compute_aur_popularity_then_official_indices(results: &[PackageItem]) -> Vec<usize> {
144 #[cfg(test)]
145 COMPUTE_AUR_INDICES_CALLS.fetch_add(1, Ordering::Relaxed);
146
147 let mut indices: Vec<usize> = (0..results.len()).collect();
148 indices.sort_by(|&i, &j| {
149 let a = &results[i];
150 let b = &results[j];
151 let aur_a = matches!(a.source, Source::Aur);
153 let aur_b = matches!(b.source, Source::Aur);
154 if aur_a != aur_b {
155 return aur_b.cmp(&aur_a); }
157 if aur_a && aur_b {
158 let pa = a.popularity.unwrap_or(0.0);
160 let pb = b.popularity.unwrap_or(0.0);
161 if (pa - pb).abs() > f64::EPSILON {
162 return pb.partial_cmp(&pa).unwrap_or(std::cmp::Ordering::Equal);
163 }
164 } else {
165 let oa = crate::util::repo_order(&a.source);
167 let ob = crate::util::repo_order(&b.source);
168 if oa != ob {
169 return oa.cmp(&ob);
170 }
171 }
172 a.name.to_lowercase().cmp(&b.name.to_lowercase())
173 });
174 indices
175}
176
177pub fn sort_results_preserve_selection(app: &mut AppState) {
190 if app.results.is_empty() {
191 return;
192 }
193 let prev_name = app.results.get(app.selected).map(|p| p.name.clone());
194
195 let current_sig = compute_results_signature(&app.results);
197
198 let cache_valid = app.sort_cache_signature == Some(current_sig);
200
201 match app.sort_mode {
202 SortMode::RepoThenName => {
203 if cache_valid {
204 if let Some(ref indices) = app.sort_cache_repo_name {
205 reorder_from_indices(&mut app.results, indices);
207 } else {
208 let indices = compute_repo_then_name_indices(&app.results);
210 reorder_from_indices(&mut app.results, &indices);
211 }
212 } else {
213 let indices = compute_repo_then_name_indices(&app.results);
215 reorder_from_indices(&mut app.results, &indices);
216 }
217 app.sort_cache_repo_name = Some((0..app.results.len()).collect());
219 app.sort_cache_aur_popularity =
220 Some(compute_aur_popularity_then_official_indices(&app.results));
221 app.sort_cache_signature = Some(current_sig);
222 }
223 SortMode::AurPopularityThenOfficial => {
224 if cache_valid {
225 if let Some(ref indices) = app.sort_cache_aur_popularity {
226 reorder_from_indices(&mut app.results, indices);
228 } else {
229 let indices = compute_aur_popularity_then_official_indices(&app.results);
231 reorder_from_indices(&mut app.results, &indices);
232 }
233 } else {
234 let indices = compute_aur_popularity_then_official_indices(&app.results);
236 reorder_from_indices(&mut app.results, &indices);
237 }
238 app.sort_cache_repo_name = Some(compute_repo_then_name_indices(&app.results));
240 app.sort_cache_aur_popularity = Some((0..app.results.len()).collect());
241 app.sort_cache_signature = Some(current_sig);
242 }
243 SortMode::BestMatches => {
244 sort_best_matches(&mut app.results, &app.input);
246 app.sort_cache_repo_name = None;
248 app.sort_cache_aur_popularity = None;
249 app.sort_cache_signature = None;
250 }
251 }
252
253 if let Some(name) = prev_name {
255 if let Some(pos) = app.results.iter().position(|p| p.name == name) {
256 app.selected = pos;
257 app.list_state.select(Some(pos));
258 } else {
259 app.selected = app.selected.min(app.results.len().saturating_sub(1));
260 app.list_state.select(Some(app.selected));
261 }
262 }
263}
264
265pub fn invalidate_sort_caches(app: &mut AppState) {
276 app.sort_cache_repo_name = None;
277 app.sort_cache_aur_popularity = None;
278 app.sort_cache_signature = None;
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[cfg(test)]
286 fn reset_compute_counters() {
297 COMPUTE_REPO_INDICES_CALLS.store(0, Ordering::SeqCst);
298 COMPUTE_AUR_INDICES_CALLS.store(0, Ordering::SeqCst);
299 }
300
301 fn item_official(name: &str, repo: &str) -> crate::state::PackageItem {
302 crate::state::PackageItem {
303 name: name.to_string(),
304 version: "1.0".to_string(),
305 description: format!("{name} desc"),
306 source: crate::state::Source::Official {
307 repo: repo.to_string(),
308 arch: "x86_64".to_string(),
309 },
310 popularity: None,
311 out_of_date: None,
312 orphaned: false,
313 }
314 }
315 fn item_aur(name: &str, pop: Option<f64>) -> crate::state::PackageItem {
316 crate::state::PackageItem {
317 name: name.to_string(),
318 version: "1.0".to_string(),
319 description: format!("{name} desc"),
320 source: crate::state::Source::Aur,
321 popularity: pop,
322 out_of_date: None,
323 orphaned: false,
324 }
325 }
326
327 #[test]
328 fn sort_preserve_selection_and_best_matches() {
340 let mut app = AppState {
341 results: vec![
342 item_aur("zzz", Some(1.0)),
343 item_official("aaa", "core"),
344 item_official("bbb", "extra"),
345 item_aur("ccc", Some(10.0)),
346 ],
347 selected: 2,
348 sort_mode: SortMode::RepoThenName,
349 ..Default::default()
350 };
351 app.list_state.select(Some(2));
352 sort_results_preserve_selection(&mut app);
353 assert_eq!(
354 app.results
355 .iter()
356 .filter(|p| matches!(p.source, Source::Official { .. }))
357 .count(),
358 2
359 );
360 assert_eq!(app.results[app.selected].name, "bbb");
361
362 app.sort_mode = SortMode::AurPopularityThenOfficial;
363 sort_results_preserve_selection(&mut app);
364 let aur_first = &app.results[0];
365 assert!(matches!(aur_first.source, Source::Aur));
366
367 app.input = "bb".into();
368 app.sort_mode = SortMode::BestMatches;
369 sort_results_preserve_selection(&mut app);
370 assert!(
371 app.results
372 .iter()
373 .position(|p| p.name.contains("bb"))
374 .expect("should find package containing 'bb' in test data")
375 <= 1
376 );
377 }
378
379 #[test]
380 fn sort_bestmatches_tiebreak_repo_then_name() {
391 let mut app = AppState {
392 results: vec![
393 item_official("alpha2", "extra"),
394 item_official("alpha1", "extra"),
395 item_official("alpha_core", "core"),
396 ],
397 input: "alpha".into(),
398 sort_mode: SortMode::BestMatches,
399 ..Default::default()
400 };
401 sort_results_preserve_selection(&mut app);
402 let names: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
403 assert_eq!(names, vec!["alpha_core", "alpha1", "alpha2"]);
404 }
405
406 #[test]
407 fn results_signature_is_order_insensitive() {
419 let base = vec![
420 item_official("aaa", "core"),
421 item_official("bbb", "extra"),
422 item_official("ccc", "community"),
423 ];
424 let permuted = vec![
425 item_official("ccc", "community"),
426 item_official("aaa", "core"),
427 item_official("bbb", "extra"),
428 ];
429 let mut extended = permuted.clone();
430 extended.push(item_official("ddd", "community"));
431
432 let sig_base = compute_results_signature(&base);
433 let sig_permuted = compute_results_signature(&permuted);
434 let sig_extended = compute_results_signature(&extended);
435
436 assert_eq!(sig_base, sig_permuted);
437 assert_ne!(sig_base, sig_extended);
438 }
439
440 #[test]
441 fn sort_aur_popularity_and_official_tiebreaks() {
452 let mut app = AppState {
453 results: vec![
454 item_aur("aurB", Some(1.0)),
455 item_aur("aurA", Some(1.0)),
456 item_official("z_off", "core"),
457 item_official("a_off", "extra"),
458 ],
459 sort_mode: SortMode::AurPopularityThenOfficial,
460 ..Default::default()
461 };
462 sort_results_preserve_selection(&mut app);
463 let names: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
464 assert_eq!(names, vec!["aurA", "aurB", "z_off", "a_off"]);
465 }
466
467 #[test]
468 fn sort_cache_invalidation() {
479 let mut app = AppState {
480 results: vec![
481 item_official("pkg1", "core"),
482 item_official("pkg2", "extra"),
483 ],
484 sort_mode: SortMode::RepoThenName,
485 sort_cache_signature: Some(12345),
486 sort_cache_repo_name: Some(vec![0, 1]),
487 sort_cache_aur_popularity: Some(vec![1, 0]),
488 ..Default::default()
489 };
490
491 invalidate_sort_caches(&mut app);
493 assert!(app.sort_cache_signature.is_none());
494 assert!(app.sort_cache_repo_name.is_none());
495 assert!(app.sort_cache_aur_popularity.is_none());
496 }
497
498 #[test]
499 fn sort_bestmatches_no_mode_cache() {
510 let mut app = AppState {
511 results: vec![
512 item_official("alpha", "core"),
513 item_official("beta", "extra"),
514 ],
515 input: "alph".into(),
516 sort_mode: SortMode::BestMatches,
517 ..Default::default()
518 };
519
520 sort_results_preserve_selection(&mut app);
521
522 assert!(app.sort_cache_repo_name.is_none());
524 assert!(app.sort_cache_aur_popularity.is_none());
525 }
526
527 #[test]
528 fn sort_cache_hit_repo_then_name() {
539 let mut app = AppState {
540 results: vec![
541 item_official("zzz", "extra"),
542 item_official("aaa", "core"),
543 item_official("bbb", "core"),
544 ],
545 sort_mode: SortMode::RepoThenName,
546 ..Default::default()
547 };
548
549 sort_results_preserve_selection(&mut app);
551 let first_sort_order: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
552 let cached_sig = app.sort_cache_signature;
553
554 app.sort_mode = SortMode::AurPopularityThenOfficial;
556 sort_results_preserve_selection(&mut app);
557
558 app.sort_mode = SortMode::RepoThenName;
560 sort_results_preserve_selection(&mut app);
561 let second_sort_order: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
562
563 assert_eq!(first_sort_order, second_sort_order);
565 assert_eq!(app.sort_cache_signature, cached_sig);
566 }
567
568 #[test]
569 fn sort_cache_miss_on_results_change() {
580 let mut app = AppState {
581 results: vec![item_official("aaa", "core"), item_official("bbb", "extra")],
582 sort_mode: SortMode::RepoThenName,
583 ..Default::default()
584 };
585
586 sort_results_preserve_selection(&mut app);
588 let old_sig = app.sort_cache_signature;
589
590 app.results = vec![item_official("ccc", "core"), item_official("ddd", "extra")];
592
593 sort_results_preserve_selection(&mut app);
595 let new_sig = app.sort_cache_signature;
596
597 assert_ne!(old_sig, new_sig);
599 assert!(app.sort_cache_repo_name.is_some());
600 assert!(app.sort_cache_aur_popularity.is_some());
601 }
602
603 #[test]
604 fn sort_cache_invalid_computes_indices_once() {
615 reset_compute_counters();
616 let mut app = AppState {
617 results: vec![item_official("bbb", "extra"), item_official("aaa", "core")],
618 sort_mode: SortMode::RepoThenName,
619 ..Default::default()
620 };
621
622 let sig = compute_results_signature(&app.results);
624 app.sort_cache_signature = Some(sig.wrapping_add(1));
625
626 sort_results_preserve_selection(&mut app);
627
628 assert_eq!(
629 COMPUTE_REPO_INDICES_CALLS.load(Ordering::SeqCst),
630 1,
631 "repo indices should be computed exactly once on cache invalidation"
632 );
633 assert_eq!(
634 COMPUTE_AUR_INDICES_CALLS.load(Ordering::SeqCst),
635 1,
636 "aur indices should be recomputed once to re-anchor caches"
637 );
638 let names: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
639 assert_eq!(names, vec!["aaa", "bbb"]);
640 }
641
642 #[test]
643 fn sort_cache_mode_switching() {
654 let mut app = AppState {
655 results: vec![
656 item_aur("low_pop", Some(1.0)),
657 item_official("core_pkg", "core"),
658 item_aur("high_pop", Some(10.0)),
659 item_official("extra_pkg", "extra"),
660 ],
661 sort_mode: SortMode::RepoThenName,
662 ..Default::default()
663 };
664
665 sort_results_preserve_selection(&mut app);
667 assert!(app.sort_cache_repo_name.is_some());
668 assert!(app.sort_cache_aur_popularity.is_some());
669 let repo_order: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
670
671 app.sort_mode = SortMode::AurPopularityThenOfficial;
673 sort_results_preserve_selection(&mut app);
674 let _aur_order: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
675 assert!(matches!(app.results[0].source, Source::Aur));
677
678 app.sort_mode = SortMode::RepoThenName;
680 sort_results_preserve_selection(&mut app);
681 let repo_order_again: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
682 assert_eq!(repo_order, repo_order_again);
683 }
684}