Skip to main content

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    VersionAnnouncement {
73        version: "0.7.4",
74        title: "Version 0.7.4",
75        content: "## What's New\n\n- New `privilege_tool` setting: `auto` | `sudo` | `doas`\n- Commands now run through the selected tool (or auto-detected one) instead of always using sudo\n- New `auth_mode` setting: `prompt` | `passwordless_only` | `interactive`\n- Interactive mode hands off to the terminal so sudo/doas can handle PAM prompts directly (including fingerprint via fprintd, when configured)\n- Detects the `blackarch` repo and adds a toggle/filter in results when available\n\n",
76    },
77    VersionAnnouncement {
78        version: "0.8.0",
79        title: "Version 0.8.0",
80        content: "## What's New\n\n### Custom and third-party repositories\n\n- Configure extra repos in **`repos.conf`**, edit them from the app, and apply changes when you are ready (with privilege prompts when needed).\n- Search and filters can include packages from those repos, with sensible handling when the same package name appears more than once.\n- Toggle managed entries on or off; disabled repos are ignored until you enable them again. The repositories screen can refresh with up-to-date status after related dialogs.\n- First run seeds **`repos.conf`** with common third-party recipes (disabled by default); enable only what you need.\n\n### After you add a repo\n\n- If packages you installed also exist in the new repo, a short guided flow explains the situation and helps you choose what to do next (including optional cleanup). Preview-only mode stays accurate; canceling or errors should not leave the UI stuck.\n\n### Overlapping names (AUR vs other sources)\n\n- AUR installs go through your helper in a way that avoids wrong-source surprises when a community mirror lists the same name.\n- Selecting an AUR hit that also appears as a normal Arch listing can show a one-time warning before you continue.\n\n### Optional: AUR voting\n\n- Vote or unvote AUR packages from search when enabled, using SSH to the AUR.\n- Built-in **SSH AUR setup** helps you configure the host entry in your SSH config.\n- Honors your SSH command, timeout, and preview-only mode (no fake vote state).\n\n### Optional: PKGBUILD checks\n\n- Run **ShellCheck** and **Namcap** on the selected package build file from the details view when those tools are installed (timeouts and missing tools handled gracefully).\n- Switch between the PKGBUILD text and check results in the details pane; settings cover raw output and ShellCheck excludes.\n\n### Bug fixes\n\n- **Repositories:** Stricter validation for paths, server URLs, signing keys, and filter keys; safer behavior when apply is interrupted or does not complete successfully.\n- **PKGBUILD checks:** More reliable when starting a check; time limits handled inside the app; clearer messaging when a checker is not installed.\n- **Lists and filters:** Better column alignment when names use wide characters (e.g. some non-Latin scripts).\n",
81    },
82    VersionAnnouncement {
83        version: "0.8.1",
84        title: "Version 0.8.1",
85        content: "## What's New\n\nCompared to **v0.8.0**, this release improves first-run setup, the updates experience, and the optional **AUR voting** SSH wizard. Everything below is new or different in **v0.8.1**; unchanged areas (custom repos, PKGBUILD checks, core voting behavior, etc.) are not repeated here.\n\n### ✨ Features\n\n- **Startup setup**: A selector runs optional setup tasks in order (optional dependencies, AUR SSH, VirusTotal, news, and related steps). Optional dependencies includes a **[Wizard]** entry. New **sudo timestamp** and **doas persist** setup wizards; install/update/remove can warn when long sessions may hit auth limits.\n- **AUR SSH setup**: Guided flow through local key/config, pasting the key on AUR, then a live SSH check. Copy the public key with **C** or the copy row; open the AUR login with **O** when you need it. More reliable first connection to AUR (including host-key handling). Success feedback appears after the remote check succeeds.\n- **Updates**: Layout shows **repo/name** and **old → new** versions with clearer diff highlighting. Slash filter, multi-select, and navigation behave more predictably, including with wrapped lines and the mouse. The app can indicate when an update list may be incomplete and why.\n\n### 🛡 Security & reliability\n\n- Tighter handling around privileged commands, temporary scripts, and saved command logs.\n\n### 🐛 Fixes\n\n- Calmer first-run order between setup dialogs and version announcements.\n- Clearer labels when a setup task is unavailable (for example wrong privilege tool).\n- Setup dialogs no longer leave stray keypresses for the next screen.\n- Startup news no longer pops up on its own; leaving news setup does not resurrect an old Arch news window.\n- Optional dependency batch installs go through the same auth/preflight path as other installs; terminal integration fixes for multiline follow-up commands and fallback ordering.\n\n",
86    },
87    VersionAnnouncement {
88        version: "0.8.2",
89        title: "Version 0.8.2",
90        content: "## What's New\n\nCompared to **v0.8.1**, this release focuses on layout customization, smoother PKGBUILD viewing, better modal scrolling, and desktop launcher files. Packaging for **pacsea-git** on the AUR was aligned with the current repo layout (including merged [PR #158](https://github.com/Firstp1ck/Pacsea/pull/158)).\n\n### ✨ Features\n\n- **Configurable UI layout**: Set `main_pane_order` and per-role vertical min/max in `settings.conf` so search, results, and details appear in the order and proportions you prefer.\n- **Mouse wheel in modals**: Scroll the focused row in System Update, Repositories, and Optional Dependencies modals when the pointer is over the list.\n- **Desktop integration**: `.desktop` entry and SVG icon ship with the tree for menu launchers and file managers.\n\n### 🛡 Security & reliability\n\n- **PKGBUILD fetching**: Each fetch runs in its own async task so one slow host does not block the queue; stale results are dropped when you change rows.\n\n### 🐛 Fixes\n\n- Shorter connect timeouts on PKGBUILD `curl` calls so bad hosts fail faster.\n- **pacsea-git** / `makepkg`: clear toolchain env (including `CHOST`) before builds when `makepkg.conf` has cross-compile defaults that would break a normal package build.\n- **Packaging**: Correct source URLs and sparse-checkout paths in `PKGBUILD-git`; icon file permissions set for normal files (not executable).\n\n",
91    },
92];
93
94/// What: Remote announcement fetched from GitHub Gist.
95///
96/// Inputs: None (deserialized from JSON).
97///
98/// Output: Represents a remote announcement with version filtering and expiration.
99///
100/// Details:
101/// - Fetched from configured URL (GitHub Gist raw URL).
102/// - Can target specific version ranges.
103/// - Can expire after a certain date.
104#[derive(Debug, Deserialize)]
105pub struct RemoteAnnouncement {
106    /// Unique identifier for this announcement (used for tracking read state).
107    pub id: String,
108    /// Title of the announcement.
109    pub title: String,
110    /// Markdown content of the announcement.
111    pub content: String,
112    /// Minimum version (inclusive) that should see this announcement.
113    pub min_version: Option<String>,
114    /// Maximum version (inclusive) that should see this announcement.
115    /// If None, no upper limit.
116    pub max_version: Option<String>,
117    /// Expiration date in ISO format (YYYY-MM-DD). If None, never expires.
118    pub expires: Option<String>,
119}
120
121/// What: Compare version strings numerically.
122///
123/// Inputs:
124/// - `a`: Left-hand version string.
125/// - `b`: Right-hand version string.
126///
127/// Output:
128/// - `Ordering` indicating which version is greater.
129///
130/// Details:
131/// - Uses the same logic as preflight version comparison.
132/// - Splits on `.` and `-`, comparing numeric segments.
133fn compare_versions(a: &str, b: &str) -> Ordering {
134    let a_parts: Vec<&str> = a.split(['.', '-']).collect();
135    let b_parts: Vec<&str> = b.split(['.', '-']).collect();
136    let len = a_parts.len().max(b_parts.len());
137
138    for idx in 0..len {
139        let a_seg = a_parts.get(idx).copied().unwrap_or("0");
140        let b_seg = b_parts.get(idx).copied().unwrap_or("0");
141
142        match (a_seg.parse::<i64>(), b_seg.parse::<i64>()) {
143            (Ok(a_num), Ok(b_num)) => match a_num.cmp(&b_num) {
144                Ordering::Equal => {}
145                ord => return ord,
146            },
147            _ => match a_seg.cmp(b_seg) {
148                Ordering::Equal => {}
149                ord => return ord,
150            },
151        }
152    }
153
154    Ordering::Equal
155}
156
157/// What: Extract base version (X.X.X) from a version string, ignoring suffixes.
158///
159/// Inputs:
160/// - `version`: Version string (e.g., "0.6.0", "0.6.0-pr#85", "0.6.0-beta").
161///
162/// Output:
163/// - Base version string (e.g., "0.6.0").
164///
165/// Details:
166/// - Extracts the semantic version part (major.minor.patch) before any suffix.
167/// - Handles versions like "0.6.0", "0.6.0-pr#85", "0.6.0-beta", "1.2.3-rc1".
168/// - Splits on '-' to remove pre-release identifiers and other suffixes.
169/// - Normalizes to X.X.X format (adds .0 for missing segments).
170#[must_use]
171pub fn extract_base_version(version: &str) -> String {
172    // Split on '-' to remove pre-release identifiers and suffixes
173    // This handles formats like "0.6.0-pr#85", "0.6.0-beta", "1.2.3-rc1"
174    let base = version.split('-').next().unwrap_or(version);
175
176    // Extract only the X.X.X part (up to 3 numeric segments separated by dots)
177    let parts: Vec<&str> = base.split('.').collect();
178    match parts.len() {
179        n if n >= 3 => {
180            // Take first 3 parts and join them
181            format!("{}.{}.{}", parts[0], parts[1], parts[2])
182        }
183        2 => {
184            // Handle X.X format, add .0
185            format!("{}.{}.0", parts[0], parts[1])
186        }
187        1 => {
188            // Handle X format, add .0.0
189            format!("{}.0.0", parts[0])
190        }
191        _ => base.to_string(),
192    }
193}
194
195/// What: Check if current version matches the version range.
196///
197/// Inputs:
198/// - `current_version`: Current app version (e.g., "0.6.0").
199/// - `min_version`: Optional minimum version (inclusive).
200/// - `max_version`: Optional maximum version (inclusive).
201///
202/// Output:
203/// - `true` if current version is within the range, `false` otherwise.
204///
205/// Details:
206/// - If `min_version` is None, no lower bound check.
207/// - If `max_version` is None, no upper bound check.
208/// - Both bounds are inclusive.
209#[must_use]
210pub fn version_matches(
211    current_version: &str,
212    min_version: Option<&str>,
213    max_version: Option<&str>,
214) -> bool {
215    if let Some(min) = min_version
216        && compare_versions(current_version, min) == Ordering::Less
217    {
218        return false;
219    }
220    if let Some(max) = max_version
221        && compare_versions(current_version, max) == Ordering::Greater
222    {
223        return false;
224    }
225    true
226}
227
228/// What: Check if an announcement has expired.
229///
230/// Inputs:
231/// - `expires`: Optional expiration date in ISO format (YYYY-MM-DD).
232///
233/// Output:
234/// - `true` if expired (date has passed), `false` if not expired or no expiration.
235///
236/// Details:
237/// - Parses ISO date format (YYYY-MM-DD).
238/// - Compares with today's date (UTC).
239#[must_use]
240pub fn is_expired(expires: Option<&str>) -> bool {
241    let Some(expires_str) = expires else {
242        return false; // No expiration date means never expires
243    };
244
245    let Ok(expires_date) = NaiveDate::parse_from_str(expires_str, "%Y-%m-%d") else {
246        tracing::warn!(expires = expires_str, "failed to parse expiration date");
247        return false; // Invalid date format - don't expire
248    };
249
250    let today = Utc::now().date_naive();
251    today > expires_date
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    /// What: Verify base version extraction works correctly.
260    ///
261    /// Inputs:
262    /// - Various version strings with and without suffixes.
263    ///
264    /// Output:
265    /// - Confirms correct base version extraction.
266    fn test_extract_base_version() {
267        assert_eq!(extract_base_version("0.6.0"), "0.6.0");
268        assert_eq!(extract_base_version("0.6.0-pr#85"), "0.6.0");
269        assert_eq!(extract_base_version("0.6.0-beta"), "0.6.0");
270        assert_eq!(extract_base_version("0.6.0-rc1"), "0.6.0");
271        assert_eq!(extract_base_version("1.2.3-alpha.1"), "1.2.3");
272        assert_eq!(extract_base_version("1.0.0"), "1.0.0");
273        assert_eq!(extract_base_version("2.5.10-dev"), "2.5.10");
274        // Handle versions with fewer segments
275        assert_eq!(extract_base_version("1.0"), "1.0.0");
276        assert_eq!(extract_base_version("1"), "1.0.0");
277    }
278
279    #[test]
280    /// What: Verify version matching logic works correctly.
281    ///
282    /// Inputs:
283    /// - Various version strings and ranges.
284    ///
285    /// Output:
286    /// - Confirms correct matching behavior.
287    fn test_version_matches() {
288        assert!(version_matches("0.6.0", Some("0.6.0"), None));
289        assert!(version_matches("0.6.0", Some("0.5.0"), None));
290        assert!(!version_matches("0.6.0", Some("0.7.0"), None));
291        assert!(version_matches("0.6.0", None, Some("0.7.0")));
292        assert!(!version_matches("0.6.0", None, Some("0.5.0")));
293        assert!(version_matches("0.6.0", Some("0.5.0"), Some("0.7.0")));
294        assert!(!version_matches("0.6.0", Some("0.7.0"), Some("0.8.0")));
295    }
296
297    #[test]
298    /// What: Verify version matching with pre-release versions.
299    ///
300    /// Inputs:
301    /// - Pre-release version strings (e.g., "0.6.0-beta", "1.0.0-rc1").
302    ///
303    /// Output:
304    /// - Confirms correct matching behavior for pre-release versions.
305    ///
306    /// Details:
307    /// - Pre-release versions are compared using string comparison for non-numeric segments.
308    /// - When comparing "0.6.0-beta" vs "0.6.0", the "beta" segment is compared as string
309    ///   against the default "0", and "beta" > "0" lexicographically.
310    fn test_version_matches_prerelease() {
311        // Pre-release versions
312        assert!(version_matches("0.6.0-beta", Some("0.6.0-beta"), None));
313        assert!(version_matches("0.6.0-beta", Some("0.5.0"), None));
314        assert!(!version_matches("0.6.0-beta", Some("0.7.0"), None));
315        assert!(version_matches("1.0.0-rc1", Some("1.0.0-rc1"), None));
316        assert!(version_matches("1.0.0-rc1", Some("0.9.0"), None));
317        // Pre-release with non-numeric segment compared as string: "beta" > "0"
318        assert!(version_matches("0.6.0-beta", Some("0.6.0"), None));
319        assert!(!version_matches("0.6.0", Some("0.6.0-beta"), None));
320    }
321
322    #[test]
323    /// What: Verify version matching with different segment counts.
324    ///
325    /// Inputs:
326    /// - Versions with different numbers of segments (e.g., "1.0" vs "1.0.0").
327    ///
328    /// Output:
329    /// - Confirms correct matching behavior when segment counts differ.
330    ///
331    /// Details:
332    /// - Missing segments should be treated as "0".
333    fn test_version_matches_different_segments() {
334        assert!(version_matches("1.0", Some("1.0.0"), None));
335        assert!(version_matches("1.0.0", Some("1.0"), None));
336        assert!(version_matches("1.0", Some("1.0"), None));
337        assert!(version_matches("1.0.0", Some("1.0.0"), None));
338        assert!(version_matches("1.0", Some("0.9"), None));
339        assert!(!version_matches("1.0", Some("1.1"), None));
340    }
341
342    #[test]
343    /// What: Verify version matching boundary conditions.
344    ///
345    /// Inputs:
346    /// - Exact min/max version matches.
347    ///
348    /// Output:
349    /// - Confirms boundaries are inclusive.
350    ///
351    /// Details:
352    /// - Both min and max bounds are inclusive, so exact matches should pass.
353    fn test_version_matches_boundaries() {
354        // Exact min boundary
355        assert!(version_matches("0.6.0", Some("0.6.0"), Some("0.7.0")));
356        // Exact max boundary
357        assert!(version_matches("0.7.0", Some("0.6.0"), Some("0.7.0")));
358        // Both boundaries exact
359        assert!(version_matches("0.6.0", Some("0.6.0"), Some("0.6.0")));
360        // Just below min
361        assert!(!version_matches("0.5.9", Some("0.6.0"), Some("0.7.0")));
362        // Just above max
363        assert!(!version_matches("0.7.1", Some("0.6.0"), Some("0.7.0")));
364    }
365
366    #[test]
367    /// What: Verify version matching with both bounds None.
368    ///
369    /// Inputs:
370    /// - Version with both min and max as None.
371    ///
372    /// Output:
373    /// - Should always match regardless of version.
374    ///
375    /// Details:
376    /// - When both bounds are None, any version should match.
377    fn test_version_matches_no_bounds() {
378        assert!(version_matches("0.1.0", None, None));
379        assert!(version_matches("1.0.0", None, None));
380        assert!(version_matches("999.999.999", None, None));
381        assert!(version_matches("0.0.0", None, None));
382    }
383
384    #[test]
385    /// What: Verify version matching with non-numeric segments.
386    ///
387    /// Inputs:
388    /// - Versions with non-numeric segments (e.g., "0.6.0-alpha" vs "0.6.0-beta").
389    ///
390    /// Output:
391    /// - Confirms string comparison for non-numeric segments.
392    ///
393    /// Details:
394    /// - Non-numeric segments are compared lexicographically.
395    /// - When comparing versions with different segment counts, missing segments default to "0".
396    /// - Non-numeric segments compared against "0" use string comparison: "alpha" > "0".
397    fn test_version_matches_non_numeric_segments() {
398        // Non-numeric segments compared as strings
399        assert!(version_matches("0.6.0-alpha", Some("0.6.0-alpha"), None));
400        assert!(version_matches("0.6.0-beta", Some("0.6.0-alpha"), None));
401        assert!(!version_matches("0.6.0-alpha", Some("0.6.0-beta"), None));
402        // Numeric vs non-numeric: "alpha" > "0" lexicographically
403        assert!(!version_matches("0.6.0", Some("0.6.0-alpha"), None));
404        assert!(version_matches("0.6.0-alpha", Some("0.6.0"), None));
405    }
406
407    #[test]
408    /// What: Verify expiration checking logic.
409    ///
410    /// Inputs:
411    /// - Various expiration dates.
412    ///
413    /// Output:
414    /// - Confirms correct expiration behavior.
415    fn test_is_expired() {
416        // Future date should not be expired
417        assert!(!is_expired(Some("2099-12-31")));
418        // Past date should be expired
419        assert!(is_expired(Some("2020-01-01")));
420        // None should not be expired
421        assert!(!is_expired(None));
422    }
423
424    #[test]
425    /// What: Verify expiration checking with malformed date formats.
426    ///
427    /// Inputs:
428    /// - Invalid date formats that cannot be parsed.
429    ///
430    /// Output:
431    /// - Should not expire (returns false) for invalid formats.
432    ///
433    /// Details:
434    /// - Invalid dates should be treated as non-expiring to avoid hiding announcements
435    ///   due to parsing errors.
436    /// - Note: Some formats like "2020-1-1" may be parsed successfully by chrono's
437    ///   lenient parser, so we test with truly invalid formats.
438    fn test_is_expired_malformed_dates() {
439        // Invalid formats should not expire
440        assert!(!is_expired(Some("invalid-date")));
441        assert!(!is_expired(Some("2020/01/01")));
442        assert!(!is_expired(Some("01-01-2020")));
443        assert!(!is_expired(Some("")));
444        assert!(!is_expired(Some("not-a-date")));
445        assert!(!is_expired(Some("2020-13-45"))); // Invalid month/day
446        assert!(!is_expired(Some("abc-def-ghi"))); // Non-numeric
447    }
448
449    #[test]
450    /// What: Verify expiration checking edge case with today's date.
451    ///
452    /// Inputs:
453    /// - Today's date as expiration.
454    ///
455    /// Output:
456    /// - Should not expire (uses ">" not ">=").
457    ///
458    /// Details:
459    /// - The comparison uses `today > expires_date`, so today's date should not expire.
460    fn test_is_expired_today() {
461        let today = Utc::now().date_naive();
462        let today_str = today.format("%Y-%m-%d").to_string();
463        // Today's date should not be expired (uses > not >=)
464        assert!(!is_expired(Some(&today_str)));
465    }
466
467    #[test]
468    /// What: Verify expiration checking with empty string.
469    ///
470    /// Inputs:
471    /// - Empty string as expiration date.
472    ///
473    /// Output:
474    /// - Should not expire (treated as invalid format).
475    ///
476    /// Details:
477    /// - Empty string cannot be parsed as a date, so should not expire.
478    fn test_is_expired_empty_string() {
479        assert!(!is_expired(Some("")));
480    }
481
482    #[test]
483    /// What: Verify `RemoteAnnouncement` deserialization from valid JSON.
484    ///
485    /// Inputs:
486    /// - Valid JSON strings with all fields present.
487    ///
488    /// Output:
489    /// - Successfully deserializes into `RemoteAnnouncement` struct.
490    ///
491    /// Details:
492    /// - Tests that the struct can be deserialized from JSON format used by GitHub Gist.
493    fn test_remote_announcement_deserialize_valid() {
494        let json = r#"{
495            "id": "test-announcement-1",
496            "title": "Test Announcement",
497            "content": "This is test content",
498            "min_version": "0.6.0",
499            "max_version": "0.7.0",
500            "expires": "2025-12-31"
501        }"#;
502
503        let announcement: RemoteAnnouncement =
504            serde_json::from_str(json).expect("should deserialize valid JSON");
505        assert_eq!(announcement.id, "test-announcement-1");
506        assert_eq!(announcement.title, "Test Announcement");
507        assert_eq!(announcement.content, "This is test content");
508        assert_eq!(announcement.min_version, Some("0.6.0".to_string()));
509        assert_eq!(announcement.max_version, Some("0.7.0".to_string()));
510        assert_eq!(announcement.expires, Some("2025-12-31".to_string()));
511    }
512
513    #[test]
514    /// What: Verify `RemoteAnnouncement` deserialization with optional fields as null.
515    ///
516    /// Inputs:
517    /// - JSON with optional fields set to null.
518    ///
519    /// Output:
520    /// - Successfully deserializes with None for optional fields.
521    ///
522    /// Details:
523    /// - Optional fields (`min_version`, `max_version`, `expires`) can be null or omitted.
524    fn test_remote_announcement_deserialize_optional_null() {
525        let json = r#"{
526            "id": "test-announcement-2",
527            "title": "Test Announcement",
528            "content": "This is test content",
529            "min_version": null,
530            "max_version": null,
531            "expires": null
532        }"#;
533
534        let announcement: RemoteAnnouncement =
535            serde_json::from_str(json).expect("should deserialize with null fields");
536        assert_eq!(announcement.id, "test-announcement-2");
537        assert_eq!(announcement.min_version, None);
538        assert_eq!(announcement.max_version, None);
539        assert_eq!(announcement.expires, None);
540    }
541
542    #[test]
543    /// What: Verify `RemoteAnnouncement` deserialization with omitted optional fields.
544    ///
545    /// Inputs:
546    /// - JSON with optional fields completely omitted.
547    ///
548    /// Output:
549    /// - Successfully deserializes with None for omitted fields.
550    ///
551    /// Details:
552    /// - Optional fields can be omitted entirely from JSON.
553    fn test_remote_announcement_deserialize_optional_omitted() {
554        let json = r#"{
555            "id": "test-announcement-3",
556            "title": "Test Announcement",
557            "content": "This is test content"
558        }"#;
559
560        let announcement: RemoteAnnouncement =
561            serde_json::from_str(json).expect("should deserialize with omitted fields");
562        assert_eq!(announcement.id, "test-announcement-3");
563        assert_eq!(announcement.min_version, None);
564        assert_eq!(announcement.max_version, None);
565        assert_eq!(announcement.expires, None);
566    }
567
568    #[test]
569    /// What: Verify `RemoteAnnouncement` deserialization fails with invalid JSON.
570    ///
571    /// Inputs:
572    /// - Invalid JSON strings that cannot be parsed.
573    ///
574    /// Output:
575    /// - Returns error when JSON is invalid or missing required fields.
576    ///
577    /// Details:
578    /// - Required fields (`id`, `title`, `content`) must be present and valid.
579    fn test_remote_announcement_deserialize_invalid() {
580        // Missing required field
581        let json_missing_id = r#"{
582            "title": "Test",
583            "content": "Content"
584        }"#;
585        assert!(serde_json::from_str::<RemoteAnnouncement>(json_missing_id).is_err());
586
587        // Invalid JSON syntax
588        let json_invalid = r#"{
589            "id": "test",
590            "title": "Test",
591            "content": "Content"
592        "#;
593        assert!(serde_json::from_str::<RemoteAnnouncement>(json_invalid).is_err());
594
595        // Wrong types
596        let json_wrong_type = r#"{
597            "id": 123,
598            "title": "Test",
599            "content": "Content"
600        }"#;
601        assert!(serde_json::from_str::<RemoteAnnouncement>(json_wrong_type).is_err());
602    }
603}