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