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}