pacsea/state/types.rs
1//! Core value types used by Pacsea state.
2
3/// Minimal news entry for Arch news modal.
4#[derive(Clone, Debug)]
5pub struct NewsItem {
6 /// Publication date (short, e.g., 2025-10-11)
7 pub date: String,
8 /// Title text
9 pub title: String,
10 /// Link URL
11 pub url: String,
12}
13
14/// What: High-level application mode.
15///
16/// Inputs: None (enum variants)
17///
18/// Output: Represents whether the UI is in package management or news view.
19///
20/// Details:
21/// - `Package` preserves the existing package management experience.
22/// - `News` switches panes to the news feed experience.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum AppMode {
25 /// Package management/search mode (existing UI).
26 Package,
27 /// News feed mode (new UI).
28 News,
29}
30
31/// What: News/advisory source type.
32///
33/// Inputs: None (enum variants)
34///
35/// Output: Identifies where a news feed item originates.
36///
37/// Details:
38/// - Distinguishes Arch news RSS posts from security advisories.
39#[derive(
40 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
41)]
42pub enum NewsFeedSource {
43 /// Official Arch Linux news RSS item.
44 ArchNews,
45 /// security.archlinux.org advisory.
46 SecurityAdvisory,
47 /// Installed official package received a version update.
48 InstalledPackageUpdate,
49 /// Installed AUR package received a version update.
50 AurPackageUpdate,
51 /// New AUR comment on an installed package.
52 AurComment,
53}
54
55/// What: Severity levels for security advisories.
56///
57/// Inputs: None (enum variants)
58///
59/// Output: Normalized advisory severity.
60///
61/// Details:
62/// - Ordered from lowest to highest severity for sorting.
63#[derive(
64 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
65)]
66pub enum AdvisorySeverity {
67 /// Unknown or not provided.
68 Unknown,
69 /// Low severity.
70 Low,
71 /// Medium severity.
72 Medium,
73 /// High severity.
74 High,
75 /// Critical severity.
76 Critical,
77}
78
79/// What: Map advisory severity to a numeric rank for sorting (higher is worse).
80///
81/// Inputs:
82/// - `severity`: Optional advisory severity value.
83///
84/// Output:
85/// - Numeric rank where larger numbers indicate higher severity (Critical highest).
86///
87/// Details:
88/// - Returns `0` when severity is missing to ensure advisories without severity fall last.
89/// - Keeps ordering stable across both news feed sorting and advisory-specific listings.
90#[must_use]
91pub const fn severity_rank(severity: Option<AdvisorySeverity>) -> u8 {
92 match severity {
93 Some(AdvisorySeverity::Critical) => 5,
94 Some(AdvisorySeverity::High) => 4,
95 Some(AdvisorySeverity::Medium) => 3,
96 Some(AdvisorySeverity::Low) => 2,
97 Some(AdvisorySeverity::Unknown) => 1,
98 None => 0,
99 }
100}
101
102/// What: Sort options for news feed results.
103///
104/// Inputs: None (enum variants)
105///
106/// Output: Selected sort mode for news items.
107///
108/// Details:
109/// - `DateDesc` is newest-first default.
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub enum NewsSortMode {
112 /// Newest first by date.
113 DateDesc,
114 /// Oldest first by date.
115 DateAsc,
116 /// Alphabetical by title.
117 Title,
118 /// Group by source then title.
119 SourceThenTitle,
120 /// Severity first (Critical..Unknown), then date (newest first).
121 SeverityThenDate,
122 /// Unread items first, then date (newest first).
123 UnreadThenDate,
124}
125
126/// What: Read filter applied to news feed items.
127///
128/// Inputs: None (enum variants)
129///
130/// Output:
131/// - Indicates whether to show all, only read, or only unread items.
132///
133/// Details:
134/// - Used by the News Feed list and toolbar filter chip.
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
136pub enum NewsReadFilter {
137 /// Show all items regardless of read status.
138 All,
139 /// Show only items marked as read.
140 Read,
141 /// Show only items not marked as read.
142 Unread,
143}
144
145/// What: Unified news/advisory feed item for the news view.
146///
147/// Inputs:
148/// - Fields describing the item (title, summary, url, source, severity, packages, date)
149///
150/// Output:
151/// - Data ready for list and details rendering in news mode.
152///
153/// Details:
154/// - `id` is a stable identifier (URL for news, advisory ID for security).
155/// - `packages` holds affected package names for advisories.
156#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
157pub struct NewsFeedItem {
158 /// Stable identifier (URL or advisory ID).
159 pub id: String,
160 /// Publication or update date (YYYY-MM-DD).
161 pub date: String,
162 /// Human-readable title/headline.
163 pub title: String,
164 /// Optional summary/description.
165 pub summary: Option<String>,
166 /// Optional link URL for details.
167 pub url: Option<String>,
168 /// Source type (Arch news vs security advisory).
169 pub source: NewsFeedSource,
170 /// Optional advisory severity.
171 pub severity: Option<AdvisorySeverity>,
172 /// Affected packages (advisories only).
173 pub packages: Vec<String>,
174}
175
176/// What: Bundle of news feed items and associated last-seen state updates.
177///
178/// Inputs:
179/// - `items`: Aggregated news feed entries ready for rendering.
180/// - `seen_pkg_versions`: Updated map of installed package names to last-seen versions.
181/// - `seen_aur_comments`: Updated map of AUR packages to last-seen comment identifiers.
182///
183/// Output:
184/// - Carries feed payload plus dedupe state for persistence.
185///
186/// Details:
187/// - Used as the payload between background fetchers and UI to keep last-seen maps in sync.
188#[derive(Clone, Debug)]
189pub struct NewsFeedPayload {
190 /// Aggregated and sorted feed items.
191 pub items: Vec<NewsFeedItem>,
192 /// Last-seen versions for installed packages.
193 pub seen_pkg_versions: std::collections::HashMap<String, String>,
194 /// Last-seen comment identifiers for installed AUR packages.
195 pub seen_aur_comments: std::collections::HashMap<String, String>,
196}
197
198/// What: Persisted bookmark entry for news items, including cached content and optional local HTML path.
199///
200/// Inputs:
201/// - `item`: The news feed item metadata.
202/// - `content`: Parsed article content stored locally for offline display.
203/// - `html_path`: Optional filesystem path to the saved HTML file (if downloaded).
204#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
205pub struct NewsBookmark {
206 /// News feed metadata for the bookmark.
207 pub item: NewsFeedItem,
208 /// Parsed content cached locally.
209 pub content: Option<String>,
210 /// Path to the saved HTML file on disk (if downloaded).
211 pub html_path: Option<String>,
212}
213
214/// Package source origin.
215///
216/// Indicates whether a package originates from the official repositories or
217/// the Arch User Repository.
218#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
219pub enum Source {
220 /// Official repository package and its associated repository and target
221 /// architecture.
222 Official {
223 /// Repository name (e.g., "core", "extra", "community").
224 repo: String,
225 /// Target architecture (e.g., `x86_64`).
226 arch: String,
227 },
228 /// AUR package.
229 Aur,
230}
231
232/// Minimal package summary used in lists and search results.
233///
234/// This is compact enough to render in lists and panes. For a richer, detailed
235/// view, see [`PackageDetails`].
236#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
237pub struct PackageItem {
238 /// Canonical package name.
239 pub name: String,
240 /// Version string as reported by the source.
241 pub version: String,
242 /// One-line description suitable for list display.
243 pub description: String,
244 /// Origin of the package (official repo or AUR).
245 pub source: Source,
246 /// AUR popularity score when available (AUR only).
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub popularity: Option<f64>,
249 /// Timestamp when package was flagged out-of-date (AUR only).
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub out_of_date: Option<u64>,
252 /// Whether package is orphaned (no active maintainer) (AUR only).
253 #[serde(default, skip_serializing_if = "is_false")]
254 pub orphaned: bool,
255}
256
257/// Full set of details for a package, suitable for a dedicated information
258/// pane.
259#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
260pub struct PackageDetails {
261 /// Repository name (e.g., "extra").
262 pub repository: String,
263 /// Package name.
264 pub name: String,
265 /// Full version string.
266 pub version: String,
267 /// Long description.
268 pub description: String,
269 /// Target architecture.
270 pub architecture: String,
271 /// Upstream project URL (may be empty if unknown).
272 pub url: String,
273 /// SPDX or human-readable license identifiers.
274 pub licenses: Vec<String>,
275 /// Group memberships.
276 pub groups: Vec<String>,
277 /// Virtual provisions supplied by this package.
278 pub provides: Vec<String>,
279 /// Required dependencies.
280 pub depends: Vec<String>,
281 /// Optional dependencies with annotations.
282 pub opt_depends: Vec<String>,
283 /// Packages that require this package.
284 pub required_by: Vec<String>,
285 /// Packages for which this package is optional.
286 pub optional_for: Vec<String>,
287 /// Conflicting packages.
288 pub conflicts: Vec<String>,
289 /// Packages that this package replaces.
290 pub replaces: Vec<String>,
291 /// Download size in bytes, if available.
292 pub download_size: Option<u64>,
293 /// Installed size in bytes, if available.
294 pub install_size: Option<u64>,
295 /// Packager or maintainer name.
296 pub owner: String, // packager/maintainer
297 /// Build or packaging date (string-formatted for display).
298 pub build_date: String,
299 /// AUR popularity score when available (AUR only).
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub popularity: Option<f64>,
302 /// Timestamp when package was flagged out-of-date (AUR only).
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub out_of_date: Option<u64>,
305 /// Whether package is orphaned (no active maintainer) (AUR only).
306 #[serde(default, skip_serializing_if = "is_false")]
307 pub orphaned: bool,
308}
309
310/// Search query sent to the background search worker.
311#[derive(Clone, Debug)]
312pub struct QueryInput {
313 /// Monotonic identifier used to correlate responses.
314 pub id: u64,
315 /// Raw query text entered by the user.
316 pub text: String,
317 /// Whether fuzzy search mode is enabled.
318 pub fuzzy: bool,
319}
320
321/// Results corresponding to a prior [`QueryInput`].
322#[derive(Clone, Debug)]
323pub struct SearchResults {
324 /// Echoed identifier from the originating query.
325 pub id: u64,
326 /// Matching packages in rank order.
327 pub items: Vec<PackageItem>,
328}
329
330/// Sorting mode for the Results list.
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum SortMode {
333 /// Default: Pacman (core/extra/other official) first, then AUR; name tiebreak.
334 RepoThenName,
335 /// AUR first (by highest popularity), then official repos; name tiebreak.
336 AurPopularityThenOfficial,
337 /// Best matches: Relevance by name to current query, then repo order, then name.
338 BestMatches,
339}
340
341impl SortMode {
342 /// Return the string key used in settings files for this sort mode.
343 ///
344 /// What: Map the enum variant to its persisted configuration key.
345 /// - Input: None; uses the receiver variant.
346 /// - Output: Static string representing the serialized value.
347 /// - Details: Keeps `settings.conf` forward/backward compatible by
348 /// standardizing the keys stored on disk.
349 #[must_use]
350 pub const fn as_config_key(&self) -> &'static str {
351 match self {
352 Self::RepoThenName => "alphabetical",
353 Self::AurPopularityThenOfficial => "aur_popularity",
354 Self::BestMatches => "best_matches",
355 }
356 }
357 /// Parse a sort mode from its settings key or legacy aliases.
358 ///
359 /// What: Convert persisted config values back into `SortMode` variants.
360 /// - Input: `s` string slice containing the stored key (case-insensitive).
361 /// - Output: `Some(SortMode)` when a known variant matches; `None` for
362 /// unrecognized keys.
363 /// - Details: Accepts historical aliases to maintain compatibility with
364 /// earlier Pacsea releases.
365 #[must_use]
366 pub fn from_config_key(s: &str) -> Option<Self> {
367 match s.trim().to_lowercase().as_str() {
368 "alphabetical" | "repo_then_name" | "pacman" => Some(Self::RepoThenName),
369 "aur_popularity" | "popularity" => Some(Self::AurPopularityThenOfficial),
370 "best_matches" | "relevance" => Some(Self::BestMatches),
371 _ => None,
372 }
373 }
374}
375
376/// Filter mode for installed packages in the "Installed" toggle.
377///
378/// What: Controls which packages are shown when viewing installed packages.
379/// - `LeafOnly`: Show only explicitly installed packages with no dependents (pacman -Qetq).
380/// - `AllExplicit`: Show all explicitly installed packages (pacman -Qeq).
381///
382/// Details:
383/// - `LeafOnly` is the default, showing packages safe to remove.
384/// - `AllExplicit` includes packages that other packages depend on.
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
386pub enum InstalledPackagesMode {
387 /// Show only leaf packages (explicitly installed, nothing depends on them).
388 #[default]
389 LeafOnly,
390 /// Show all explicitly installed packages.
391 AllExplicit,
392}
393
394impl InstalledPackagesMode {
395 /// Return the string key used in settings files for this mode.
396 ///
397 /// What: Map the enum variant to its persisted configuration key.
398 /// - Input: None; uses the receiver variant.
399 /// - Output: Static string representing the serialized value.
400 #[must_use]
401 pub const fn as_config_key(&self) -> &'static str {
402 match self {
403 Self::LeafOnly => "leaf",
404 Self::AllExplicit => "all",
405 }
406 }
407
408 /// Parse an installed packages mode from its settings key.
409 ///
410 /// What: Convert persisted config values back into `InstalledPackagesMode` variants.
411 /// - Input: `s` string slice containing the stored key (case-insensitive).
412 /// - Output: `Some(InstalledPackagesMode)` when a known variant matches; `None` otherwise.
413 #[must_use]
414 pub fn from_config_key(s: &str) -> Option<Self> {
415 match s.trim().to_lowercase().as_str() {
416 "leaf" | "leaf_only" => Some(Self::LeafOnly),
417 "all" | "all_explicit" => Some(Self::AllExplicit),
418 _ => None,
419 }
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::{InstalledPackagesMode, SortMode};
426
427 #[test]
428 /// What: Validate `SortMode` converts to and from configuration keys, including legacy aliases.
429 ///
430 /// Inputs:
431 /// - Known config keys, historical aliases, and a deliberately unknown key.
432 ///
433 /// Output:
434 /// - Returns the expected enum variants for recognised keys and `None` for the unknown entry.
435 ///
436 /// Details:
437 /// - Guards against accidental regressions when tweaking the accepted key list or canonical names.
438 fn state_sortmode_config_roundtrip_and_aliases() {
439 assert_eq!(SortMode::RepoThenName.as_config_key(), "alphabetical");
440 assert_eq!(
441 SortMode::from_config_key("alphabetical"),
442 Some(SortMode::RepoThenName)
443 );
444 assert_eq!(
445 SortMode::from_config_key("repo_then_name"),
446 Some(SortMode::RepoThenName)
447 );
448 assert_eq!(
449 SortMode::from_config_key("pacman"),
450 Some(SortMode::RepoThenName)
451 );
452 assert_eq!(
453 SortMode::from_config_key("aur_popularity"),
454 Some(SortMode::AurPopularityThenOfficial)
455 );
456 assert_eq!(
457 SortMode::from_config_key("popularity"),
458 Some(SortMode::AurPopularityThenOfficial)
459 );
460 assert_eq!(
461 SortMode::from_config_key("best_matches"),
462 Some(SortMode::BestMatches)
463 );
464 assert_eq!(
465 SortMode::from_config_key("relevance"),
466 Some(SortMode::BestMatches)
467 );
468 assert_eq!(SortMode::from_config_key("unknown"), None);
469 }
470
471 #[test]
472 /// What: Validate `InstalledPackagesMode` converts to and from configuration keys, including aliases.
473 ///
474 /// Inputs:
475 /// - Known config keys, aliases, case variations, whitespace, and a deliberately unknown key.
476 ///
477 /// Output:
478 /// - Returns the expected enum variants for recognised keys and `None` for the unknown entry.
479 ///
480 /// Details:
481 /// - Guards against accidental regressions when tweaking the accepted key list or canonical names.
482 /// - Verifies roundtrip conversions and case-insensitive parsing.
483 fn state_installedpackagesmode_config_roundtrip_and_aliases() {
484 // Test as_config_key for both variants
485 assert_eq!(InstalledPackagesMode::LeafOnly.as_config_key(), "leaf");
486 assert_eq!(InstalledPackagesMode::AllExplicit.as_config_key(), "all");
487
488 // Test from_config_key with canonical keys
489 assert_eq!(
490 InstalledPackagesMode::from_config_key("leaf"),
491 Some(InstalledPackagesMode::LeafOnly)
492 );
493 assert_eq!(
494 InstalledPackagesMode::from_config_key("all"),
495 Some(InstalledPackagesMode::AllExplicit)
496 );
497
498 // Test from_config_key with aliases
499 assert_eq!(
500 InstalledPackagesMode::from_config_key("leaf_only"),
501 Some(InstalledPackagesMode::LeafOnly)
502 );
503 assert_eq!(
504 InstalledPackagesMode::from_config_key("all_explicit"),
505 Some(InstalledPackagesMode::AllExplicit)
506 );
507
508 // Test roundtrip conversions
509 assert_eq!(
510 InstalledPackagesMode::from_config_key(InstalledPackagesMode::LeafOnly.as_config_key()),
511 Some(InstalledPackagesMode::LeafOnly)
512 );
513 assert_eq!(
514 InstalledPackagesMode::from_config_key(
515 InstalledPackagesMode::AllExplicit.as_config_key()
516 ),
517 Some(InstalledPackagesMode::AllExplicit)
518 );
519
520 // Test case insensitivity
521 assert_eq!(
522 InstalledPackagesMode::from_config_key("LEAF"),
523 Some(InstalledPackagesMode::LeafOnly)
524 );
525 assert_eq!(
526 InstalledPackagesMode::from_config_key("Leaf"),
527 Some(InstalledPackagesMode::LeafOnly)
528 );
529 assert_eq!(
530 InstalledPackagesMode::from_config_key("LEAF_ONLY"),
531 Some(InstalledPackagesMode::LeafOnly)
532 );
533 assert_eq!(
534 InstalledPackagesMode::from_config_key("All"),
535 Some(InstalledPackagesMode::AllExplicit)
536 );
537 assert_eq!(
538 InstalledPackagesMode::from_config_key("ALL_EXPLICIT"),
539 Some(InstalledPackagesMode::AllExplicit)
540 );
541
542 // Test whitespace trimming
543 assert_eq!(
544 InstalledPackagesMode::from_config_key(" leaf "),
545 Some(InstalledPackagesMode::LeafOnly)
546 );
547 assert_eq!(
548 InstalledPackagesMode::from_config_key(" all "),
549 Some(InstalledPackagesMode::AllExplicit)
550 );
551 assert_eq!(
552 InstalledPackagesMode::from_config_key(" leaf_only "),
553 Some(InstalledPackagesMode::LeafOnly)
554 );
555 assert_eq!(
556 InstalledPackagesMode::from_config_key(" all_explicit "),
557 Some(InstalledPackagesMode::AllExplicit)
558 );
559
560 // Test unknown key
561 assert_eq!(InstalledPackagesMode::from_config_key("unknown"), None);
562 assert_eq!(InstalledPackagesMode::from_config_key(""), None);
563 }
564}
565
566/// Visual indicator for Arch status line.
567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
568pub enum ArchStatusColor {
569 /// No color known yet.
570 None,
571 /// Everything operational (green).
572 Operational,
573 /// Relevant incident today (yellow).
574 IncidentToday,
575 /// Severe incident today (red).
576 IncidentSevereToday,
577}
578
579/// Which UI pane currently has keyboard focus.
580#[derive(Debug, Clone, Copy, PartialEq, Eq)]
581pub enum Focus {
582 /// Center pane: search input and results.
583 Search,
584 /// Left pane: recent queries list.
585 Recent,
586 /// Right pane: pending install list.
587 Install,
588}
589
590/// Which sub-pane within the right column is currently focused when applicable.
591#[derive(Debug, Clone, Copy, PartialEq, Eq)]
592pub enum RightPaneFocus {
593 /// Normal mode: single Install list occupies the right column.
594 Install,
595 /// Installed-only mode: left subpane for planned downgrades.
596 Downgrade,
597 /// Installed-only mode: right subpane for removals.
598 Remove,
599}
600
601/// Row model for the "TUI Optional Deps" modal/list.
602/// Each row represents a concrete package candidate such as an editor,
603/// terminal, clipboard tool, mirror updater, or AUR helper.
604#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
605pub struct OptionalDepRow {
606 /// Human-friendly label to display in the UI (e.g., "Editor: nvim", "Terminal: kitty").
607 pub label: String,
608 /// The concrete package name to check/install (e.g., "nvim", "kitty", "wl-clipboard",
609 /// "reflector", "pacman-mirrors", "paru", "yay").
610 pub package: String,
611 /// Whether this dependency is currently installed on the system.
612 #[serde(default)]
613 pub installed: bool,
614 /// Whether the user can select this row for installation (only when not installed).
615 #[serde(default)]
616 pub selectable: bool,
617 /// Optional note for environment/distro constraints (e.g., "Wayland", "X11", "Manjaro only").
618 #[serde(default, skip_serializing_if = "Option::is_none")]
619 pub note: Option<String>,
620}
621
622/// AUR package comment data structure.
623///
624/// What: Represents a single comment from an AUR package page.
625///
626/// Inputs: None (data structure).
627///
628/// Output: None (data structure).
629///
630/// Details:
631/// - Contains author, date, and content of a comment.
632/// - Includes optional timestamp for reliable chronological sorting.
633#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
634pub struct AurComment {
635 /// Stable comment identifier parsed from DOM when available.
636 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub id: Option<String>,
638 /// Comment author username.
639 pub author: String,
640 /// Human-readable date string.
641 pub date: String,
642 /// Unix timestamp for sorting (None if parsing failed).
643 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub date_timestamp: Option<i64>,
645 /// URL from the date link (None if not available).
646 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub date_url: Option<String>,
648 /// Comment content text.
649 pub content: String,
650 /// Whether this comment is pinned (shown at the top).
651 #[serde(default)]
652 pub pinned: bool,
653}
654
655/// Helper function for serde to skip serializing false boolean values.
656#[allow(clippy::trivially_copy_pass_by_ref)]
657const fn is_false(b: &bool) -> bool {
658 !(*b)
659}