Skip to main content

pacsea/state/
types.rs

1//! Core value types used by Pacsea state.
2
3use zeroize::Zeroize;
4
5/// What: Zeroizing wrapper for sensitive in-memory string data such as passwords.
6///
7/// Inputs:
8/// - Constructed from owned string data via [`From<String>`], [`From<&str>`], or [`SecureString::new`].
9///
10/// Output:
11/// - Provides read-only string access while ensuring secret bytes are wiped on drop.
12///
13/// Details:
14/// - The inner buffer is zeroized before deallocation to reduce residual secret exposure.
15/// - `Debug` output is intentionally redacted and never reveals the secret value.
16#[derive(Clone, Default, PartialEq, Eq)]
17pub struct SecureString(String);
18
19impl SecureString {
20    /// What: Create a new zeroizing string wrapper from owned string data.
21    ///
22    /// Inputs:
23    /// - `value`: Secret string to store.
24    ///
25    /// Output:
26    /// - New [`SecureString`] containing `value`.
27    ///
28    /// Details:
29    /// - Ownership is moved into the wrapper so drop-time zeroization covers this allocation.
30    #[must_use]
31    pub const fn new(value: String) -> Self {
32        Self(value)
33    }
34
35    /// What: Borrow the wrapped secret as an immutable string slice.
36    ///
37    /// Inputs:
38    /// - `self`: Borrowed secure string instance.
39    ///
40    /// Output:
41    /// - `&str` view of the wrapped value.
42    ///
43    /// Details:
44    /// - Intended for short-lived read usage (validation and command construction).
45    #[must_use]
46    pub fn as_str(&self) -> &str {
47        &self.0
48    }
49
50    /// What: Return the current number of bytes in the wrapped secret.
51    ///
52    /// Inputs:
53    /// - `self`: Borrowed secure string instance.
54    ///
55    /// Output:
56    /// - Byte length of the underlying UTF-8 buffer.
57    ///
58    /// Details:
59    /// - Mirrors `String::len` and is used by cursor movement logic in password input handling.
60    #[must_use]
61    pub const fn len(&self) -> usize {
62        self.0.len()
63    }
64
65    /// What: Check whether the wrapped secret is empty.
66    ///
67    /// Inputs:
68    /// - `self`: Borrowed secure string instance.
69    ///
70    /// Output:
71    /// - `true` when no bytes are present, otherwise `false`.
72    ///
73    /// Details:
74    /// - Mirrors `String::is_empty`.
75    #[must_use]
76    pub const fn is_empty(&self) -> bool {
77        self.0.is_empty()
78    }
79
80    /// What: Insert a character into the wrapped secret at a byte index.
81    ///
82    /// Inputs:
83    /// - `idx`: Byte position where `ch` is inserted.
84    /// - `ch`: Character to insert.
85    ///
86    /// Output:
87    /// - Mutates the wrapped secret in place.
88    ///
89    /// Details:
90    /// - Panics if `idx` is not on a valid UTF-8 boundary, matching `String::insert`.
91    pub fn insert(&mut self, idx: usize, ch: char) {
92        self.0.insert(idx, ch);
93    }
94
95    /// What: Remove and return a character from the wrapped secret at a byte index.
96    ///
97    /// Inputs:
98    /// - `idx`: Byte position of the character to remove.
99    ///
100    /// Output:
101    /// - Removed `char` value.
102    ///
103    /// Details:
104    /// - Panics if `idx` is not on a valid UTF-8 boundary, matching `String::remove`.
105    pub fn remove(&mut self, idx: usize) -> char {
106        self.0.remove(idx)
107    }
108
109    /// What: Append a character to the wrapped secret.
110    ///
111    /// Inputs:
112    /// - `ch`: Character to append.
113    ///
114    /// Output:
115    /// - Mutates the wrapped secret in place.
116    ///
117    /// Details:
118    /// - Mirrors `String::push` while keeping ownership in the secure wrapper.
119    pub fn push(&mut self, ch: char) {
120        self.0.push(ch);
121    }
122
123    /// What: Clear all bytes from the wrapped secret.
124    ///
125    /// Inputs:
126    /// - `self`: Mutable secure string instance.
127    ///
128    /// Output:
129    /// - Empties the wrapped string.
130    ///
131    /// Details:
132    /// - Mirrors `String::clear` for controlled reset paths.
133    pub fn clear(&mut self) {
134        self.0.clear();
135    }
136}
137
138impl std::ops::Deref for SecureString {
139    type Target = str;
140
141    fn deref(&self) -> &Self::Target {
142        self.as_str()
143    }
144}
145
146impl std::fmt::Debug for SecureString {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        f.write_str("SecureString([REDACTED])")
149    }
150}
151
152impl From<String> for SecureString {
153    fn from(value: String) -> Self {
154        Self::new(value)
155    }
156}
157
158impl From<&str> for SecureString {
159    fn from(value: &str) -> Self {
160        Self::new(value.to_string())
161    }
162}
163
164impl Drop for SecureString {
165    fn drop(&mut self) {
166        self.0.zeroize();
167    }
168}
169
170/// Minimal news entry for Arch news modal.
171#[derive(Clone, Debug)]
172pub struct NewsItem {
173    /// Publication date (short, e.g., 2025-10-11)
174    pub date: String,
175    /// Title text
176    pub title: String,
177    /// Link URL
178    pub url: String,
179}
180
181/// What: High-level application mode.
182///
183/// Inputs: None (enum variants)
184///
185/// Output: Represents whether the UI is in package, news, or integrated config-editor view.
186///
187/// Details:
188/// - `Package` preserves the existing package management experience.
189/// - `News` switches panes to the news feed experience.
190/// - `ConfigEditor` reuses the main window layout for integrated configuration editing.
191#[derive(Clone, Copy, Debug, PartialEq, Eq)]
192pub enum AppMode {
193    /// Package management/search mode (existing UI).
194    Package,
195    /// News feed mode (new UI).
196    News,
197    /// Integrated configuration editor mode rendered as a top-level view.
198    ConfigEditor,
199}
200
201/// What: News/advisory source type.
202///
203/// Inputs: None (enum variants)
204///
205/// Output: Identifies where a news feed item originates.
206///
207/// Details:
208/// - Distinguishes Arch news RSS posts from security advisories.
209#[derive(
210    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
211)]
212pub enum NewsFeedSource {
213    /// Official Arch Linux news RSS item.
214    ArchNews,
215    /// security.archlinux.org advisory.
216    SecurityAdvisory,
217    /// Installed official package received a version update.
218    InstalledPackageUpdate,
219    /// Installed AUR package received a version update.
220    AurPackageUpdate,
221    /// New AUR comment on an installed package.
222    AurComment,
223}
224
225/// What: Severity levels for security advisories.
226///
227/// Inputs: None (enum variants)
228///
229/// Output: Normalized advisory severity.
230///
231/// Details:
232/// - Ordered from lowest to highest severity for sorting.
233#[derive(
234    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
235)]
236pub enum AdvisorySeverity {
237    /// Unknown or not provided.
238    Unknown,
239    /// Low severity.
240    Low,
241    /// Medium severity.
242    Medium,
243    /// High severity.
244    High,
245    /// Critical severity.
246    Critical,
247}
248
249/// What: Map advisory severity to a numeric rank for sorting (higher is worse).
250///
251/// Inputs:
252/// - `severity`: Optional advisory severity value.
253///
254/// Output:
255/// - Numeric rank where larger numbers indicate higher severity (Critical highest).
256///
257/// Details:
258/// - Returns `0` when severity is missing to ensure advisories without severity fall last.
259/// - Keeps ordering stable across both news feed sorting and advisory-specific listings.
260#[must_use]
261pub const fn severity_rank(severity: Option<AdvisorySeverity>) -> u8 {
262    match severity {
263        Some(AdvisorySeverity::Critical) => 5,
264        Some(AdvisorySeverity::High) => 4,
265        Some(AdvisorySeverity::Medium) => 3,
266        Some(AdvisorySeverity::Low) => 2,
267        Some(AdvisorySeverity::Unknown) => 1,
268        None => 0,
269    }
270}
271
272/// What: Sort options for news feed results.
273///
274/// Inputs: None (enum variants)
275///
276/// Output: Selected sort mode for news items.
277///
278/// Details:
279/// - `DateDesc` is newest-first default.
280#[derive(Clone, Copy, Debug, PartialEq, Eq)]
281pub enum NewsSortMode {
282    /// Newest first by date.
283    DateDesc,
284    /// Oldest first by date.
285    DateAsc,
286    /// Alphabetical by title.
287    Title,
288    /// Group by source then title.
289    SourceThenTitle,
290    /// Severity first (Critical..Unknown), then date (newest first).
291    SeverityThenDate,
292    /// Unread items first, then date (newest first).
293    UnreadThenDate,
294}
295
296/// What: Read filter applied to news feed items.
297///
298/// Inputs: None (enum variants)
299///
300/// Output:
301/// - Indicates whether to show all, only read, or only unread items.
302///
303/// Details:
304/// - Used by the News Feed list and toolbar filter chip.
305#[derive(Clone, Copy, Debug, PartialEq, Eq)]
306pub enum NewsReadFilter {
307    /// Show all items regardless of read status.
308    All,
309    /// Show only items marked as read.
310    Read,
311    /// Show only items not marked as read.
312    Unread,
313}
314
315/// What: Unified news/advisory feed item for the news view.
316///
317/// Inputs:
318/// - Fields describing the item (title, summary, url, source, severity, packages, date)
319///
320/// Output:
321/// - Data ready for list and details rendering in news mode.
322///
323/// Details:
324/// - `id` is a stable identifier (URL for news, advisory ID for security).
325/// - `packages` holds affected package names for advisories.
326#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
327pub struct NewsFeedItem {
328    /// Stable identifier (URL or advisory ID).
329    pub id: String,
330    /// Publication or update date (YYYY-MM-DD).
331    pub date: String,
332    /// Human-readable title/headline.
333    pub title: String,
334    /// Optional summary/description.
335    pub summary: Option<String>,
336    /// Optional link URL for details.
337    pub url: Option<String>,
338    /// Source type (Arch news vs security advisory).
339    pub source: NewsFeedSource,
340    /// Optional advisory severity.
341    pub severity: Option<AdvisorySeverity>,
342    /// Affected packages (advisories only).
343    pub packages: Vec<String>,
344}
345
346/// What: Bundle of news feed items and associated last-seen state updates.
347///
348/// Inputs:
349/// - `items`: Aggregated news feed entries ready for rendering.
350/// - `seen_pkg_versions`: Updated map of installed package names to last-seen versions.
351/// - `seen_aur_comments`: Updated map of AUR packages to last-seen comment identifiers.
352///
353/// Output:
354/// - Carries feed payload plus dedupe state for persistence.
355///
356/// Details:
357/// - Used as the payload between background fetchers and UI to keep last-seen maps in sync.
358#[derive(Clone, Debug)]
359pub struct NewsFeedPayload {
360    /// Aggregated and sorted feed items.
361    pub items: Vec<NewsFeedItem>,
362    /// Last-seen versions for installed packages.
363    pub seen_pkg_versions: std::collections::HashMap<String, String>,
364    /// Last-seen comment identifiers for installed AUR packages.
365    pub seen_aur_comments: std::collections::HashMap<String, String>,
366}
367
368/// What: Persisted bookmark entry for news items, including cached content and optional local HTML path.
369///
370/// Inputs:
371/// - `item`: The news feed item metadata.
372/// - `content`: Parsed article content stored locally for offline display.
373/// - `html_path`: Optional filesystem path to the saved HTML file (if downloaded).
374#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
375pub struct NewsBookmark {
376    /// News feed metadata for the bookmark.
377    pub item: NewsFeedItem,
378    /// Parsed content cached locally.
379    pub content: Option<String>,
380    /// Path to the saved HTML file on disk (if downloaded).
381    pub html_path: Option<String>,
382}
383
384/// Package source origin.
385///
386/// Indicates whether a package originates from the official repositories or
387/// the Arch User Repository.
388#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
389pub enum Source {
390    /// Official repository package and its associated repository and target
391    /// architecture.
392    Official {
393        /// Repository name (e.g., "core", "extra", "community").
394        repo: String,
395        /// Target architecture (e.g., `x86_64`).
396        arch: String,
397    },
398    /// AUR package.
399    Aur,
400}
401
402/// Minimal package summary used in lists and search results.
403///
404/// This is compact enough to render in lists and panes. For a richer, detailed
405/// view, see [`PackageDetails`].
406#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
407pub struct PackageItem {
408    /// Canonical package name.
409    pub name: String,
410    /// Version string as reported by the source.
411    pub version: String,
412    /// One-line description suitable for list display.
413    pub description: String,
414    /// Origin of the package (official repo or AUR).
415    pub source: Source,
416    /// AUR popularity score when available (AUR only).
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub popularity: Option<f64>,
419    /// Timestamp when package was flagged out-of-date (AUR only).
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub out_of_date: Option<u64>,
422    /// Whether package is orphaned (no active maintainer) (AUR only).
423    #[serde(default, skip_serializing_if = "is_false")]
424    pub orphaned: bool,
425}
426
427/// Full set of details for a package, suitable for a dedicated information
428/// pane.
429#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
430pub struct PackageDetails {
431    /// Repository name (e.g., "extra").
432    pub repository: String,
433    /// Package name.
434    pub name: String,
435    /// Full version string.
436    pub version: String,
437    /// Long description.
438    pub description: String,
439    /// Target architecture.
440    pub architecture: String,
441    /// Upstream project URL (may be empty if unknown).
442    pub url: String,
443    /// SPDX or human-readable license identifiers.
444    pub licenses: Vec<String>,
445    /// Group memberships.
446    pub groups: Vec<String>,
447    /// Virtual provisions supplied by this package.
448    pub provides: Vec<String>,
449    /// Required dependencies.
450    pub depends: Vec<String>,
451    /// Optional dependencies with annotations.
452    pub opt_depends: Vec<String>,
453    /// Packages that require this package.
454    pub required_by: Vec<String>,
455    /// Packages for which this package is optional.
456    pub optional_for: Vec<String>,
457    /// Conflicting packages.
458    pub conflicts: Vec<String>,
459    /// Packages that this package replaces.
460    pub replaces: Vec<String>,
461    /// Download size in bytes, if available.
462    pub download_size: Option<u64>,
463    /// Installed size in bytes, if available.
464    pub install_size: Option<u64>,
465    /// Packager or maintainer name.
466    pub owner: String, // packager/maintainer
467    /// Build or packaging date (string-formatted for display).
468    pub build_date: String,
469    /// AUR popularity score when available (AUR only).
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub popularity: Option<f64>,
472    /// Timestamp when package was flagged out-of-date (AUR only).
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub out_of_date: Option<u64>,
475    /// Whether package is orphaned (no active maintainer) (AUR only).
476    #[serde(default, skip_serializing_if = "is_false")]
477    pub orphaned: bool,
478}
479
480/// Search query sent to the background search worker.
481#[derive(Clone, Debug)]
482pub struct QueryInput {
483    /// Monotonic identifier used to correlate responses.
484    pub id: u64,
485    /// Raw query text entered by the user.
486    pub text: String,
487    /// Whether fuzzy search mode is enabled.
488    pub fuzzy: bool,
489}
490
491/// Results corresponding to a prior [`QueryInput`].
492#[derive(Clone, Debug)]
493pub struct SearchResults {
494    /// Echoed identifier from the originating query.
495    pub id: u64,
496    /// Matching packages in rank order.
497    pub items: Vec<PackageItem>,
498}
499
500/// What: Request payload to run PKGBUILD static checks.
501#[derive(Clone, Debug)]
502pub struct PkgbuildCheckRequest {
503    /// Selected package name.
504    pub package_name: String,
505    /// Current PKGBUILD text shown in preview.
506    pub pkgbuild_text: String,
507    /// Global dry-run flag.
508    pub dry_run: bool,
509}
510
511/// What: Response payload for PKGBUILD static checks.
512#[derive(Clone, Debug)]
513pub struct PkgbuildCheckResponse {
514    /// Package name tied to this run.
515    pub package_name: String,
516    /// Parsed findings for list rendering.
517    pub findings: Vec<crate::state::app_state::PkgbuildCheckFinding>,
518    /// Raw per-tool outputs from latest PKGBUILD check run.
519    pub raw_results: Vec<crate::state::app_state::PkgbuildToolRawResult>,
520    /// User-facing missing tool hints.
521    pub missing_tools: Vec<String>,
522    /// Optional high-level execution error.
523    pub last_error: Option<String>,
524}
525
526/// Sorting mode for the Results list.
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528pub enum SortMode {
529    /// Default: Pacman (core/extra/other official) first, then AUR; name tiebreak.
530    RepoThenName,
531    /// AUR first (by highest popularity), then official repos; name tiebreak.
532    AurPopularityThenOfficial,
533    /// Best matches: Relevance by name to current query, then repo order, then name.
534    BestMatches,
535}
536
537impl SortMode {
538    /// Return the string key used in settings files for this sort mode.
539    ///
540    /// What: Map the enum variant to its persisted configuration key.
541    /// - Input: None; uses the receiver variant.
542    /// - Output: Static string representing the serialized value.
543    /// - Details: Keeps `settings.conf` forward/backward compatible by
544    ///   standardizing the keys stored on disk.
545    #[must_use]
546    pub const fn as_config_key(&self) -> &'static str {
547        match self {
548            Self::RepoThenName => "alphabetical",
549            Self::AurPopularityThenOfficial => "aur_popularity",
550            Self::BestMatches => "best_matches",
551        }
552    }
553    /// Parse a sort mode from its settings key or legacy aliases.
554    ///
555    /// What: Convert persisted config values back into `SortMode` variants.
556    /// - Input: `s` string slice containing the stored key (case-insensitive).
557    /// - Output: `Some(SortMode)` when a known variant matches; `None` for
558    ///   unrecognized keys.
559    /// - Details: Accepts historical aliases to maintain compatibility with
560    ///   earlier Pacsea releases.
561    #[must_use]
562    pub fn from_config_key(s: &str) -> Option<Self> {
563        match s.trim().to_lowercase().as_str() {
564            "alphabetical" | "repo_then_name" | "pacman" => Some(Self::RepoThenName),
565            "aur_popularity" | "popularity" => Some(Self::AurPopularityThenOfficial),
566            "best_matches" | "relevance" => Some(Self::BestMatches),
567            _ => None,
568        }
569    }
570}
571
572/// Filter mode for installed packages in the "Installed" toggle.
573///
574/// What: Controls which packages are shown when viewing installed packages.
575/// - `LeafOnly`: Show only explicitly installed packages with no dependents (pacman -Qetq).
576/// - `AllExplicit`: Show all explicitly installed packages (pacman -Qeq).
577///
578/// Details:
579/// - `LeafOnly` is the default, showing packages safe to remove.
580/// - `AllExplicit` includes packages that other packages depend on.
581#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
582pub enum InstalledPackagesMode {
583    /// Show only leaf packages (explicitly installed, nothing depends on them).
584    #[default]
585    LeafOnly,
586    /// Show all explicitly installed packages.
587    AllExplicit,
588}
589
590impl InstalledPackagesMode {
591    /// Return the string key used in settings files for this mode.
592    ///
593    /// What: Map the enum variant to its persisted configuration key.
594    /// - Input: None; uses the receiver variant.
595    /// - Output: Static string representing the serialized value.
596    #[must_use]
597    pub const fn as_config_key(&self) -> &'static str {
598        match self {
599            Self::LeafOnly => "leaf",
600            Self::AllExplicit => "all",
601        }
602    }
603
604    /// Parse an installed packages mode from its settings key.
605    ///
606    /// What: Convert persisted config values back into `InstalledPackagesMode` variants.
607    /// - Input: `s` string slice containing the stored key (case-insensitive).
608    /// - Output: `Some(InstalledPackagesMode)` when a known variant matches; `None` otherwise.
609    #[must_use]
610    pub fn from_config_key(s: &str) -> Option<Self> {
611        match s.trim().to_lowercase().as_str() {
612            "leaf" | "leaf_only" => Some(Self::LeafOnly),
613            "all" | "all_explicit" => Some(Self::AllExplicit),
614            _ => None,
615        }
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::{InstalledPackagesMode, SortMode};
622
623    #[test]
624    /// What: Validate `SortMode` converts to and from configuration keys, including legacy aliases.
625    ///
626    /// Inputs:
627    /// - Known config keys, historical aliases, and a deliberately unknown key.
628    ///
629    /// Output:
630    /// - Returns the expected enum variants for recognised keys and `None` for the unknown entry.
631    ///
632    /// Details:
633    /// - Guards against accidental regressions when tweaking the accepted key list or canonical names.
634    fn state_sortmode_config_roundtrip_and_aliases() {
635        assert_eq!(SortMode::RepoThenName.as_config_key(), "alphabetical");
636        assert_eq!(
637            SortMode::from_config_key("alphabetical"),
638            Some(SortMode::RepoThenName)
639        );
640        assert_eq!(
641            SortMode::from_config_key("repo_then_name"),
642            Some(SortMode::RepoThenName)
643        );
644        assert_eq!(
645            SortMode::from_config_key("pacman"),
646            Some(SortMode::RepoThenName)
647        );
648        assert_eq!(
649            SortMode::from_config_key("aur_popularity"),
650            Some(SortMode::AurPopularityThenOfficial)
651        );
652        assert_eq!(
653            SortMode::from_config_key("popularity"),
654            Some(SortMode::AurPopularityThenOfficial)
655        );
656        assert_eq!(
657            SortMode::from_config_key("best_matches"),
658            Some(SortMode::BestMatches)
659        );
660        assert_eq!(
661            SortMode::from_config_key("relevance"),
662            Some(SortMode::BestMatches)
663        );
664        assert_eq!(SortMode::from_config_key("unknown"), None);
665    }
666
667    #[test]
668    /// What: Validate `InstalledPackagesMode` converts to and from configuration keys, including aliases.
669    ///
670    /// Inputs:
671    /// - Known config keys, aliases, case variations, whitespace, and a deliberately unknown key.
672    ///
673    /// Output:
674    /// - Returns the expected enum variants for recognised keys and `None` for the unknown entry.
675    ///
676    /// Details:
677    /// - Guards against accidental regressions when tweaking the accepted key list or canonical names.
678    /// - Verifies roundtrip conversions and case-insensitive parsing.
679    fn state_installedpackagesmode_config_roundtrip_and_aliases() {
680        // Test as_config_key for both variants
681        assert_eq!(InstalledPackagesMode::LeafOnly.as_config_key(), "leaf");
682        assert_eq!(InstalledPackagesMode::AllExplicit.as_config_key(), "all");
683
684        // Test from_config_key with canonical keys
685        assert_eq!(
686            InstalledPackagesMode::from_config_key("leaf"),
687            Some(InstalledPackagesMode::LeafOnly)
688        );
689        assert_eq!(
690            InstalledPackagesMode::from_config_key("all"),
691            Some(InstalledPackagesMode::AllExplicit)
692        );
693
694        // Test from_config_key with aliases
695        assert_eq!(
696            InstalledPackagesMode::from_config_key("leaf_only"),
697            Some(InstalledPackagesMode::LeafOnly)
698        );
699        assert_eq!(
700            InstalledPackagesMode::from_config_key("all_explicit"),
701            Some(InstalledPackagesMode::AllExplicit)
702        );
703
704        // Test roundtrip conversions
705        assert_eq!(
706            InstalledPackagesMode::from_config_key(InstalledPackagesMode::LeafOnly.as_config_key()),
707            Some(InstalledPackagesMode::LeafOnly)
708        );
709        assert_eq!(
710            InstalledPackagesMode::from_config_key(
711                InstalledPackagesMode::AllExplicit.as_config_key()
712            ),
713            Some(InstalledPackagesMode::AllExplicit)
714        );
715
716        // Test case insensitivity
717        assert_eq!(
718            InstalledPackagesMode::from_config_key("LEAF"),
719            Some(InstalledPackagesMode::LeafOnly)
720        );
721        assert_eq!(
722            InstalledPackagesMode::from_config_key("Leaf"),
723            Some(InstalledPackagesMode::LeafOnly)
724        );
725        assert_eq!(
726            InstalledPackagesMode::from_config_key("LEAF_ONLY"),
727            Some(InstalledPackagesMode::LeafOnly)
728        );
729        assert_eq!(
730            InstalledPackagesMode::from_config_key("All"),
731            Some(InstalledPackagesMode::AllExplicit)
732        );
733        assert_eq!(
734            InstalledPackagesMode::from_config_key("ALL_EXPLICIT"),
735            Some(InstalledPackagesMode::AllExplicit)
736        );
737
738        // Test whitespace trimming
739        assert_eq!(
740            InstalledPackagesMode::from_config_key("  leaf  "),
741            Some(InstalledPackagesMode::LeafOnly)
742        );
743        assert_eq!(
744            InstalledPackagesMode::from_config_key("  all  "),
745            Some(InstalledPackagesMode::AllExplicit)
746        );
747        assert_eq!(
748            InstalledPackagesMode::from_config_key("  leaf_only  "),
749            Some(InstalledPackagesMode::LeafOnly)
750        );
751        assert_eq!(
752            InstalledPackagesMode::from_config_key("  all_explicit  "),
753            Some(InstalledPackagesMode::AllExplicit)
754        );
755
756        // Test unknown key
757        assert_eq!(InstalledPackagesMode::from_config_key("unknown"), None);
758        assert_eq!(InstalledPackagesMode::from_config_key(""), None);
759    }
760}
761
762/// Visual indicator for Arch status line.
763#[derive(Debug, Clone, Copy, PartialEq, Eq)]
764pub enum ArchStatusColor {
765    /// No color known yet.
766    None,
767    /// Everything operational (green).
768    Operational,
769    /// Relevant incident today (yellow).
770    IncidentToday,
771    /// Severe incident today (red).
772    IncidentSevereToday,
773}
774
775/// Which UI pane currently has keyboard focus.
776#[derive(Debug, Clone, Copy, PartialEq, Eq)]
777pub enum Focus {
778    /// Center pane: search input and results.
779    Search,
780    /// Left pane: recent queries list.
781    Recent,
782    /// Right pane: pending install list.
783    Install,
784}
785
786/// Which sub-pane within the right column is currently focused when applicable.
787#[derive(Debug, Clone, Copy, PartialEq, Eq)]
788pub enum RightPaneFocus {
789    /// Normal mode: single Install list occupies the right column.
790    Install,
791    /// Installed-only mode: left subpane for planned downgrades.
792    Downgrade,
793    /// Installed-only mode: right subpane for removals.
794    Remove,
795}
796
797/// Row model for the "TUI Optional Deps" modal/list.
798/// Each row represents a concrete package candidate such as an editor,
799/// terminal, clipboard tool, mirror updater, or AUR helper.
800#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
801pub struct OptionalDepRow {
802    /// Human-friendly label to display in the UI (e.g., "Editor: nvim", "Terminal: kitty").
803    pub label: String,
804    /// The concrete package name to check/install (e.g., "nvim", "kitty", "wl-clipboard",
805    /// "reflector", "pacman-mirrors", "paru", "yay").
806    pub package: String,
807    /// Whether this dependency is currently installed on the system.
808    #[serde(default)]
809    pub installed: bool,
810    /// Whether the user can select this row for installation (only when not installed).
811    #[serde(default)]
812    pub selectable: bool,
813    /// Optional note for environment/distro constraints (e.g., "Wayland", "X11", "Manjaro only").
814    #[serde(default, skip_serializing_if = "Option::is_none")]
815    pub note: Option<String>,
816}
817
818/// What: Pacman `[repo]` presence as shown in the read-only Repositories modal.
819///
820/// Inputs:
821/// - Set when merging `repos.conf` rows with a live `pacman.conf` scan.
822///
823/// Output:
824/// - Drives result column labels in the UI.
825///
826/// Details:
827/// - Distinct from Pacsea results-filter toggles; this reflects `/etc/pacman.conf` only.
828#[derive(Clone, Copy, Debug, PartialEq, Eq)]
829pub enum RepositoryPacmanStatus {
830    /// No matching section header found.
831    Absent,
832    /// Active `[name]` header exists.
833    Active,
834    /// Only `# [name]` (commented) headers exist.
835    Commented,
836}
837
838/// What: Signing key trust hint for a `[[repo]]` row that declares `key_id`.
839///
840/// Inputs:
841/// - Derived from a batched `pacman-key --list-keys` check.
842///
843/// Output:
844/// - Column text in the Repositories modal.
845///
846/// Details:
847/// - `Unknown` covers missing `pacman-key`, failed runs, or fingerprints too short to match safely.
848#[derive(Clone, Copy, Debug, PartialEq, Eq)]
849pub enum RepositoryKeyTrust {
850    /// Row has no `key_id`; nothing to verify.
851    NotApplicable,
852    /// Fingerprint (normalized) appears in the key listing.
853    Trusted,
854    /// Listing succeeded but fingerprint not found.
855    NotTrusted,
856    /// Could not determine (tool missing, error, or invalid id).
857    Unknown,
858}
859
860/// What: One row in the read-only Repositories modal (merged `repos.conf` + live pacman scan).
861///
862/// Inputs:
863/// - Built when opening the Repositories modal from `logic::repos`.
864///
865/// Output:
866/// - Rendered as a list line with status chips.
867///
868/// Details:
869/// - Read-only in Phase 2; apply flows will extend behavior later.
870#[derive(Clone, Debug)]
871pub struct RepositoryModalRow {
872    /// Pacman section `name` from `repos.conf`.
873    pub pacman_section_name: String,
874    /// Raw `results_filter` label for display.
875    pub results_filter_display: String,
876    /// Whether `/etc/pacman.conf` (includes) contains this repo section.
877    pub pacman_status: RepositoryPacmanStatus,
878    /// Optional short source file hint (e.g. include file name).
879    pub source_hint: Option<String>,
880    /// Keyring trust classification when `key_id` is set.
881    pub key_trust: RepositoryKeyTrust,
882}
883
884/// AUR package comment data structure.
885///
886/// What: Represents a single comment from an AUR package page.
887///
888/// Inputs: None (data structure).
889///
890/// Output: None (data structure).
891///
892/// Details:
893/// - Contains author, date, and content of a comment.
894/// - Includes optional timestamp for reliable chronological sorting.
895#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
896pub struct AurComment {
897    /// Stable comment identifier parsed from DOM when available.
898    #[serde(default, skip_serializing_if = "Option::is_none")]
899    pub id: Option<String>,
900    /// Comment author username.
901    pub author: String,
902    /// Human-readable date string.
903    pub date: String,
904    /// Unix timestamp for sorting (None if parsing failed).
905    #[serde(default, skip_serializing_if = "Option::is_none")]
906    pub date_timestamp: Option<i64>,
907    /// URL from the date link (None if not available).
908    #[serde(default, skip_serializing_if = "Option::is_none")]
909    pub date_url: Option<String>,
910    /// Comment content text.
911    pub content: String,
912    /// Whether this comment is pinned (shown at the top).
913    #[serde(default)]
914    pub pinned: bool,
915}
916
917/// Helper function for serde to skip serializing false boolean values.
918#[allow(clippy::trivially_copy_pass_by_ref)]
919const fn is_false(b: &bool) -> bool {
920    !(*b)
921}