Skip to main content

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