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(&timestamp_data, &["FirstSubmitted", "Submitted"]), Some(1672531200));
255/// assert_eq!(u64_of(&timestamp_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}