pacsea/util/mod.rs
1//! Small utility helpers for encoding, JSON extraction, ranking, and time formatting.
2//!
3//! The functions in this module are intentionally lightweight and dependency-free
4//! to keep hot paths fast and reduce compile times. They are used by networking,
5//! indexing, and UI code.
6
7pub mod clipboard;
8pub mod command;
9pub mod config;
10pub mod curl;
11pub mod pacman;
12pub mod srcinfo;
13
14use serde_json::Value;
15use std::fmt::Write;
16
17/// What: Ensure mouse capture is enabled for the TUI.
18///
19/// Inputs:
20/// - None.
21///
22/// Output:
23/// - No return value; enables mouse capture on stdout if not in headless mode.
24///
25/// Details:
26/// - Should be called after spawning external processes (like terminals) that might disable mouse capture.
27/// - Safe to call multiple times.
28/// - In headless/test mode (`PACSEA_TEST_HEADLESS=1`), this is a no-op to prevent mouse escape sequences from appearing in test output.
29/// - If terminal raw mode is not active, this is a no-op to avoid leaking mouse-reporting sequences into normal shell sessions (e.g. tests/doctests).
30/// - On Windows, this is a no-op as mouse capture is handled differently.
31pub fn ensure_mouse_capture() {
32 // Skip mouse capture in headless/test mode to prevent escape sequences in test output
33 if std::env::var("PACSEA_TEST_HEADLESS").ok().as_deref() == Some("1") {
34 } else {
35 // Only enable mouse reporting when the app already owns the terminal in raw mode.
36 // This prevents leaking SGR mouse escape sequences during cargo test/doctest runs.
37 if !crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) {
38 return;
39 }
40 #[cfg(not(target_os = "windows"))]
41 {
42 use crossterm::execute;
43 let _ = execute!(std::io::stdout(), crossterm::event::EnableMouseCapture);
44 }
45 }
46}
47
48/// What: Percent-encode a string for use in URLs according to RFC 3986.
49///
50/// Inputs:
51/// - `input`: String to encode.
52///
53/// Output:
54/// - Returns a percent-encoded string where reserved characters are escaped.
55///
56/// Details:
57/// - Unreserved characters as per RFC 3986 (`A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, `~`) are left as-is.
58/// - Space is encoded as `%20` (not `+`).
59/// - All other bytes are encoded as two uppercase hexadecimal digits prefixed by `%`.
60/// - Operates on raw bytes from the input string; any non-ASCII bytes are hex-escaped.
61/// # Examples
62/// ```
63/// use pacsea::util::percent_encode;
64///
65/// // Encoding a package name for a URL, like in API calls to the AUR
66/// assert_eq!(percent_encode("linux-zen"), "linux-zen");
67///
68/// // Encoding a search query with spaces for the package database
69/// assert_eq!(percent_encode("terminal emulator"), "terminal%20emulator");
70///
71/// // Encoding a maintainer name with special characters
72/// assert_eq!(percent_encode("John Doe <john@example.com>"), "John%20Doe%20%3Cjohn%40example.com%3E");
73/// ```
74#[must_use]
75pub fn percent_encode(input: &str) -> String {
76 let mut out = String::with_capacity(input.len());
77 for &b in input.as_bytes() {
78 match b {
79 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
80 out.push(b as char);
81 }
82 b' ' => out.push_str("%20"),
83 _ => {
84 out.push('%');
85 let _ = write!(out, "{b:02X}");
86 }
87 }
88 }
89 out
90}
91
92/// What: Extract a string value from a JSON object by key, defaulting to empty string.
93///
94/// Inputs:
95/// - `v`: JSON value to extract from.
96/// - `key`: Key to look up in the JSON object.
97///
98/// Output:
99/// - Returns the string value if found, or an empty string if the key is missing or not a string.
100///
101/// Details:
102/// - Returns `""` if the key is missing or the value is not a string type.
103/// # Examples
104/// ```
105/// use pacsea::util::s;
106/// use serde_json::json;
107///
108/// // Simulating a real AUR RPC API response for a package like 'yay'
109/// let aur_pkg_info = json!({
110/// "Name": "yay",
111/// "Version": "12.3.4-1",
112/// "Description": "Yet another Yogurt - An AUR Helper written in Go"
113/// });
114/// assert_eq!(s(&aur_pkg_info, "Name"), "yay");
115/// assert_eq!(s(&aur_pkg_info, "Description"), "Yet another Yogurt - An AUR Helper written in Go");
116/// assert_eq!(s(&aur_pkg_info, "Maintainer"), ""); // Returns empty string for missing keys
117///
118/// // Simulating a package search result from the official repository API
119/// let repo_pkg_info = json!({
120/// "pkgname": "firefox",
121/// "pkgver": "128.0-1",
122/// "repo": "extra"
123/// });
124/// assert_eq!(s(&repo_pkg_info, "pkgname"), "firefox");
125/// assert_eq!(s(&repo_pkg_info, "repo"), "extra");
126/// ```
127#[must_use]
128pub fn s(v: &Value, key: &str) -> String {
129 v.get(key)
130 .and_then(Value::as_str)
131 .unwrap_or_default()
132 .to_owned()
133}
134/// What: Extract the first available string from a list of candidate keys.
135///
136/// Inputs:
137/// - `v`: JSON value to extract from.
138/// - `keys`: Array of candidate keys to try in order.
139///
140/// Output:
141/// - Returns `Some(String)` for the first key that maps to a JSON string, or `None` if none match.
142///
143/// Details:
144/// - Tries keys in the order provided and returns the first match.
145/// - Returns `None` if no key maps to a string value.
146/// # Examples
147/// ```
148/// use pacsea::util::ss;
149/// use serde_json::json;
150///
151/// // Trying multiple possible version keys from different AUR API responses
152/// let pkg_info = json!({
153/// "Version": "1.2.3",
154/// "pkgver": "1.2.3",
155/// "ver": "1.2.3"
156/// });
157/// // Returns the first matching key: "pkgver"
158/// assert_eq!(ss(&pkg_info, &["pkgver", "Version", "ver"]), Some("1.2.3".to_string()));
159///
160/// // Trying to get a maintainer, falling back to a packager field
161/// let maintainer_info = json!({
162/// "Packager": "Arch Linux Pacsea Team <pacsea@example.org>"
163/// // "Maintainer" key is missing to demonstrate fallback
164/// });
165/// assert_eq!(ss(&maintainer_info, &["Maintainer", "Packager"]), Some("Arch Linux Pacsea Team <pacsea@example.org>".to_string()));
166///
167/// // Returns None if no key matches
168/// assert_eq!(ss(&pkg_info, &["License", "URL"]), None);
169/// ```
170#[must_use]
171pub fn ss(v: &Value, keys: &[&str]) -> Option<String> {
172 for k in keys {
173 if let Some(s) = v.get(*k).and_then(|x| x.as_str()) {
174 return Some(s.to_owned());
175 }
176 }
177 None
178}
179/// What: Extract an array of strings from a JSON object by trying keys in order.
180///
181/// Inputs:
182/// - `v`: JSON value to extract from.
183/// - `keys`: Array of candidate keys to try in order.
184///
185/// Output:
186/// - Returns the first found array as `Vec<String>`, filtering out non-string elements.
187/// - Returns an empty vector if no array of strings is found.
188///
189/// Details:
190/// - Tries keys in the order provided and returns the first array found.
191/// - Filters out non-string elements from the array.
192/// - Returns an empty vector if no key maps to an array or if all elements are non-string.
193/// # Examples
194/// ```
195/// use pacsea::util::arrs;
196/// use serde_json::json;
197///
198/// // Getting the list of dependencies from a package's metadata
199/// let pkg_metadata = json!({
200/// "Depends": ["glibc", "gcc-libs", "bash"],
201/// "MakeDepends": ["git", "pkgconf"]
202/// });
203/// // Tries "Depends" first, returns those dependencies
204/// assert_eq!(arrs(&pkg_metadata, &["Depends", "MakeDepends"]), vec!["glibc", "gcc-libs", "bash"]);
205///
206/// // Getting the list of provides or alternate package names
207/// let provides_info = json!({
208/// "Provides": ["python-cryptography", "python-crypto"],
209/// "Conflicts": ["python-crypto-legacy"]
210/// });
211/// assert_eq!(arrs(&provides_info, &["Provides", "Replaces"]), vec!["python-cryptography", "python-crypto"]);
212///
213/// // Returns empty vector if no array of strings is found
214/// let simple_json = json!({"Name": "firefox"});
215/// assert_eq!(arrs(&simple_json, &["Depends", "OptDepends"]), Vec::<String>::new());
216/// ```
217#[must_use]
218pub fn arrs(v: &Value, keys: &[&str]) -> Vec<String> {
219 for k in keys {
220 if let Some(arr) = v.get(*k).and_then(|x| x.as_array()) {
221 return arr
222 .iter()
223 .filter_map(|e| e.as_str().map(ToOwned::to_owned))
224 .collect();
225 }
226 }
227 Vec::new()
228}
229/// What: Extract an unsigned 64-bit integer by trying multiple keys and representations.
230///
231/// Inputs:
232/// - `v`: JSON value to extract from.
233/// - `keys`: Array of candidate keys to try in order.
234///
235/// Output:
236/// - Returns `Some(u64)` if a valid value is found, or `None` if no usable value is found.
237///
238/// Details:
239/// - Accepts any of the following representations for the first matching key:
240/// - JSON `u64`
241/// - JSON `i64` convertible to `u64`
242/// - String that parses as `u64`
243/// - Tries keys in the order provided and returns the first match.
244/// - Returns `None` if no key maps to a convertible value.
245/// # Examples
246/// ```
247/// use pacsea::util::u64_of;
248/// use serde_json::json;
249///
250/// // Extracting the vote count from an AUR package info (can be a number or a string)
251/// let aur_vote_data = json!({
252/// "NumVotes": 123,
253/// "Popularity": "45.67"
254/// });
255/// assert_eq!(u64_of(&aur_vote_data, &["NumVotes", "Votes"]), Some(123));
256///
257/// // Extracting the first seen timestamp (often a string in JSON APIs)
258/// let timestamp_data = json!({
259/// "FirstSubmitted": "1672531200",
260/// "LastModified": 1672617600
261/// });
262/// assert_eq!(u64_of(×tamp_data, &["FirstSubmitted", "Submitted"]), Some(1672531200));
263/// assert_eq!(u64_of(×tamp_data, &["LastModified", "Modified"]), Some(1672617600));
264///
265/// // Returns None for negative numbers or if no convertible value is found
266/// let negative_data = json!({"OutOfDate": -1});
267/// assert_eq!(u64_of(&negative_data, &["OutOfDate"]), None);
268/// ```
269#[must_use]
270pub fn u64_of(v: &Value, keys: &[&str]) -> Option<u64> {
271 for k in keys {
272 if let Some(n) = v.get(*k) {
273 if let Some(u) = n.as_u64() {
274 return Some(u);
275 }
276 if let Some(i) = n.as_i64()
277 && let Ok(u) = u64::try_from(i)
278 {
279 return Some(u);
280 }
281 if let Some(s) = n.as_str()
282 && let Ok(p) = s.parse::<u64>()
283 {
284 return Some(p);
285 }
286 }
287 }
288 None
289}
290
291use crate::state::Source;
292
293/// Rank how well a package name matches a query using fuzzy matching (fzf-style) with a provided matcher.
294///
295/// Inputs:
296/// - `name`: Package name to match against
297/// - `query`: Query string to match
298/// - `matcher`: Reference to a `SkimMatcherV2` instance to reuse across multiple calls
299///
300/// Output:
301/// - `Some(score)` if the query matches the name (higher score = better match), `None` if no match
302///
303/// Details:
304/// - Uses the provided `fuzzy_matcher::skim::SkimMatcherV2` for fzf-style fuzzy matching
305/// - Returns scores where higher values indicate better matches
306/// - Returns `None` when the query doesn't match at all
307/// - This function is optimized for cases where the matcher can be reused across multiple calls
308#[must_use]
309pub fn fuzzy_match_rank_with_matcher(
310 name: &str,
311 query: &str,
312 matcher: &fuzzy_matcher::skim::SkimMatcherV2,
313) -> Option<i64> {
314 use fuzzy_matcher::FuzzyMatcher;
315
316 if query.trim().is_empty() {
317 return None;
318 }
319
320 matcher.fuzzy_match(name, query)
321}
322
323/// Rank how well a package name matches a query using fuzzy matching (fzf-style).
324///
325/// Inputs:
326/// - `name`: Package name to match against
327/// - `query`: Query string to match
328///
329/// Output:
330/// - `Some(score)` if the query matches the name (higher score = better match), `None` if no match
331///
332/// Details:
333/// - Uses `fuzzy_matcher::skim::SkimMatcherV2` for fzf-style fuzzy matching
334/// - Returns scores where higher values indicate better matches
335/// - Returns `None` when the query doesn't match at all
336/// - For performance-critical code that calls this function multiple times with the same query,
337/// consider using `fuzzy_match_rank_with_matcher` instead to reuse the matcher instance
338/// # Examples
339/// ```
340/// use pacsea::util::fuzzy_match_rank;
341///
342/// // Fuzzy matching a package name during search (e.g., user types "rg" for "ripgrep")
343/// let score = fuzzy_match_rank("ripgrep", "rg");
344/// assert!(score.is_some()); // Should match and return a score
345/// assert!(score.unwrap() > 0); // Higher score means better match
346///
347/// // Another common search: "fz" matching "fzf" (a command-line fuzzy finder)
348/// let fzf_score = fuzzy_match_rank("fzf", "fz");
349/// assert!(fzf_score.is_some());
350///
351/// // Exact match should have the highest score
352/// let exact_score = fuzzy_match_rank("pacman", "pacman");
353/// let partial_score = fuzzy_match_rank("pacman", "pac");
354/// assert!(exact_score.unwrap() > partial_score.unwrap());
355///
356/// // No match returns None (e.g., searching "xyz" for "linux")
357/// assert_eq!(fuzzy_match_rank("linux", "xyz"), None);
358///
359/// // Empty or whitespace-only query returns None
360/// assert_eq!(fuzzy_match_rank("vim", ""), None);
361/// assert_eq!(fuzzy_match_rank("neovim", " "), None);
362/// ```
363#[must_use]
364pub fn fuzzy_match_rank(name: &str, query: &str) -> Option<i64> {
365 use fuzzy_matcher::skim::SkimMatcherV2;
366
367 let matcher = SkimMatcherV2::default();
368 fuzzy_match_rank_with_matcher(name, query, &matcher)
369}
370
371/// What: Determine ordering weight for a package source.
372///
373/// Inputs:
374/// - `src`: Package source to rank.
375///
376/// Output:
377/// - Returns a `u8` weight where lower values indicate higher priority.
378///
379/// Details:
380/// - Used to sort results such that official repositories precede AUR, and core repos precede others.
381/// - Order: `core` => 0, `extra` => 1, other official repos => 2, AUR => 3.
382/// - Case-insensitive comparison for repository names.
383#[must_use]
384pub fn repo_order(src: &Source) -> u8 {
385 match src {
386 Source::Official { repo, .. } => {
387 if repo.eq_ignore_ascii_case("core") {
388 0
389 } else if repo.eq_ignore_ascii_case("extra") {
390 1
391 } else {
392 2
393 }
394 }
395 Source::Aur => 3,
396 }
397}
398/// What: Rank how well a package name matches a query (lower is better).
399///
400/// Inputs:
401/// - `name`: Package name to match against.
402/// - `query_lower`: Query string (must be lowercase).
403///
404/// Output:
405/// - Returns a `u8` rank: 0 = exact match, 1 = prefix match, 2 = substring match, 3 = no match.
406///
407/// Details:
408/// - Expects `query_lower` to be lowercase; the name is lowercased internally.
409/// - Returns 3 (no match) if the query is empty.
410#[must_use]
411pub fn match_rank(name: &str, query_lower: &str) -> u8 {
412 let n = name.to_lowercase();
413 if !query_lower.is_empty() {
414 if n == query_lower {
415 return 0;
416 }
417 if n.starts_with(query_lower) {
418 return 1;
419 }
420 if n.contains(query_lower) {
421 return 2;
422 }
423 }
424 3
425}
426
427/// What: Convert an optional Unix timestamp (seconds) to a UTC date-time string.
428///
429/// Inputs:
430/// - `ts`: Optional Unix timestamp in seconds since epoch.
431///
432/// Output:
433/// - Returns a formatted string `YYYY-MM-DD HH:MM:SS` (UTC), or empty string for `None`, or numeric string for negative timestamps.
434///
435/// Details:
436/// - Returns an empty string for `None`.
437/// - Negative timestamps are returned as their numeric string representation.
438/// - Output format: `YYYY-MM-DD HH:MM:SS` (UTC).
439/// - This implementation performs a simple conversion using loops and does not account for leap seconds.
440/// # Examples
441/// ```
442/// use pacsea::util::ts_to_date;
443///
444/// // Converting the timestamp for the release of a significant Arch Linux package update
445/// // Example: A major 'glibc' or 'linux' package release
446/// assert_eq!(ts_to_date(Some(1680307200)), "2023-04-01 00:00:00");
447///
448/// // Converting the 'LastModified' timestamp from an AUR package's metadata
449/// // This is commonly used to show when a package was last updated in the AUR
450/// assert_eq!(ts_to_date(Some(1704067200)), "2024-01-01 00:00:00");
451///
452/// // Handling the case where no timestamp is available (e.g., a package with no build date)
453/// assert_eq!(ts_to_date(None), "");
454/// ```
455#[must_use]
456pub fn ts_to_date(ts: Option<i64>) -> String {
457 let Some(t) = ts else {
458 return String::new();
459 };
460 if t < 0 {
461 return t.to_string();
462 }
463
464 // Split into days and seconds-of-day
465 let mut days = t / 86_400;
466 let mut sod = t % 86_400; // 0..86399
467 if sod < 0 {
468 sod += 86_400;
469 days -= 1;
470 }
471
472 let hour = u32::try_from(sod / 3600).unwrap_or(0);
473 sod %= 3600;
474 let minute = u32::try_from(sod / 60).unwrap_or(0);
475 let second = u32::try_from(sod % 60).unwrap_or(0);
476
477 // Convert days since 1970-01-01 to Y-M-D (UTC) using simple loops
478 let mut year: i32 = 1970;
479 loop {
480 let leap = is_leap(year);
481 let diy = i64::from(if leap { 366 } else { 365 });
482 if days >= diy {
483 days -= diy;
484 year += 1;
485 } else {
486 break;
487 }
488 }
489 let leap = is_leap(year);
490 let mut month: u32 = 1;
491 let mdays = [
492 31,
493 if leap { 29 } else { 28 },
494 31,
495 30,
496 31,
497 30,
498 31,
499 31,
500 30,
501 31,
502 30,
503 31,
504 ];
505 for &len in &mdays {
506 if days >= i64::from(len) {
507 days -= i64::from(len);
508 month += 1;
509 } else {
510 break;
511 }
512 }
513 let day = u32::try_from(days + 1).unwrap_or(1);
514
515 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}")
516}
517
518/// Leap year predicate for the proleptic Gregorian calendar.
519/// Return `true` if year `y` is a leap year.
520///
521/// Inputs:
522/// - `y`: Year (Gregorian calendar)
523///
524/// Output:
525/// - `true` when `y` is a leap year; `false` otherwise.
526///
527/// Notes:
528/// - Follows Gregorian rule: divisible by 4 and not by 100, unless divisible by 400.
529const fn is_leap(y: i32) -> bool {
530 (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
531}
532
533/// What: Open a file in the default editor (cross-platform).
534///
535/// Inputs:
536/// - `path`: Path to the file to open.
537///
538/// Output:
539/// - No return value; spawns a background process to open the file.
540///
541/// Details:
542/// - On Windows, uses `PowerShell`'s `Invoke-Item` to open files with the default application, with fallback to `cmd start`.
543/// - On Unix-like systems (Linux/macOS), uses `xdg-open` (Linux) or `open` (macOS).
544/// - Spawns the command in a background thread and ignores errors.
545/// # Examples
546/// ```
547/// use pacsea::util::open_file;
548/// use std::path::Path;
549///
550/// // Opening a downloaded package's PKGBUILD for inspection
551/// let pkgbuild_path = Path::new("/tmp/linux-zen/PKGBUILD");
552/// open_file(pkgbuild_path); // Launches the default text editor
553///
554/// // Opening the local Pacsea configuration file for editing
555/// let config_path = Path::new("/home/alice/.config/pacsea/settings.conf");
556/// open_file(config_path); // Opens in the configured editor
557///
558/// // Note: This function runs asynchronously and does not block.
559/// // It's safe to call even if the file doesn't exist (the OS will show an error).
560/// ```
561pub fn open_file(path: &std::path::Path) {
562 std::thread::spawn({
563 let path = path.to_path_buf();
564 move || {
565 #[cfg(target_os = "windows")]
566 {
567 // Use PowerShell to open file with default application
568 let path_str = path.display().to_string().replace('\'', "''");
569 let _ = std::process::Command::new("powershell.exe")
570 .args([
571 "-NoProfile",
572 "-Command",
573 &format!("Invoke-Item '{path_str}'"),
574 ])
575 .stdin(std::process::Stdio::null())
576 .stdout(std::process::Stdio::null())
577 .stderr(std::process::Stdio::null())
578 .spawn()
579 .or_else(|_| {
580 // Fallback: try cmd start
581 std::process::Command::new("cmd")
582 .args(["/c", "start", "", &path.display().to_string()])
583 .stdin(std::process::Stdio::null())
584 .stdout(std::process::Stdio::null())
585 .stderr(std::process::Stdio::null())
586 .spawn()
587 });
588 }
589 #[cfg(not(target_os = "windows"))]
590 {
591 // Try xdg-open first (Linux), then open (macOS)
592 let _ = std::process::Command::new("xdg-open")
593 .arg(&path)
594 .stdin(std::process::Stdio::null())
595 .stdout(std::process::Stdio::null())
596 .stderr(std::process::Stdio::null())
597 .spawn()
598 .or_else(|_| {
599 std::process::Command::new("open")
600 .arg(&path)
601 .stdin(std::process::Stdio::null())
602 .stdout(std::process::Stdio::null())
603 .stderr(std::process::Stdio::null())
604 .spawn()
605 });
606 }
607 }
608 });
609}
610
611/// What: Open a URL in the default browser (cross-platform).
612///
613/// Inputs:
614/// - `url`: URL string to open.
615///
616/// Output:
617/// - No return value; spawns a background process to open the URL.
618///
619/// Details:
620/// - On Windows, uses `cmd /c start`, with fallback to `PowerShell` `Start-Process`.
621/// - On Unix-like systems (Linux/macOS), uses `xdg-open` (Linux) or `open` (macOS).
622/// - Spawns the command in a background thread and ignores errors.
623/// - During tests, this is a no-op to avoid opening real browser windows.
624/// # Examples
625/// ```
626/// use pacsea::util::open_url;
627///
628/// // Opening the AUR page of a package for manual review
629/// open_url("https://aur.archlinux.org/packages/linux-zen");
630///
631/// // Opening the Arch Linux package search in a browser
632/// open_url("https://archlinux.org/packages/?q=neovim");
633///
634/// // Opening the Pacsea project's GitHub page for issue reporting
635/// open_url("https://github.com/Firstp1ck/Pacsea");
636///
637/// // Note: This function runs asynchronously and does not block.
638/// // During tests (`cargo test`), it's a no-op to prevent opening browsers.
639/// ```
640#[allow(clippy::missing_const_for_fn)]
641pub fn open_url(url: &str) {
642 // Skip actual spawning during tests
643 // Note: url is only used in non-test builds, but we acknowledge it for static analysis
644 #[cfg(test)]
645 let _ = url;
646 #[cfg(not(test))]
647 {
648 let url = url.to_string();
649 std::thread::spawn(move || {
650 #[cfg(target_os = "windows")]
651 {
652 // Use cmd /c start with empty title to open URL in default browser
653 let _ = std::process::Command::new("cmd")
654 .args(["/c", "start", "", &url])
655 .stdin(std::process::Stdio::null())
656 .stdout(std::process::Stdio::null())
657 .stderr(std::process::Stdio::null())
658 .spawn()
659 .or_else(|_| {
660 // Fallback: try PowerShell
661 std::process::Command::new("powershell")
662 .args(["-Command", &format!("Start-Process '{url}'")])
663 .stdin(std::process::Stdio::null())
664 .stdout(std::process::Stdio::null())
665 .stderr(std::process::Stdio::null())
666 .spawn()
667 });
668 }
669 #[cfg(not(target_os = "windows"))]
670 {
671 // Try xdg-open first (Linux), then open (macOS)
672 let _ = std::process::Command::new("xdg-open")
673 .arg(&url)
674 .stdin(std::process::Stdio::null())
675 .stdout(std::process::Stdio::null())
676 .stderr(std::process::Stdio::null())
677 .spawn()
678 .or_else(|_| {
679 std::process::Command::new("open")
680 .arg(&url)
681 .stdin(std::process::Stdio::null())
682 .stdout(std::process::Stdio::null())
683 .stderr(std::process::Stdio::null())
684 .spawn()
685 });
686 }
687 });
688 }
689}
690
691/// Build curl command arguments for fetching a URL.
692///
693/// On Windows, adds `-k` flag to skip SSL certificate verification to work around
694/// common SSL certificate issues (exit code 77). On other platforms, uses standard
695/// SSL verification.
696///
697/// Inputs:
698/// - `url`: The URL to fetch
699/// - `extra_args`: Additional curl arguments (e.g., `["--max-time", "10"]`)
700///
701/// Output:
702/// - Vector of curl arguments ready to pass to `Command::args()`
703///
704/// Details:
705/// - Base arguments: `-sSLf` (silent, show errors, follow redirects, fail on HTTP errors)
706/// - Windows: Adds `-k` to skip SSL verification
707/// - Adds `--max-filesize 10485760` to cap response bodies at 10 MiB
708/// - Adds User-Agent header to avoid being blocked by APIs
709/// - Appends `extra_args` and `url` at the end
710/// # Examples
711/// ```
712/// use pacsea::util::curl_args;
713///
714/// // Building arguments to fetch package info from the AUR RPC API
715/// let aur_args = curl_args("https://aur.archlinux.org/rpc/?v=5&type=info&arg=linux-zen", &["--max-time", "10"]);
716/// // On Windows, includes -k flag; always includes -sSLf and User-Agent
717/// assert!(aur_args.contains(&"-sSLf".to_string()));
718/// assert!(aur_args.contains(&"-H".to_string()));
719/// // User-Agent is browser-like (Firefox) with Pacsea identifier
720/// let user_agent = aur_args.iter().find(|arg| arg.contains("Mozilla") && arg.contains("Pacsea/")).unwrap();
721/// assert!(user_agent.contains("Mozilla/5.0"));
722/// assert!(user_agent.contains("Firefox"));
723/// assert!(user_agent.contains("Pacsea/"));
724/// assert!(aur_args.contains(&"--max-time".to_string()));
725/// assert!(aur_args.contains(&"10".to_string()));
726/// assert!(aur_args.last().unwrap().starts_with("https://aur.archlinux.org"));
727///
728/// // Building arguments to fetch the core repository database
729/// let repo_args = curl_args("https://archlinux.org/packages/core/x86_64/pacman/", &["--compressed"]);
730/// assert!(repo_args.contains(&"--compressed".to_string()));
731/// assert!(repo_args.last().unwrap().contains("archlinux.org"));
732///
733/// // Building arguments with no extra options
734/// let simple_args = curl_args("https://example.com/feed", &[]);
735/// assert_eq!(simple_args.last().unwrap(), "https://example.com/feed");
736/// ```
737#[must_use]
738pub fn curl_args(url: &str, extra_args: &[&str]) -> Vec<String> {
739 let mut args = vec!["-sSLf".to_string()];
740
741 #[cfg(target_os = "windows")]
742 {
743 // Skip SSL certificate verification on Windows to avoid exit code 77
744 args.push("-k".to_string());
745 }
746
747 // Add default timeouts to prevent indefinite hangs:
748 // --connect-timeout 30: fail if connection not established within 30 seconds
749 // --max-time 90: fail if entire operation exceeds 90 seconds
750 // --max-filesize 10485760: cap response body to 10 MiB to avoid excessive memory use
751 // Note: archlinux.org has DDoS protection that can make responses slower
752 args.push("--connect-timeout".to_string());
753 args.push("30".to_string());
754 args.push("--max-time".to_string());
755 args.push("90".to_string());
756 args.push("--max-filesize".to_string());
757 args.push("10485760".to_string());
758
759 // Add browser-like headers to work with archlinux.org's DDoS protection.
760 // Using a Firefox-like User-Agent helps bypass bot detection while still
761 // identifying as Pacsea in the product token for transparency.
762 args.push("-H".to_string());
763 args.push(format!(
764 "User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0 Pacsea/{}",
765 env!("CARGO_PKG_VERSION")
766 ));
767 // Add Accept header that browsers send
768 args.push("-H".to_string());
769 args.push(
770 "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".to_string(),
771 );
772 // Add Accept-Language header for completeness
773 args.push("-H".to_string());
774 args.push("Accept-Language: en-US,en;q=0.5".to_string());
775
776 // Add any extra arguments
777 for arg in extra_args {
778 args.push((*arg).to_string());
779 }
780
781 // URL goes last
782 args.push(url.to_string());
783
784 args
785}
786
787/// What: Parse a single update entry line in the format "name - `old_version` -> name - `new_version`".
788///
789/// Inputs:
790/// - `line`: A trimmed line from the updates file
791///
792/// Output:
793/// - `Some((name, old_version, new_version))` if parsing succeeds, `None` otherwise
794///
795/// Details:
796/// - Parses format: "name - `old_version` -> name - `new_version`"
797/// - Returns `None` for empty lines or invalid formats
798/// - Uses `rfind` to find the last occurrence of " - " to handle package names that may contain dashes
799/// - Normalizes the right-hand side with the same ` -> ` rules as pacman parsing so a merged
800/// `oldver -> newver` chain never becomes a single `new_version` string in column three
801/// # Examples
802/// ```
803/// use pacsea::util::parse_update_entry;
804///
805/// // Parsing a standard package update line from `pacman -Spu` or similar output
806/// let update_line = "linux - 6.10.1.arch1-1 -> linux - 6.10.2.arch1-1";
807/// let parsed = parse_update_entry(update_line);
808/// assert_eq!(parsed, Some(("linux".to_string(), "6.10.1.arch1-1".to_string(), "6.10.2.arch1-1".to_string())));
809///
810/// // Parsing an update for a package with a hyphen in its name (common in AUR)
811/// let aur_update_line = "python-requests - 2.31.0-1 -> python-requests - 2.32.0-1";
812/// let aur_parsed = parse_update_entry(aur_update_line);
813/// assert_eq!(aur_parsed, Some(("python-requests".to_string(), "2.31.0-1".to_string(), "2.32.0-1".to_string())));
814///
815/// // Handling a malformed or empty line (returns None)
816/// assert_eq!(parse_update_entry(""), None);
817/// assert_eq!(parse_update_entry("invalid line"), None);
818/// ```
819#[must_use]
820pub fn parse_update_entry(line: &str) -> Option<(String, String, String)> {
821 let trimmed = line.trim();
822 if trimmed.is_empty() {
823 return None;
824 }
825 // Parse format: "name - old_version -> name - new_version"
826 trimmed.find(" -> ").and_then(|arrow_pos| {
827 let before_arrow = trimmed[..arrow_pos].trim();
828 let after_arrow = trimmed[arrow_pos + 4..].trim();
829
830 // Parse "name - old_version" from before_arrow
831 before_arrow.rfind(" - ").and_then(|old_dash_pos| {
832 let name = before_arrow[..old_dash_pos].trim().to_string();
833 let old_version = before_arrow[old_dash_pos + 3..].trim().to_string();
834 if name.is_empty() || old_version.is_empty() {
835 return None;
836 }
837
838 let rhs_tail = after_arrow.rsplit(" -> ").next()?.trim();
839 if rhs_tail.is_empty() {
840 return None;
841 }
842 let new_version = rhs_tail.rfind(" - ").map_or_else(
843 || rhs_tail.to_string(),
844 |new_dash_pos| rhs_tail[new_dash_pos + 3..].trim().to_string(),
845 );
846 if new_version.is_empty() {
847 return None;
848 }
849 Some((name, old_version, new_version))
850 })
851 })
852}
853
854/// What: Return today's UTC date formatted as `YYYYMMDD` using only the standard library.
855///
856/// Inputs:
857/// - None (uses current system time).
858///
859/// Output:
860/// - Returns a string in format `YYYYMMDD` representing today's date in UTC.
861///
862/// Details:
863/// - Uses a simple conversion from Unix epoch seconds to a UTC calendar date.
864/// - Matches the same leap-year logic as `ts_to_date`.
865/// - Falls back to epoch date (1970-01-01) if system time is before 1970.
866#[must_use]
867pub fn today_yyyymmdd_utc() -> String {
868 let secs = std::time::SystemTime::now()
869 .duration_since(std::time::UNIX_EPOCH)
870 .ok()
871 .and_then(|dur| i64::try_from(dur.as_secs()).ok())
872 .unwrap_or(0); // fallback to epoch if clock is before 1970
873 let mut days = secs / 86_400;
874 // Derive year
875 let mut year: i32 = 1970;
876 loop {
877 let leap = is_leap(year);
878 let diy = i64::from(if leap { 366 } else { 365 });
879 if days >= diy {
880 days -= diy;
881 year += 1;
882 } else {
883 break;
884 }
885 }
886 // Derive month/day within the year
887 let leap = is_leap(year);
888 let mut month: u32 = 1;
889 let mdays = [
890 31,
891 if leap { 29 } else { 28 },
892 31,
893 30,
894 31,
895 30,
896 31,
897 31,
898 30,
899 31,
900 30,
901 31,
902 ];
903 for &len in &mdays {
904 if days >= i64::from(len) {
905 days -= i64::from(len);
906 month += 1;
907 } else {
908 break;
909 }
910 }
911 let day = u32::try_from(days + 1).unwrap_or(1);
912 format!("{year:04}{month:02}{day:02}")
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918 use crate::state::Source;
919
920 #[test]
921 /// What: Verify that percent encoding preserves unreserved characters and escapes reserved ones.
922 ///
923 /// Inputs:
924 /// - `cases`: Sample strings covering empty input, ASCII safe set, spaces, plus signs, and unicode.
925 ///
926 /// Output:
927 /// - Encoded results match RFC 3986 expectations for each case.
928 ///
929 /// Details:
930 /// - Exercises `percent_encode` across edge characters to confirm proper handling of special
931 /// symbols and non-ASCII glyphs.
932 fn util_percent_encode() {
933 assert_eq!(percent_encode(""), "");
934 assert_eq!(percent_encode("abc-_.~"), "abc-_.~");
935 assert_eq!(percent_encode("a b"), "a%20b");
936 assert_eq!(percent_encode("C++"), "C%2B%2B");
937 assert_eq!(percent_encode("π"), "%CF%80");
938 }
939
940 #[test]
941 /// What: Validate JSON helper extractors across strings, arrays, and numeric conversions.
942 ///
943 /// Inputs:
944 /// - `v`: Composite JSON value containing strings, arrays, unsigned ints, negatives, and text numbers.
945 ///
946 /// Output:
947 /// - Helpers return expected values, defaulting or rejecting incompatible types.
948 ///
949 /// Details:
950 /// - Confirms `s`, `ss`, `arrs`, and `u64_of` handle fallbacks, partial arrays, and reject negative
951 /// values while parsing numeric strings.
952 fn util_json_extractors_and_u64() {
953 let v: serde_json::Value = serde_json::json!({
954 "a": "str",
955 "b": ["x", 1, "y"],
956 "c": 42u64,
957 "d": -5,
958 "e": "123",
959 });
960 assert_eq!(s(&v, "a"), "str");
961 assert_eq!(s(&v, "missing"), "");
962 assert_eq!(ss(&v, &["z", "a"]).as_deref(), Some("str"));
963 assert_eq!(
964 arrs(&v, &["b", "missing"]),
965 vec!["x".to_string(), "y".to_string()]
966 );
967 assert_eq!(u64_of(&v, &["c"]), Some(42));
968 assert_eq!(u64_of(&v, &["d"]), None);
969 assert_eq!(u64_of(&v, &["e"]), Some(123));
970 assert_eq!(u64_of(&v, &["missing"]), None);
971 }
972
973 #[test]
974 /// What: Ensure repository ordering and name match ranking align with search heuristics.
975 ///
976 /// Inputs:
977 /// - `sources`: Official repos (core, extra, other) plus AUR source for ordering comparison.
978 /// - `queries`: Example name/query pairs for ranking checks.
979 ///
980 /// Output:
981 /// - Ordering places core before extra before other before AUR and match ranks progress 0→3.
982 ///
983 /// Details:
984 /// - Verifies that `repo_order` promotes official repositories and that `match_rank` scores exact,
985 /// prefix, substring, and non-matches as intended.
986 fn util_repo_order_and_rank() {
987 let core = Source::Official {
988 repo: "core".into(),
989 arch: "x86_64".into(),
990 };
991 let extra = Source::Official {
992 repo: "extra".into(),
993 arch: "x86_64".into(),
994 };
995 let other = Source::Official {
996 repo: "community".into(),
997 arch: "x86_64".into(),
998 };
999 let aur = Source::Aur;
1000 assert!(repo_order(&core) < repo_order(&extra));
1001 assert!(repo_order(&extra) < repo_order(&other));
1002 assert!(repo_order(&other) < repo_order(&aur));
1003
1004 assert_eq!(match_rank("ripgrep", "ripgrep"), 0);
1005 assert_eq!(match_rank("ripgrep", "rip"), 1);
1006 assert_eq!(match_rank("ripgrep", "pg"), 2);
1007 assert_eq!(match_rank("ripgrep", "zzz"), 3);
1008 }
1009
1010 #[test]
1011 /// What: Verify fuzzy matching returns scores for valid matches and None for non-matches.
1012 ///
1013 /// Inputs:
1014 /// - Package names and queries covering exact matches, partial matches, and non-matches.
1015 ///
1016 /// Output:
1017 /// - Fuzzy matching returns `Some(score)` for matches (higher = better) and `None` for non-matches.
1018 ///
1019 /// Details:
1020 /// - Tests that fuzzy matching can find non-substring matches (e.g., "rg" matches "ripgrep").
1021 /// - Verifies empty queries return `None`.
1022 fn util_fuzzy_match_rank() {
1023 // Exact match should return a score
1024 assert!(fuzzy_match_rank("ripgrep", "ripgrep").is_some());
1025
1026 // Prefix match should return a score
1027 assert!(fuzzy_match_rank("ripgrep", "rip").is_some());
1028
1029 // Fuzzy match (non-substring) should return a score
1030 assert!(fuzzy_match_rank("ripgrep", "rg").is_some());
1031
1032 // Non-match should return None
1033 assert!(fuzzy_match_rank("ripgrep", "xyz").is_none());
1034
1035 // Empty query should return None
1036 assert!(fuzzy_match_rank("ripgrep", "").is_none());
1037 assert!(fuzzy_match_rank("ripgrep", " ").is_none());
1038
1039 // Case-insensitive matching
1040 assert!(fuzzy_match_rank("RipGrep", "rg").is_some());
1041 assert!(fuzzy_match_rank("RIPGREP", "rip").is_some());
1042 }
1043
1044 #[test]
1045 /// What: Convert timestamps into UTC date strings, including leap-year handling.
1046 ///
1047 /// Inputs:
1048 /// - `samples`: `None`, negative, epoch, and leap-day timestamps.
1049 ///
1050 /// Output:
1051 /// - Strings reflect empty/default, passthrough, epoch baseline, and leap day formatting.
1052 ///
1053 /// Details:
1054 /// - Exercises `ts_to_date` across typical edge cases to ensure correct chrono arithmetic.
1055 fn util_ts_to_date_and_leap() {
1056 assert_eq!(ts_to_date(None), "");
1057 assert_eq!(ts_to_date(Some(-1)), "-1");
1058 assert_eq!(ts_to_date(Some(0)), "1970-01-01 00:00:00");
1059 assert_eq!(ts_to_date(Some(951_782_400)), "2000-02-29 00:00:00");
1060 }
1061
1062 #[test]
1063 /// What: Validate `ts_to_date` output at the Y2K boundary.
1064 ///
1065 /// Inputs:
1066 /// - `y2k`: Timestamp for 2000-01-01 and the preceding second.
1067 ///
1068 /// Output:
1069 /// - Formatted strings match midnight Y2K and the final second of 1999.
1070 ///
1071 /// Details:
1072 /// - Confirms no off-by-one errors occur when crossing the year boundary.
1073 fn util_ts_to_date_boundaries() {
1074 assert_eq!(ts_to_date(Some(946_684_800)), "2000-01-01 00:00:00");
1075 assert_eq!(ts_to_date(Some(946_684_799)), "1999-12-31 23:59:59");
1076 }
1077
1078 #[test]
1079 /// What: Ensure `parse_update_entry` collapses chained ` -> ` on the right-hand side.
1080 ///
1081 /// Inputs:
1082 /// - Update line with duplicated old/target transition in the stored format
1083 ///
1084 /// Output:
1085 /// - Parsed `new_version` is a single target version string
1086 ///
1087 /// Details:
1088 /// - Guards the updates modal third column against `old -> new` redundantly shown as `new`
1089 fn util_parse_update_entry_collapses_rhs_chain() {
1090 let line = "libraw - 0.22.0-2 -> libraw - 0.22.0-2 -> 0.22.1-1";
1091 let parsed = parse_update_entry(line);
1092 assert_eq!(
1093 parsed,
1094 Some((
1095 "libraw".to_string(),
1096 "0.22.0-2".to_string(),
1097 "0.22.1-1".to_string()
1098 ))
1099 );
1100 }
1101
1102 #[test]
1103 /// What: Ensure curl argument defaults include an explicit response-size limit.
1104 ///
1105 /// Inputs:
1106 /// - A sample HTTPS URL and no extra arguments.
1107 ///
1108 /// Output:
1109 /// - The generated curl args include `--max-filesize 10485760`.
1110 ///
1111 /// Details:
1112 /// - Protects against oversized HTTP responses that could exhaust memory.
1113 fn util_curl_args_include_max_filesize_limit() {
1114 let args = curl_args("https://example.com/feed", &[]);
1115 assert!(
1116 args.windows(2)
1117 .any(|pair| { pair[0] == "--max-filesize" && pair[1] == "10485760" })
1118 );
1119 }
1120}