pacsea/
announcements.rs

1//! Announcement system supporting both version-embedded and remote announcements.
2
3use chrono::{NaiveDate, Utc};
4use serde::Deserialize;
5use std::cmp::Ordering;
6
7/// What: Version-embedded announcement for a specific app version.
8///
9/// Inputs: None (static data).
10///
11/// Output: Represents an announcement tied to a specific version.
12///
13/// Details:
14/// - Shown when the base version (X.X.X) matches, regardless of suffix.
15/// - Content is embedded in the binary at compile time.
16/// - Version matching compares only the base version (X.X.X), ignoring suffixes.
17/// - Announcements show again when the suffix changes (e.g., "0.6.0-pr#85" -> "0.6.0-pr#86").
18/// - For example, announcement version "0.6.0-pr#85" will match Cargo.toml version "0.6.0".
19pub struct VersionAnnouncement {
20    /// Version string this announcement is for (e.g., "0.6.0" or "0.6.0-pr#85").
21    /// Only the base version (X.X.X) is used for matching, but full version is used for tracking.
22    pub version: &'static str,
23    /// Title of the announcement.
24    pub title: &'static str,
25    /// Markdown content of the announcement.
26    pub content: &'static str,
27}
28
29/// What: Embedded announcements for specific app versions.
30///
31/// Inputs: None (static data).
32///
33/// Output: Array of version announcements.
34///
35/// Details:
36/// - Add new announcements here for each release.
37/// - Version matching compares only the base version (X.X.X), so "0.6.0-pr#85" matches "0.6.0".
38/// - Announcements show again when the suffix changes (e.g., "0.6.0-pr#85" -> "0.6.0-pr#86").
39/// - Cargo.toml can stay at "0.6.0" while announcements use "0.6.0-pr#85" for clarity.
40pub const VERSION_ANNOUNCEMENTS: &[VersionAnnouncement] = &[
41    // Add version-specific announcements here
42    VersionAnnouncement {
43        version: "0.6.1",
44        title: "Announcement Modal",
45        content: "## What's New\n\n- Announcement modal system - view important updates and version notes\n- Fixed global keybinds interfering with modals - keyboard shortcuts now work correctly\n\n## Chore\n\n- Updated PKGBUILD SHASUM\n",
46    },
47    VersionAnnouncement {
48        version: "0.6.2",
49        title: "Version 0.6.2",
50        content: "## What's New\n\n### ⚡ Force Sync Option\n- Toggle between Normal (-Syu) and Force Sync (-Syyu) in System Update\n- Use ←/→ or Tab keys to switch sync mode\n\n### 🐛 Bug Fixes\n- Install list preserved: System update no longer clears queued packages\n- Faster exit: App closes immediately when exiting during preflight\n- Auto-refresh: Updates count refreshes after install/remove/downgrade\n\n### 🌍 Translations\n- Updated Hungarian translations\n",
51    },
52    VersionAnnouncement {
53        version: "0.7.0",
54        title: "Version 0.7.0",
55        content: "## What's New\n\n- **Arch Linux News**: Latest announcements and updates from archlinux.org\n- **Security Advisories**: Security alerts with severity indicators and affecte...\n- **Package Updates**: Track version changes for your installed packages with c...\n- **AUR Comments**: Recent community discussions and feedback\n- **Change Detection**: Automatically detects package changes (version, maintai...\n\n",
56    },
57    VersionAnnouncement {
58        version: "0.7.1",
59        title: "Version 0.7.1",
60        content: "## What's New\n\n### News Mode Enhancements\n- **Separated search inputs**: News mode and Package mode now have independent search fields\n  - No more shared state issues when switching between modes\n  - Search text is preserved when switching modes\n- **Improved mark-as-read behavior**: Mark read actions (`r` key) now only work in normal mode\n  - Prevents accidental marking when typing 'r' in insert mode\n  - More consistent with vim-like behavior\n\n### Toast Notifications\n- Improved toast clearing logic for better user experience\n- Enhanced toast title detection for news, clipboard, and notification types\n- Added notification title translations\n\n### UI Polish\n- Sort menu no longer auto-closes (stays open until you select an option or close it)\n- Added `change_sort` keybind to help footer in News mode\n- Fixed help text punctuation for better readability\n\n",
61    },
62    VersionAnnouncement {
63        version: "0.7.2",
64        title: "Version 0.7.2",
65        content: "## What's New\n\n- Updated multiple dependencies to address low-severity security vulnerabilities\n- Updated core dependencies including `clap`, `ratatui`, `tokio`, `reqwest`, and more\n- Improved overall security posture of the application\n- Fixed CodeQL security analysis issues (#2, #3, #4, #5)\n- Enhanced input validation in import modals\n\n",
66    },
67    VersionAnnouncement {
68        version: "0.7.3",
69        title: "Version 0.7.3",
70        content: "## What's New\n\n- TUI install/update/downgrade operations can use passwordless sudo when configured\n- Same behavior as CLI: no password prompt when sudo allows it\n- Remove operations always ask for password for safety\n- Opening config files now uses your `VISUAL` or `EDITOR` environment variable\n- Edit settings, theme, and keybinds in your preferred editor\n\n",
71    },
72];
73
74/// What: Remote announcement fetched from GitHub Gist.
75///
76/// Inputs: None (deserialized from JSON).
77///
78/// Output: Represents a remote announcement with version filtering and expiration.
79///
80/// Details:
81/// - Fetched from configured URL (GitHub Gist raw URL).
82/// - Can target specific version ranges.
83/// - Can expire after a certain date.
84#[derive(Debug, Deserialize)]
85pub struct RemoteAnnouncement {
86    /// Unique identifier for this announcement (used for tracking read state).
87    pub id: String,
88    /// Title of the announcement.
89    pub title: String,
90    /// Markdown content of the announcement.
91    pub content: String,
92    /// Minimum version (inclusive) that should see this announcement.
93    pub min_version: Option<String>,
94    /// Maximum version (inclusive) that should see this announcement.
95    /// If None, no upper limit.
96    pub max_version: Option<String>,
97    /// Expiration date in ISO format (YYYY-MM-DD). If None, never expires.
98    pub expires: Option<String>,
99}
100
101/// What: Compare version strings numerically.
102///
103/// Inputs:
104/// - `a`: Left-hand version string.
105/// - `b`: Right-hand version string.
106///
107/// Output:
108/// - `Ordering` indicating which version is greater.
109///
110/// Details:
111/// - Uses the same logic as preflight version comparison.
112/// - Splits on `.` and `-`, comparing numeric segments.
113fn compare_versions(a: &str, b: &str) -> Ordering {
114    let a_parts: Vec<&str> = a.split(['.', '-']).collect();
115    let b_parts: Vec<&str> = b.split(['.', '-']).collect();
116    let len = a_parts.len().max(b_parts.len());
117
118    for idx in 0..len {
119        let a_seg = a_parts.get(idx).copied().unwrap_or("0");
120        let b_seg = b_parts.get(idx).copied().unwrap_or("0");
121
122        match (a_seg.parse::<i64>(), b_seg.parse::<i64>()) {
123            (Ok(a_num), Ok(b_num)) => match a_num.cmp(&b_num) {
124                Ordering::Equal => {}
125                ord => return ord,
126            },
127            _ => match a_seg.cmp(b_seg) {
128                Ordering::Equal => {}
129                ord => return ord,
130            },
131        }
132    }
133
134    Ordering::Equal
135}
136
137/// What: Extract base version (X.X.X) from a version string, ignoring suffixes.
138///
139/// Inputs:
140/// - `version`: Version string (e.g., "0.6.0", "0.6.0-pr#85", "0.6.0-beta").
141///
142/// Output:
143/// - Base version string (e.g., "0.6.0").
144///
145/// Details:
146/// - Extracts the semantic version part (major.minor.patch) before any suffix.
147/// - Handles versions like "0.6.0", "0.6.0-pr#85", "0.6.0-beta", "1.2.3-rc1".
148/// - Splits on '-' to remove pre-release identifiers and other suffixes.
149/// - Normalizes to X.X.X format (adds .0 for missing segments).
150#[must_use]
151pub fn extract_base_version(version: &str) -> String {
152    // Split on '-' to remove pre-release identifiers and suffixes
153    // This handles formats like "0.6.0-pr#85", "0.6.0-beta", "1.2.3-rc1"
154    let base = version.split('-').next().unwrap_or(version);
155
156    // Extract only the X.X.X part (up to 3 numeric segments separated by dots)
157    let parts: Vec<&str> = base.split('.').collect();
158    match parts.len() {
159        n if n >= 3 => {
160            // Take first 3 parts and join them
161            format!("{}.{}.{}", parts[0], parts[1], parts[2])
162        }
163        2 => {
164            // Handle X.X format, add .0
165            format!("{}.{}.0", parts[0], parts[1])
166        }
167        1 => {
168            // Handle X format, add .0.0
169            format!("{}.0.0", parts[0])
170        }
171        _ => base.to_string(),
172    }
173}
174
175/// What: Check if current version matches the version range.
176///
177/// Inputs:
178/// - `current_version`: Current app version (e.g., "0.6.0").
179/// - `min_version`: Optional minimum version (inclusive).
180/// - `max_version`: Optional maximum version (inclusive).
181///
182/// Output:
183/// - `true` if current version is within the range, `false` otherwise.
184///
185/// Details:
186/// - If `min_version` is None, no lower bound check.
187/// - If `max_version` is None, no upper bound check.
188/// - Both bounds are inclusive.
189#[must_use]
190pub fn version_matches(
191    current_version: &str,
192    min_version: Option<&str>,
193    max_version: Option<&str>,
194) -> bool {
195    if let Some(min) = min_version
196        && compare_versions(current_version, min) == Ordering::Less
197    {
198        return false;
199    }
200    if let Some(max) = max_version
201        && compare_versions(current_version, max) == Ordering::Greater
202    {
203        return false;
204    }
205    true
206}
207
208/// What: Check if an announcement has expired.
209///
210/// Inputs:
211/// - `expires`: Optional expiration date in ISO format (YYYY-MM-DD).
212///
213/// Output:
214/// - `true` if expired (date has passed), `false` if not expired or no expiration.
215///
216/// Details:
217/// - Parses ISO date format (YYYY-MM-DD).
218/// - Compares with today's date (UTC).
219#[must_use]
220pub fn is_expired(expires: Option<&str>) -> bool {
221    let Some(expires_str) = expires else {
222        return false; // No expiration date means never expires
223    };
224
225    let Ok(expires_date) = NaiveDate::parse_from_str(expires_str, "%Y-%m-%d") else {
226        tracing::warn!(expires = expires_str, "failed to parse expiration date");
227        return false; // Invalid date format - don't expire
228    };
229
230    let today = Utc::now().date_naive();
231    today > expires_date
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    /// What: Verify base version extraction works correctly.
240    ///
241    /// Inputs:
242    /// - Various version strings with and without suffixes.
243    ///
244    /// Output:
245    /// - Confirms correct base version extraction.
246    fn test_extract_base_version() {
247        assert_eq!(extract_base_version("0.6.0"), "0.6.0");
248        assert_eq!(extract_base_version("0.6.0-pr#85"), "0.6.0");
249        assert_eq!(extract_base_version("0.6.0-beta"), "0.6.0");
250        assert_eq!(extract_base_version("0.6.0-rc1"), "0.6.0");
251        assert_eq!(extract_base_version("1.2.3-alpha.1"), "1.2.3");
252        assert_eq!(extract_base_version("1.0.0"), "1.0.0");
253        assert_eq!(extract_base_version("2.5.10-dev"), "2.5.10");
254        // Handle versions with fewer segments
255        assert_eq!(extract_base_version("1.0"), "1.0.0");
256        assert_eq!(extract_base_version("1"), "1.0.0");
257    }
258
259    #[test]
260    /// What: Verify version matching logic works correctly.
261    ///
262    /// Inputs:
263    /// - Various version strings and ranges.
264    ///
265    /// Output:
266    /// - Confirms correct matching behavior.
267    fn test_version_matches() {
268        assert!(version_matches("0.6.0", Some("0.6.0"), None));
269        assert!(version_matches("0.6.0", Some("0.5.0"), None));
270        assert!(!version_matches("0.6.0", Some("0.7.0"), None));
271        assert!(version_matches("0.6.0", None, Some("0.7.0")));
272        assert!(!version_matches("0.6.0", None, Some("0.5.0")));
273        assert!(version_matches("0.6.0", Some("0.5.0"), Some("0.7.0")));
274        assert!(!version_matches("0.6.0", Some("0.7.0"), Some("0.8.0")));
275    }
276
277    #[test]
278    /// What: Verify version matching with pre-release versions.
279    ///
280    /// Inputs:
281    /// - Pre-release version strings (e.g., "0.6.0-beta", "1.0.0-rc1").
282    ///
283    /// Output:
284    /// - Confirms correct matching behavior for pre-release versions.
285    ///
286    /// Details:
287    /// - Pre-release versions are compared using string comparison for non-numeric segments.
288    /// - When comparing "0.6.0-beta" vs "0.6.0", the "beta" segment is compared as string
289    ///   against the default "0", and "beta" > "0" lexicographically.
290    fn test_version_matches_prerelease() {
291        // Pre-release versions
292        assert!(version_matches("0.6.0-beta", Some("0.6.0-beta"), None));
293        assert!(version_matches("0.6.0-beta", Some("0.5.0"), None));
294        assert!(!version_matches("0.6.0-beta", Some("0.7.0"), None));
295        assert!(version_matches("1.0.0-rc1", Some("1.0.0-rc1"), None));
296        assert!(version_matches("1.0.0-rc1", Some("0.9.0"), None));
297        // Pre-release with non-numeric segment compared as string: "beta" > "0"
298        assert!(version_matches("0.6.0-beta", Some("0.6.0"), None));
299        assert!(!version_matches("0.6.0", Some("0.6.0-beta"), None));
300    }
301
302    #[test]
303    /// What: Verify version matching with different segment counts.
304    ///
305    /// Inputs:
306    /// - Versions with different numbers of segments (e.g., "1.0" vs "1.0.0").
307    ///
308    /// Output:
309    /// - Confirms correct matching behavior when segment counts differ.
310    ///
311    /// Details:
312    /// - Missing segments should be treated as "0".
313    fn test_version_matches_different_segments() {
314        assert!(version_matches("1.0", Some("1.0.0"), None));
315        assert!(version_matches("1.0.0", Some("1.0"), None));
316        assert!(version_matches("1.0", Some("1.0"), None));
317        assert!(version_matches("1.0.0", Some("1.0.0"), None));
318        assert!(version_matches("1.0", Some("0.9"), None));
319        assert!(!version_matches("1.0", Some("1.1"), None));
320    }
321
322    #[test]
323    /// What: Verify version matching boundary conditions.
324    ///
325    /// Inputs:
326    /// - Exact min/max version matches.
327    ///
328    /// Output:
329    /// - Confirms boundaries are inclusive.
330    ///
331    /// Details:
332    /// - Both min and max bounds are inclusive, so exact matches should pass.
333    fn test_version_matches_boundaries() {
334        // Exact min boundary
335        assert!(version_matches("0.6.0", Some("0.6.0"), Some("0.7.0")));
336        // Exact max boundary
337        assert!(version_matches("0.7.0", Some("0.6.0"), Some("0.7.0")));
338        // Both boundaries exact
339        assert!(version_matches("0.6.0", Some("0.6.0"), Some("0.6.0")));
340        // Just below min
341        assert!(!version_matches("0.5.9", Some("0.6.0"), Some("0.7.0")));
342        // Just above max
343        assert!(!version_matches("0.7.1", Some("0.6.0"), Some("0.7.0")));
344    }
345
346    #[test]
347    /// What: Verify version matching with both bounds None.
348    ///
349    /// Inputs:
350    /// - Version with both min and max as None.
351    ///
352    /// Output:
353    /// - Should always match regardless of version.
354    ///
355    /// Details:
356    /// - When both bounds are None, any version should match.
357    fn test_version_matches_no_bounds() {
358        assert!(version_matches("0.1.0", None, None));
359        assert!(version_matches("1.0.0", None, None));
360        assert!(version_matches("999.999.999", None, None));
361        assert!(version_matches("0.0.0", None, None));
362    }
363
364    #[test]
365    /// What: Verify version matching with non-numeric segments.
366    ///
367    /// Inputs:
368    /// - Versions with non-numeric segments (e.g., "0.6.0-alpha" vs "0.6.0-beta").
369    ///
370    /// Output:
371    /// - Confirms string comparison for non-numeric segments.
372    ///
373    /// Details:
374    /// - Non-numeric segments are compared lexicographically.
375    /// - When comparing versions with different segment counts, missing segments default to "0".
376    /// - Non-numeric segments compared against "0" use string comparison: "alpha" > "0".
377    fn test_version_matches_non_numeric_segments() {
378        // Non-numeric segments compared as strings
379        assert!(version_matches("0.6.0-alpha", Some("0.6.0-alpha"), None));
380        assert!(version_matches("0.6.0-beta", Some("0.6.0-alpha"), None));
381        assert!(!version_matches("0.6.0-alpha", Some("0.6.0-beta"), None));
382        // Numeric vs non-numeric: "alpha" > "0" lexicographically
383        assert!(!version_matches("0.6.0", Some("0.6.0-alpha"), None));
384        assert!(version_matches("0.6.0-alpha", Some("0.6.0"), None));
385    }
386
387    #[test]
388    /// What: Verify expiration checking logic.
389    ///
390    /// Inputs:
391    /// - Various expiration dates.
392    ///
393    /// Output:
394    /// - Confirms correct expiration behavior.
395    fn test_is_expired() {
396        // Future date should not be expired
397        assert!(!is_expired(Some("2099-12-31")));
398        // Past date should be expired
399        assert!(is_expired(Some("2020-01-01")));
400        // None should not be expired
401        assert!(!is_expired(None));
402    }
403
404    #[test]
405    /// What: Verify expiration checking with malformed date formats.
406    ///
407    /// Inputs:
408    /// - Invalid date formats that cannot be parsed.
409    ///
410    /// Output:
411    /// - Should not expire (returns false) for invalid formats.
412    ///
413    /// Details:
414    /// - Invalid dates should be treated as non-expiring to avoid hiding announcements
415    ///   due to parsing errors.
416    /// - Note: Some formats like "2020-1-1" may be parsed successfully by chrono's
417    ///   lenient parser, so we test with truly invalid formats.
418    fn test_is_expired_malformed_dates() {
419        // Invalid formats should not expire
420        assert!(!is_expired(Some("invalid-date")));
421        assert!(!is_expired(Some("2020/01/01")));
422        assert!(!is_expired(Some("01-01-2020")));
423        assert!(!is_expired(Some("")));
424        assert!(!is_expired(Some("not-a-date")));
425        assert!(!is_expired(Some("2020-13-45"))); // Invalid month/day
426        assert!(!is_expired(Some("abc-def-ghi"))); // Non-numeric
427    }
428
429    #[test]
430    /// What: Verify expiration checking edge case with today's date.
431    ///
432    /// Inputs:
433    /// - Today's date as expiration.
434    ///
435    /// Output:
436    /// - Should not expire (uses ">" not ">=").
437    ///
438    /// Details:
439    /// - The comparison uses `today > expires_date`, so today's date should not expire.
440    fn test_is_expired_today() {
441        let today = Utc::now().date_naive();
442        let today_str = today.format("%Y-%m-%d").to_string();
443        // Today's date should not be expired (uses > not >=)
444        assert!(!is_expired(Some(&today_str)));
445    }
446
447    #[test]
448    /// What: Verify expiration checking with empty string.
449    ///
450    /// Inputs:
451    /// - Empty string as expiration date.
452    ///
453    /// Output:
454    /// - Should not expire (treated as invalid format).
455    ///
456    /// Details:
457    /// - Empty string cannot be parsed as a date, so should not expire.
458    fn test_is_expired_empty_string() {
459        assert!(!is_expired(Some("")));
460    }
461
462    #[test]
463    /// What: Verify `RemoteAnnouncement` deserialization from valid JSON.
464    ///
465    /// Inputs:
466    /// - Valid JSON strings with all fields present.
467    ///
468    /// Output:
469    /// - Successfully deserializes into `RemoteAnnouncement` struct.
470    ///
471    /// Details:
472    /// - Tests that the struct can be deserialized from JSON format used by GitHub Gist.
473    fn test_remote_announcement_deserialize_valid() {
474        let json = r#"{
475            "id": "test-announcement-1",
476            "title": "Test Announcement",
477            "content": "This is test content",
478            "min_version": "0.6.0",
479            "max_version": "0.7.0",
480            "expires": "2025-12-31"
481        }"#;
482
483        let announcement: RemoteAnnouncement =
484            serde_json::from_str(json).expect("should deserialize valid JSON");
485        assert_eq!(announcement.id, "test-announcement-1");
486        assert_eq!(announcement.title, "Test Announcement");
487        assert_eq!(announcement.content, "This is test content");
488        assert_eq!(announcement.min_version, Some("0.6.0".to_string()));
489        assert_eq!(announcement.max_version, Some("0.7.0".to_string()));
490        assert_eq!(announcement.expires, Some("2025-12-31".to_string()));
491    }
492
493    #[test]
494    /// What: Verify `RemoteAnnouncement` deserialization with optional fields as null.
495    ///
496    /// Inputs:
497    /// - JSON with optional fields set to null.
498    ///
499    /// Output:
500    /// - Successfully deserializes with None for optional fields.
501    ///
502    /// Details:
503    /// - Optional fields (`min_version`, `max_version`, `expires`) can be null or omitted.
504    fn test_remote_announcement_deserialize_optional_null() {
505        let json = r#"{
506            "id": "test-announcement-2",
507            "title": "Test Announcement",
508            "content": "This is test content",
509            "min_version": null,
510            "max_version": null,
511            "expires": null
512        }"#;
513
514        let announcement: RemoteAnnouncement =
515            serde_json::from_str(json).expect("should deserialize with null fields");
516        assert_eq!(announcement.id, "test-announcement-2");
517        assert_eq!(announcement.min_version, None);
518        assert_eq!(announcement.max_version, None);
519        assert_eq!(announcement.expires, None);
520    }
521
522    #[test]
523    /// What: Verify `RemoteAnnouncement` deserialization with omitted optional fields.
524    ///
525    /// Inputs:
526    /// - JSON with optional fields completely omitted.
527    ///
528    /// Output:
529    /// - Successfully deserializes with None for omitted fields.
530    ///
531    /// Details:
532    /// - Optional fields can be omitted entirely from JSON.
533    fn test_remote_announcement_deserialize_optional_omitted() {
534        let json = r#"{
535            "id": "test-announcement-3",
536            "title": "Test Announcement",
537            "content": "This is test content"
538        }"#;
539
540        let announcement: RemoteAnnouncement =
541            serde_json::from_str(json).expect("should deserialize with omitted fields");
542        assert_eq!(announcement.id, "test-announcement-3");
543        assert_eq!(announcement.min_version, None);
544        assert_eq!(announcement.max_version, None);
545        assert_eq!(announcement.expires, None);
546    }
547
548    #[test]
549    /// What: Verify `RemoteAnnouncement` deserialization fails with invalid JSON.
550    ///
551    /// Inputs:
552    /// - Invalid JSON strings that cannot be parsed.
553    ///
554    /// Output:
555    /// - Returns error when JSON is invalid or missing required fields.
556    ///
557    /// Details:
558    /// - Required fields (`id`, `title`, `content`) must be present and valid.
559    fn test_remote_announcement_deserialize_invalid() {
560        // Missing required field
561        let json_missing_id = r#"{
562            "title": "Test",
563            "content": "Content"
564        }"#;
565        assert!(serde_json::from_str::<RemoteAnnouncement>(json_missing_id).is_err());
566
567        // Invalid JSON syntax
568        let json_invalid = r#"{
569            "id": "test",
570            "title": "Test",
571            "content": "Content"
572        "#;
573        assert!(serde_json::from_str::<RemoteAnnouncement>(json_invalid).is_err());
574
575        // Wrong types
576        let json_wrong_type = r#"{
577            "id": 123,
578            "title": "Test",
579            "content": "Content"
580        }"#;
581        assert!(serde_json::from_str::<RemoteAnnouncement>(json_wrong_type).is_err());
582    }
583}