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