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}