pacsea/logic/
filter.rs

1//! Package filtering logic for repository and AUR results.
2
3use crate::state::{AppState, PackageItem, Source};
4
5#[inline]
6/// What: Conditionally push a `PackageItem` into the filtered results buffer.
7///
8/// Inputs:
9/// - `cond`: When `true`, the item is appended to `out`.
10/// - `it`: Candidate package record (moved into the collection when included).
11/// - `out`: Destination vector accumulating filtered results.
12///
13/// Output:
14/// - Extends `out` with `it` when `cond` evaluates to `true`; leaves `out` untouched otherwise.
15///
16/// Details:
17/// - Keeps the filtering loop concise by encapsulating the conditional push logic.
18fn return_if_true(cond: bool, it: PackageItem, out: &mut Vec<PackageItem>) {
19    if cond {
20        out.push(it);
21    }
22}
23
24/// What: Apply current repo/AUR filters to `app.all_results`, write into `app.results`, then sort.
25///
26/// Inputs:
27/// - `app`: Mutable application state containing `all_results`, filter toggles, and selection
28///
29/// Output:
30/// - Updates `app.results`, applies sorting, and preserves selection when possible.
31///
32/// Details:
33/// - Unknown official repos are included only when all official filters are enabled.
34/// - Selection is restored by name when present; otherwise clamped or cleared if list is empty.
35pub fn apply_filters_and_sort_preserve_selection(app: &mut AppState) {
36    // Capture previous selected name to preserve when possible
37    let prev_name = app.results.get(app.selected).map(|p| p.name.clone());
38
39    // Filter from all_results into results based on toggles
40    let mut filtered: Vec<PackageItem> = Vec::with_capacity(app.all_results.len());
41    for it in app.all_results.iter().cloned() {
42        let include = match &it.source {
43            Source::Aur => app.results_filter_show_aur,
44            Source::Official { repo, .. } => {
45                // Unified Manjaro detection: name prefix or owner contains "manjaro" when available.
46                // Prefer details_cache owner if present; fall back to name-only rule.
47                let owner = app
48                    .details_cache
49                    .get(&it.name)
50                    .map(|d| d.owner.clone())
51                    .unwrap_or_default();
52                if crate::index::is_manjaro_name_or_owner(&it.name, &owner) {
53                    return_if_true(app.results_filter_show_manjaro, it, &mut filtered);
54                    continue;
55                }
56                crate::logic::distro::repo_toggle_for(repo, app)
57            }
58        };
59        if include {
60            filtered.push(it);
61        }
62    }
63    app.results = filtered;
64    // Invalidate sort caches since results changed
65    crate::logic::invalidate_sort_caches(app);
66    // Apply existing sort policy and preserve selection
67    crate::logic::sort_results_preserve_selection(app);
68    // Restore by name if possible
69    if let Some(name) = prev_name {
70        if let Some(pos) = app.results.iter().position(|p| p.name == name) {
71            app.selected = pos;
72            app.list_state.select(Some(pos));
73        } else if !app.results.is_empty() {
74            app.selected = app.selected.min(app.results.len() - 1);
75            app.list_state.select(Some(app.selected));
76        } else {
77            app.selected = 0;
78            app.list_state.select(None);
79        }
80    } else if app.results.is_empty() {
81        app.selected = 0;
82        app.list_state.select(None);
83    } else {
84        app.selected = app.selected.min(app.results.len() - 1);
85        app.list_state.select(Some(app.selected));
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn item_official(name: &str, repo: &str) -> PackageItem {
94        PackageItem {
95            name: name.to_string(),
96            version: "1.0".to_string(),
97            description: format!("{name} desc"),
98            source: Source::Official {
99                repo: repo.to_string(),
100                arch: "x86_64".to_string(),
101            },
102            popularity: None,
103            out_of_date: None,
104            orphaned: false,
105        }
106    }
107
108    #[test]
109    /// What: Ensure repo/AUR filters include only enabled repositories while keeping selection stable.
110    ///
111    /// Inputs:
112    /// - `app`: `AppState` seeded with mixed official/AUR results and selective filter toggles.
113    ///
114    /// Output:
115    /// - Results contain solely core packages after filtering; selection index remains valid.
116    ///
117    /// Details:
118    /// - Disables AUR/extra/multilib toggles to confirm `apply_filters_and_sort_preserve_selection`
119    ///   respects flags and prunes disabled repositories.
120    fn apply_filters_and_preserve_selection() {
121        let mut app = AppState {
122            all_results: vec![
123                PackageItem {
124                    name: "aur1".into(),
125                    version: "1".into(),
126                    description: String::new(),
127                    source: Source::Aur,
128                    popularity: Some(1.0),
129                    out_of_date: None,
130                    orphaned: false,
131                },
132                item_official("core1", "core"),
133                item_official("extra1", "extra"),
134                item_official("other1", "community"),
135            ],
136            results_filter_show_aur: false,
137            results_filter_show_core: true,
138            results_filter_show_extra: false,
139            results_filter_show_multilib: false,
140            ..Default::default()
141        };
142        apply_filters_and_sort_preserve_selection(&mut app);
143        assert!(app.results.iter().all(
144            |p| matches!(&p.source, Source::Official{repo, ..} if repo.eq_ignore_ascii_case("core"))
145        ));
146    }
147
148    #[test]
149    /// What: Verify `CachyOS` and `EOS` toggles act independently when filtering official repos.
150    ///
151    /// Inputs:
152    /// - `app`: `AppState` containing `CachyOS` and `EndeavourOS` entries with toggle combinations.
153    ///
154    /// Output:
155    /// - `CachyOS` packages persist while `EOS` entries are removed per toggle state.
156    ///
157    /// Details:
158    /// - Confirms `CachyOS` inclusion does not implicitly re-enable `EOS` repositories.
159    fn apply_filters_cachyos_and_eos_interaction() {
160        let mut app = AppState {
161            all_results: vec![
162                PackageItem {
163                    name: "cx".into(),
164                    version: "1".into(),
165                    description: String::new(),
166                    source: Source::Official {
167                        repo: "cachyos-core".into(),
168                        arch: "x86_64".into(),
169                    },
170                    popularity: None,
171                    out_of_date: None,
172                    orphaned: false,
173                },
174                PackageItem {
175                    name: "ey".into(),
176                    version: "1".into(),
177                    description: String::new(),
178                    source: Source::Official {
179                        repo: "endeavouros".into(),
180                        arch: "x86_64".into(),
181                    },
182                    popularity: None,
183                    out_of_date: None,
184                    orphaned: false,
185                },
186                item_official("core1", "core"),
187            ],
188            results_filter_show_core: true,
189            results_filter_show_extra: true,
190            results_filter_show_multilib: true,
191            results_filter_show_eos: false,
192            results_filter_show_cachyos: true,
193            ..Default::default()
194        };
195        apply_filters_and_sort_preserve_selection(&mut app);
196        assert!(app.results.iter().any(|p| match &p.source {
197            Source::Official { repo, .. } => repo.to_lowercase().starts_with("cachyos"),
198            Source::Aur => false,
199        }));
200        assert!(app.results.iter().all(|p| match &p.source {
201            Source::Official { repo, .. } => !repo.eq_ignore_ascii_case("endeavouros"),
202            Source::Aur => true,
203        }));
204    }
205
206    #[test]
207    /// What: Validate inclusion rules for unknown official repositories relative to toggle coverage.
208    ///
209    /// Inputs:
210    /// - `app`: `AppState` with an unfamiliar official repo plus standard core entry.
211    ///
212    /// Output:
213    /// - Unknown repo excluded when any official toggle is off, then included once all are enabled.
214    ///
215    /// Details:
216    /// - Demonstrates that enabling the remaining official toggle (`multilib`) widens acceptance to
217    ///   previously filtered repos.
218    fn logic_filter_unknown_official_inclusion_policy() {
219        let mut app = AppState {
220            all_results: vec![
221                PackageItem {
222                    name: "x1".into(),
223                    version: "1".into(),
224                    description: String::new(),
225                    source: Source::Official {
226                        repo: "weirdrepo".into(),
227                        arch: "x86_64".into(),
228                    },
229                    popularity: None,
230                    out_of_date: None,
231                    orphaned: false,
232                },
233                item_official("core1", "core"),
234            ],
235            results_filter_show_aur: true,
236            results_filter_show_core: true,
237            results_filter_show_extra: true,
238            results_filter_show_multilib: false,
239            results_filter_show_eos: true,
240            results_filter_show_cachyos: true,
241            ..Default::default()
242        };
243        apply_filters_and_sort_preserve_selection(&mut app);
244        assert!(app.results.iter().all(|p| match &p.source {
245            Source::Official { repo, .. } => repo.eq_ignore_ascii_case("core"),
246            Source::Aur => false,
247        }));
248
249        app.results_filter_show_multilib = true;
250        apply_filters_and_sort_preserve_selection(&mut app);
251        assert!(app.results.iter().any(|p| match &p.source {
252            Source::Official { repo, .. } => repo.eq_ignore_ascii_case("weirdrepo"),
253            Source::Aur => false,
254        }));
255    }
256}