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(×tamp_data, &["FirstSubmitted", "Submitted"]), Some(1672531200));
262/// assert_eq!(u64_of(×tamp_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}