1use chrono::{NaiveDate, Utc};
4use serde::Deserialize;
5use std::cmp::Ordering;
6
7pub struct VersionAnnouncement {
20 pub version: &'static str,
23 pub title: &'static str,
25 pub content: &'static str,
27}
28
29pub const VERSION_ANNOUNCEMENTS: &[VersionAnnouncement] = &[
41 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#[derive(Debug, Deserialize)]
105pub struct RemoteAnnouncement {
106 pub id: String,
108 pub title: String,
110 pub content: String,
112 pub min_version: Option<String>,
114 pub max_version: Option<String>,
117 pub expires: Option<String>,
119}
120
121fn 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#[must_use]
171pub fn extract_base_version(version: &str) -> String {
172 let base = version.split('-').next().unwrap_or(version);
175
176 let parts: Vec<&str> = base.split('.').collect();
178 match parts.len() {
179 n if n >= 3 => {
180 format!("{}.{}.{}", parts[0], parts[1], parts[2])
182 }
183 2 => {
184 format!("{}.{}.0", parts[0], parts[1])
186 }
187 1 => {
188 format!("{}.0.0", parts[0])
190 }
191 _ => base.to_string(),
192 }
193}
194
195#[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#[must_use]
240pub fn is_expired(expires: Option<&str>) -> bool {
241 let Some(expires_str) = expires else {
242 return false; };
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; };
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 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 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 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 fn test_version_matches_prerelease() {
311 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 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 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 fn test_version_matches_boundaries() {
354 assert!(version_matches("0.6.0", Some("0.6.0"), Some("0.7.0")));
356 assert!(version_matches("0.7.0", Some("0.6.0"), Some("0.7.0")));
358 assert!(version_matches("0.6.0", Some("0.6.0"), Some("0.6.0")));
360 assert!(!version_matches("0.5.9", Some("0.6.0"), Some("0.7.0")));
362 assert!(!version_matches("0.7.1", Some("0.6.0"), Some("0.7.0")));
364 }
365
366 #[test]
367 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 fn test_version_matches_non_numeric_segments() {
398 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 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 fn test_is_expired() {
416 assert!(!is_expired(Some("2099-12-31")));
418 assert!(is_expired(Some("2020-01-01")));
420 assert!(!is_expired(None));
422 }
423
424 #[test]
425 fn test_is_expired_malformed_dates() {
439 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"))); assert!(!is_expired(Some("abc-def-ghi"))); }
448
449 #[test]
450 fn test_is_expired_today() {
461 let today = Utc::now().date_naive();
462 let today_str = today.format("%Y-%m-%d").to_string();
463 assert!(!is_expired(Some(&today_str)));
465 }
466
467 #[test]
468 fn test_is_expired_empty_string() {
479 assert!(!is_expired(Some("")));
480 }
481
482 #[test]
483 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 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 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 fn test_remote_announcement_deserialize_invalid() {
580 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 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 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}