pacsea/logic/
lists.rs

1//! Management of install, remove, and downgrade package lists.
2
3use crate::state::{AppState, PackageItem};
4use tracing::debug;
5
6/// What: Add a `PackageItem` to the install list if it is not already present.
7///
8/// Inputs:
9/// - `app`: Mutable application state (`install_list` and selection)
10/// - `item`: Package to add
11///
12/// Output:
13/// - Inserts at the front on success, marks list dirty, and selects index 0; no-op on dedup.
14///
15/// Details:
16/// - Updates `last_install_change` to support UI throttling of follow-up actions.
17/// - Uses `HashSet` for O(1) membership checking instead of linear scan.
18pub fn add_to_install_list(app: &mut AppState, item: PackageItem) {
19    let name_lower = item.name.to_lowercase();
20    if !app.install_list_names.insert(name_lower) {
21        return;
22    }
23    let prev_len = app.install_list.len();
24    app.install_list.insert(0, item);
25    app.install_dirty = true;
26    app.last_install_change = Some(std::time::Instant::now());
27    // Always keep cursor on top after adding
28    app.install_state.select(Some(0));
29    debug!(
30        new_len = app.install_list.len(),
31        previous_len = prev_len,
32        first = ?app.install_list.first().map(|p| &p.name),
33        "[State] Added package to install list"
34    );
35}
36
37/// What: Add a `PackageItem` to the remove list if it is not already present.
38///
39/// Inputs:
40/// - `app`: Mutable application state (`remove_list` and selection)
41/// - `item`: Package to add
42///
43/// Output:
44/// - Inserts at the front and selects index 0; no-op on dedup.
45///
46/// Details:
47/// - Leaves `remove_list` order deterministic by always pushing new entries to the head.
48/// - Uses `HashSet` for O(1) membership checking instead of linear scan.
49pub fn add_to_remove_list(app: &mut AppState, item: PackageItem) {
50    let name_lower = item.name.to_lowercase();
51    if !app.remove_list_names.insert(name_lower) {
52        return;
53    }
54    let prev_len = app.remove_list.len();
55    app.remove_list.insert(0, item);
56    app.remove_state.select(Some(0));
57    debug!(
58        new_len = app.remove_list.len(),
59        previous_len = prev_len,
60        first = ?app.remove_list.first().map(|p| &p.name),
61        "[State] Added package to remove list"
62    );
63}
64
65/// What: Add a `PackageItem` to the downgrade list if it is not already present.
66///
67/// Inputs:
68/// - `app`: Mutable application state (`downgrade_list` and selection)
69/// - `item`: Package to add
70///
71/// Output:
72/// - Inserts at the front and selects index 0; no-op on dedup.
73///
74/// Details:
75/// - Ensures repeated requests for the same package keep the cursor anchored at the newest item.
76/// - Uses `HashSet` for O(1) membership checking instead of linear scan.
77pub fn add_to_downgrade_list(app: &mut AppState, item: PackageItem) {
78    let name_lower = item.name.to_lowercase();
79    if !app.downgrade_list_names.insert(name_lower) {
80        return;
81    }
82    let prev_len = app.downgrade_list.len();
83    app.downgrade_list.insert(0, item);
84    app.downgrade_state.select(Some(0));
85    debug!(
86        new_len = app.downgrade_list.len(),
87        previous_len = prev_len,
88        first = ?app.downgrade_list.first().map(|p| &p.name),
89        "[State] Added package to downgrade list"
90    );
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    fn item_official(name: &str, repo: &str) -> PackageItem {
98        PackageItem {
99            name: name.to_string(),
100            version: "1.0".to_string(),
101            description: format!("{name} desc"),
102            source: crate::state::Source::Official {
103                repo: repo.to_string(),
104                arch: "x86_64".to_string(),
105            },
106            popularity: None,
107            out_of_date: None,
108            orphaned: false,
109        }
110    }
111
112    #[test]
113    /// What: Ensure the install list deduplicates entries case-insensitively and updates selection state.
114    ///
115    /// Inputs:
116    /// - Two package items whose names differ only by casing.
117    ///
118    /// Output:
119    /// - Install list contains a single entry, marked dirty, with the selection pointing at index `0`.
120    ///
121    /// Details:
122    /// - Exercises the guard path preventing duplicate installs and verifies the UI selection remains anchored on insert.
123    fn add_to_install_list_behavior() {
124        let mut app = AppState::default();
125        add_to_install_list(&mut app, item_official("pkg1", "core"));
126        add_to_install_list(&mut app, item_official("Pkg1", "core"));
127        assert_eq!(app.install_list.len(), 1);
128        assert!(app.install_dirty);
129        assert_eq!(app.install_state.selected(), Some(0));
130    }
131
132    #[test]
133    /// What: Confirm the remove list enforces case-insensitive uniqueness and selection updates.
134    ///
135    /// Inputs:
136    /// - Two package items whose names differ only by casing.
137    ///
138    /// Output:
139    /// - Remove list retains a single item and its selection index becomes `0`.
140    ///
141    /// Details:
142    /// - Protects against regressions where duplicates might shift the selection or leak into the list.
143    fn add_to_remove_list_behavior() {
144        let mut app = AppState::default();
145        add_to_remove_list(&mut app, item_official("pkg1", "extra"));
146        add_to_remove_list(&mut app, item_official("Pkg1", "extra"));
147        assert_eq!(app.remove_list.len(), 1);
148        assert_eq!(app.remove_state.selected(), Some(0));
149    }
150
151    #[test]
152    /// What: Verify the downgrade list rejects duplicate names regardless of case and updates selection.
153    ///
154    /// Inputs:
155    /// - Two package items whose names differ only by casing.
156    ///
157    /// Output:
158    /// - Downgrade list contains one item and the selection index resolves to `0`.
159    ///
160    /// Details:
161    /// - Ensures repeated downgrade requests do not reorder the cursor unexpectedly.
162    fn add_to_downgrade_list_behavior() {
163        let mut app = AppState::default();
164        add_to_downgrade_list(&mut app, item_official("PkgX", "extra"));
165        add_to_downgrade_list(&mut app, item_official("pkgx", "extra"));
166        assert_eq!(app.downgrade_list.len(), 1);
167        assert_eq!(app.downgrade_state.selected(), Some(0));
168    }
169
170    #[test]
171    /// What: Verify `HashSet` synchronization after adding and removing items from install list.
172    ///
173    /// Inputs:
174    /// - Add items to install list, then remove them.
175    ///
176    /// Output:
177    /// - `HashSet` contains names only when items are in the list.
178    ///
179    /// Details:
180    /// - Ensures `HashSet` stays synchronized with the `Vec` for O(1) membership checking.
181    fn install_list_hashset_synchronization() {
182        let mut app = AppState::default();
183        add_to_install_list(&mut app, item_official("pkg1", "core"));
184        add_to_install_list(&mut app, item_official("pkg2", "extra"));
185        assert!(app.install_list_names.contains("pkg1"));
186        assert!(app.install_list_names.contains("pkg2"));
187        assert_eq!(app.install_list_names.len(), 2);
188
189        // Remove first item (pkg2 is at index 0 since it was added last)
190        // Items are inserted at index 0, so order is: [pkg2, pkg1]
191        let removed_name = app.install_list[0].name.to_lowercase();
192        app.install_list_names.remove(&removed_name);
193        app.install_list.remove(0);
194        // After removing pkg2, pkg1 should remain
195        assert!(app.install_list_names.contains("pkg1"));
196        assert!(!app.install_list_names.contains("pkg2"));
197        assert_eq!(app.install_list_names.len(), 1);
198    }
199
200    #[test]
201    /// What: Verify `HashSet` synchronization after clearing install list.
202    ///
203    /// Inputs:
204    /// - Add items to install list, then clear it.
205    ///
206    /// Output:
207    /// - `HashSet` is empty after clearing.
208    ///
209    /// Details:
210    /// - Ensures `HashSet` is cleared when list is cleared.
211    fn install_list_hashset_clear_synchronization() {
212        let mut app = AppState::default();
213        add_to_install_list(&mut app, item_official("pkg1", "core"));
214        add_to_install_list(&mut app, item_official("pkg2", "extra"));
215        assert_eq!(app.install_list_names.len(), 2);
216
217        app.install_list.clear();
218        app.install_list_names.clear();
219        assert!(app.install_list_names.is_empty());
220    }
221}