pacsea/state/app_state/
methods.rs

1//! Implementation methods for `AppState`.
2
3use crate::state::app_state::{AppState, recent_capacity};
4use crate::state::types::{
5    NewsBookmark, NewsFeedItem, NewsReadFilter, NewsSortMode, severity_rank,
6};
7use chrono::{NaiveDate, Utc};
8
9impl AppState {
10    /// What: Return recent searches in most-recent-first order.
11    ///
12    /// Inputs:
13    /// - `self`: Application state containing the recent LRU cache.
14    ///
15    /// Output:
16    /// - Vector of recent search strings ordered from most to least recent.
17    ///
18    /// Details:
19    /// - Clones stored values; limited to `RECENT_CAPACITY`.
20    #[must_use]
21    pub fn recent_values(&self) -> Vec<String> {
22        self.recent.iter().map(|(_, v)| v.clone()).collect()
23    }
24
25    /// What: Fetch a recent search by positional index.
26    ///
27    /// Inputs:
28    /// - `index`: Zero-based position in most-recent-first ordering.
29    ///
30    /// Output:
31    /// - `Some(String)` when the index is valid; `None` otherwise.
32    ///
33    /// Details:
34    /// - Uses the LRU iterator, so `index == 0` is the most recent entry.
35    #[must_use]
36    pub fn recent_value_at(&self, index: usize) -> Option<String> {
37        self.recent.iter().nth(index).map(|(_, v)| v.clone())
38    }
39
40    /// What: Remove a recent search at the provided position.
41    ///
42    /// Inputs:
43    /// - `index`: Zero-based position in most-recent-first ordering.
44    ///
45    /// Output:
46    /// - `Some(String)` containing the removed value when found; `None` otherwise.
47    ///
48    /// Details:
49    /// - Resolves the cache key via iteration, then pops it to maintain LRU invariants.
50    pub fn remove_recent_at(&mut self, index: usize) -> Option<String> {
51        let key = self.recent.iter().nth(index).map(|(k, _)| k.clone())?;
52        self.recent.pop(&key)
53    }
54
55    /// What: Add or replace a news bookmark, marking state dirty.
56    ///
57    /// Inputs:
58    /// - `bookmark`: Bookmark to insert (deduped by `item.id`).
59    ///
60    /// Output:
61    /// - None (mutates bookmarks and dirty flag).
62    pub fn add_news_bookmark(&mut self, bookmark: NewsBookmark) {
63        if let Some(pos) = self
64            .news_bookmarks
65            .iter()
66            .position(|b| b.item.id == bookmark.item.id)
67        {
68            self.news_bookmarks[pos] = bookmark;
69        } else {
70            self.news_bookmarks.push(bookmark);
71        }
72        self.news_bookmarks_dirty = true;
73    }
74
75    /// What: Remove a news bookmark at a position.
76    ///
77    /// Inputs:
78    /// - `index`: Zero-based index into bookmarks vector.
79    ///
80    /// Output:
81    /// - Removed bookmark if present.
82    pub fn remove_news_bookmark_at(&mut self, index: usize) -> Option<NewsBookmark> {
83        if index >= self.news_bookmarks.len() {
84            return None;
85        }
86        let removed = self.news_bookmarks.remove(index);
87        self.news_bookmarks_dirty = true;
88        Some(removed)
89    }
90
91    /// What: Return recent news searches in most-recent-first order.
92    ///
93    /// Inputs:
94    /// - `self`: Application state containing the news recent LRU cache.
95    ///
96    /// Output:
97    /// - Vector of recent news search strings ordered from most to least recent.
98    ///
99    /// Details:
100    /// - Clones stored values; limited by the configured recent capacity.
101    #[must_use]
102    pub fn news_recent_values(&self) -> Vec<String> {
103        self.news_recent.iter().map(|(_, v)| v.clone()).collect()
104    }
105
106    /// What: Fetch a recent news search by positional index.
107    ///
108    /// Inputs:
109    /// - `index`: Zero-based position in most-recent-first ordering.
110    ///
111    /// Output:
112    /// - `Some(String)` when the index is valid; `None` otherwise.
113    ///
114    /// Details:
115    /// - Uses the LRU iterator, so `index == 0` is the most recent entry.
116    #[must_use]
117    pub fn news_recent_value_at(&self, index: usize) -> Option<String> {
118        self.news_recent.iter().nth(index).map(|(_, v)| v.clone())
119    }
120
121    /// What: Replace the news recent cache with the provided most-recent-first entries.
122    ///
123    /// Inputs:
124    /// - `items`: Slice of recent news search strings ordered from most to least recent.
125    ///
126    /// Output:
127    /// - None (mutates `self.news_recent`).
128    ///
129    /// Details:
130    /// - Clears existing entries, enforces configured capacity, and preserves ordering by
131    ///   inserting from least-recent to most-recent.
132    pub fn load_news_recent_items(&mut self, items: &[String]) {
133        self.news_recent.clear();
134        self.news_recent.resize(recent_capacity());
135        for value in items.iter().rev() {
136            let stored = value.clone();
137            let key = stored.to_ascii_lowercase();
138            self.news_recent.put(key, stored);
139        }
140    }
141
142    /// What: Remove a recent news search at the provided position.
143    ///
144    /// Inputs:
145    /// - `index`: Zero-based position in most-recent-first ordering.
146    ///
147    /// Output:
148    /// - `Some(String)` containing the removed value when found; `None` otherwise.
149    ///
150    /// Details:
151    /// - Resolves the cache key via iteration, then pops it to maintain LRU invariants.
152    pub fn remove_news_recent_at(&mut self, index: usize) -> Option<String> {
153        let key = self.news_recent.iter().nth(index).map(|(k, _)| k.clone())?;
154        self.news_recent.pop(&key)
155    }
156
157    /// What: Replace the recent cache with the provided most-recent-first entries.
158    ///
159    /// Inputs:
160    /// - `items`: Slice of recent search strings ordered from most to least recent.
161    ///
162    /// Output:
163    /// - None (mutates `self.recent`).
164    ///
165    /// Details:
166    /// - Clears existing entries, enforces configured capacity, and preserves ordering by
167    ///   inserting from least-recent to most-recent.
168    pub fn load_recent_items(&mut self, items: &[String]) {
169        self.recent.clear();
170        self.recent.resize(recent_capacity());
171        for value in items.iter().rev() {
172            let stored = value.clone();
173            let key = stored.to_ascii_lowercase();
174            self.recent.put(key, stored);
175        }
176    }
177
178    /// What: Recompute news results applying filters, search, age cutoff, and sorting.
179    ///
180    /// Inputs:
181    /// - `self`: Mutable application state containing news items and filter fields.
182    ///
183    /// Output:
184    /// - Updates `news_results`, selection state, and recent news searches.
185    pub fn refresh_news_results(&mut self) {
186        let query = self.news_search_input.to_lowercase();
187        if query.is_empty() {
188            self.news_history_pending = None;
189            self.news_history_pending_at = None;
190        } else {
191            self.news_history_pending = Some(self.news_search_input.clone());
192            self.news_history_pending_at = Some(std::time::Instant::now());
193        }
194        let mut filtered: Vec<NewsFeedItem> = self
195            .news_items
196            .iter()
197            .filter(|it| match it.source {
198                crate::state::types::NewsFeedSource::ArchNews => self.news_filter_show_arch_news,
199                crate::state::types::NewsFeedSource::SecurityAdvisory => {
200                    self.news_filter_show_advisories
201                }
202                crate::state::types::NewsFeedSource::InstalledPackageUpdate => {
203                    self.news_filter_show_pkg_updates
204                }
205                crate::state::types::NewsFeedSource::AurPackageUpdate => {
206                    self.news_filter_show_aur_updates
207                }
208                crate::state::types::NewsFeedSource::AurComment => {
209                    self.news_filter_show_aur_comments
210                }
211            })
212            .cloned()
213            .collect();
214
215        // Apply installed-only filter for advisories when enabled.
216        // When "[Advisories All]" is active (news_filter_show_advisories = true,
217        // news_filter_installed_only = false), this block does not run, allowing
218        // all advisories to be shown regardless of installed status.
219        if self.news_filter_installed_only {
220            let installed: std::collections::HashSet<String> =
221                crate::index::explicit_names().into_iter().collect();
222            filtered.retain(|it| {
223                !matches!(
224                    it.source,
225                    crate::state::types::NewsFeedSource::SecurityAdvisory
226                ) || it.packages.iter().any(|pkg| installed.contains(pkg))
227            });
228        }
229
230        if !matches!(self.news_filter_read_status, NewsReadFilter::All) {
231            filtered.retain(|it| {
232                let is_read = self.news_read_ids.contains(&it.id)
233                    || it
234                        .url
235                        .as_ref()
236                        .is_some_and(|u| self.news_read_urls.contains(u));
237                matches!(self.news_filter_read_status, NewsReadFilter::Read) && is_read
238                    || matches!(self.news_filter_read_status, NewsReadFilter::Unread) && !is_read
239            });
240        }
241
242        if !query.is_empty() {
243            filtered.retain(|it| {
244                let hay = format!(
245                    "{} {} {}",
246                    it.title,
247                    it.summary.clone().unwrap_or_default(),
248                    it.packages.join(" ")
249                )
250                .to_lowercase();
251                hay.contains(&query)
252            });
253        }
254
255        if let Some(max_days) = self.news_max_age_days
256            && let Some(cutoff_date) = Utc::now()
257                .date_naive()
258                .checked_sub_days(chrono::Days::new(u64::from(max_days)))
259        {
260            filtered.retain(|it| {
261                NaiveDate::parse_from_str(&it.date, "%Y-%m-%d").map_or(true, |d| d >= cutoff_date)
262            });
263        }
264
265        let is_read = |it: &NewsFeedItem| {
266            self.news_read_ids.contains(&it.id)
267                || it
268                    .url
269                    .as_ref()
270                    .is_some_and(|u| self.news_read_urls.contains(u))
271        };
272
273        match self.news_sort_mode {
274            NewsSortMode::DateDesc => filtered.sort_by(|a, b| b.date.cmp(&a.date)),
275            NewsSortMode::DateAsc => filtered.sort_by(|a, b| a.date.cmp(&b.date)),
276            NewsSortMode::Title => {
277                filtered.sort_by(|a, b| {
278                    a.title
279                        .to_lowercase()
280                        .cmp(&b.title.to_lowercase())
281                        .then(b.date.cmp(&a.date))
282                });
283            }
284            NewsSortMode::SourceThenTitle => filtered.sort_by(|a, b| {
285                a.source
286                    .cmp(&b.source)
287                    .then(b.date.cmp(&a.date))
288                    .then(a.title.to_lowercase().cmp(&b.title.to_lowercase()))
289            }),
290            NewsSortMode::SeverityThenDate => filtered.sort_by(|a, b| {
291                let sa = severity_rank(a.severity);
292                let sb = severity_rank(b.severity);
293                sb.cmp(&sa)
294                    .then(b.date.cmp(&a.date))
295                    .then(a.title.to_lowercase().cmp(&b.title.to_lowercase()))
296            }),
297            NewsSortMode::UnreadThenDate => filtered.sort_by(|a, b| {
298                let ra = is_read(a);
299                let rb = is_read(b);
300                ra.cmp(&rb)
301                    .then(b.date.cmp(&a.date))
302                    .then(a.title.to_lowercase().cmp(&b.title.to_lowercase()))
303            }),
304        }
305
306        self.news_results = filtered;
307        if self.news_results.is_empty() {
308            self.news_selected = 0;
309            self.news_list_state.select(None);
310        } else {
311            self.news_selected = self
312                .news_selected
313                .min(self.news_results.len().saturating_sub(1));
314            self.news_list_state.select(Some(self.news_selected));
315        }
316    }
317}