use super::command::{CommandError, CommandRunner};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
pub(super) fn fetch_official_metadata<R: CommandRunner>(
runner: &R,
repo: &str,
name: &str,
expected_version: &str,
) -> Result<OfficialMetadata, CommandError> {
let spec = format!("{repo}/{name}");
let output = runner.run("pacman", &["-Si", &spec])?;
let fields = parse_pacman_key_values(&output);
if let Some(version) = fields.get("Version")
&& version.trim() != expected_version
{
tracing::debug!(
"Preflight summary: pacman -Si reported version {} for {} (expected {})",
version.trim(),
spec,
expected_version
);
}
let download_size = fields
.get("Download Size")
.and_then(|raw| parse_size_to_bytes(raw));
let install_size = fields
.get("Installed Size")
.and_then(|raw| parse_size_to_bytes(raw));
Ok(OfficialMetadata {
download_size,
install_size,
})
}
pub(super) fn fetch_installed_version<R: CommandRunner>(
runner: &R,
name: &str,
) -> Result<String, CommandError> {
let output = runner.run("pacman", &["-Q", name])?;
let mut parts = output.split_whitespace();
let _pkg_name = parts.next();
parts
.next_back()
.map(ToString::to_string)
.ok_or_else(|| CommandError::Parse {
program: "pacman -Q".to_string(),
field: "version".to_string(),
})
}
pub(super) fn fetch_installed_size<R: CommandRunner>(
runner: &R,
name: &str,
) -> Result<u64, CommandError> {
let output = runner.run("pacman", &["-Qi", name])?;
let fields = parse_pacman_key_values(&output);
fields
.get("Installed Size")
.and_then(|raw| parse_size_to_bytes(raw))
.ok_or_else(|| CommandError::Parse {
program: "pacman -Qi".to_string(),
field: "Installed Size".to_string(),
})
}
#[derive(Default, Debug)]
pub struct OfficialMetadata {
pub(crate) download_size: Option<u64>,
pub(crate) install_size: Option<u64>,
}
pub(super) fn parse_pacman_key_values(output: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
let mut last_key: Option<String> = None;
for line in output.lines() {
if line.trim().is_empty() {
continue;
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim().to_string();
let val = value.trim().to_string();
map.insert(key.clone(), val);
last_key = Some(key);
} else if line.starts_with(' ')
&& let Some(key) = &last_key
{
map.entry(key.clone())
.and_modify(|existing| {
if !existing.ends_with(' ') {
existing.push(' ');
}
existing.push_str(line.trim());
})
.or_insert_with(|| line.trim().to_string());
}
}
map
}
pub(super) fn parse_size_to_bytes(raw: &str) -> Option<u64> {
const MAX_U64_AS_F64: f64 = 18_446_744_073_709_551_615.0; let mut parts = raw.split_whitespace();
let number = parts.next()?.replace(',', "");
let value = number.parse::<f64>().ok()?;
let unit = parts.next().unwrap_or("B");
let multiplier = match unit {
"KiB" => 1024.0,
"MiB" => 1024.0 * 1024.0,
"GiB" => 1024.0 * 1024.0 * 1024.0,
"TiB" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
_ => 1.0,
};
let result = value * multiplier;
if result < 0.0 {
return None;
}
if result > MAX_U64_AS_F64 {
return None;
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let bytes = result.round() as u64;
Some(bytes)
}
fn find_aur_package_file(name: &str, version: Option<&str>) -> Option<PathBuf> {
if let Ok(pacman_cache) = Path::new("/var/cache/pacman/pkg").read_dir() {
for entry in pacman_cache.flatten() {
let path = entry.path();
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.starts_with(name)
&& (file_name.ends_with(".pkg.tar.zst") || file_name.ends_with(".pkg.tar.xz"))
{
if let Some(ver) = version {
if file_name.contains(ver) {
return Some(path);
}
} else {
return Some(path);
}
}
}
}
}
if let Ok(home) = std::env::var("HOME") {
let cache_paths = [
format!("{home}/.cache/paru/clone/{name}"),
format!("{home}/.cache/yay/{name}"),
];
for cache_base in cache_paths {
let cache_dir = Path::new(&cache_base);
if let Ok(entries) = fs::read_dir(cache_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& let Some(file_name) = path.file_name().and_then(|n| n.to_str())
&& file_name.starts_with(name)
&& (file_name.ends_with(".pkg.tar.zst")
|| file_name.ends_with(".pkg.tar.xz"))
{
if let Some(ver) = version {
if file_name.contains(ver) {
return Some(path);
}
} else {
return Some(path);
}
}
}
}
}
}
None
}
fn extract_aur_package_sizes<R: CommandRunner>(runner: &R, pkg_path: &Path) -> OfficialMetadata {
let download_size = fs::metadata(pkg_path).ok().map(|meta| meta.len());
let install_size = pkg_path.to_str().and_then(|pkg_str| {
runner
.run("pacman", &["-Qp", pkg_str])
.ok()
.and_then(|output| {
let fields = parse_pacman_key_values(&output);
fields
.get("Installed Size")
.and_then(|raw| parse_size_to_bytes(raw))
})
});
OfficialMetadata {
download_size,
install_size,
}
}
pub(super) fn fetch_aur_metadata<R: CommandRunner>(
runner: &R,
name: &str,
version: Option<&str>,
) -> OfficialMetadata {
find_aur_package_file(name, version).map_or(
OfficialMetadata {
download_size: None,
install_size: None,
},
|pkg_path| extract_aur_package_sizes(runner, &pkg_path),
)
}
#[cfg(not(windows))]
#[cfg(test)]
mod tests {
use super::*;
use crate::logic::preflight::command::{CommandError, CommandRunner};
use std::collections::HashMap;
use std::os::unix::process::ExitStatusExt;
use std::sync::Mutex;
type MockCommandKey = (String, Vec<String>);
type MockCommandResult = Result<String, CommandError>;
type MockResponseMap = HashMap<MockCommandKey, MockCommandResult>;
#[derive(Default)]
struct MockRunner {
responses: Mutex<MockResponseMap>,
}
impl MockRunner {
fn with(responses: MockResponseMap) -> Self {
Self {
responses: Mutex::new(responses),
}
}
}
impl CommandRunner for MockRunner {
fn run(&self, program: &str, args: &[&str]) -> Result<String, CommandError> {
let key = (
program.to_string(),
args.iter().map(ToString::to_string).collect::<Vec<_>>(),
);
let mut guard = self.responses.lock().expect("poisoned responses mutex");
guard.remove(&key).unwrap_or_else(|| {
Err(CommandError::Failed {
program: program.to_string(),
args: args.iter().map(ToString::to_string).collect(),
status: std::process::ExitStatus::from_raw(1),
})
})
}
}
#[test]
fn test_parse_size_to_bytes() {
assert_eq!(parse_size_to_bytes("10 B"), Some(10));
assert_eq!(parse_size_to_bytes("1 KiB"), Some(1024));
assert_eq!(parse_size_to_bytes("2.5 MiB"), Some(2_621_440));
assert_eq!(parse_size_to_bytes("1.5 GiB"), Some(1_610_612_736));
assert_eq!(parse_size_to_bytes("1,234.5 MiB"), Some(1_294_467_072));
assert_eq!(parse_size_to_bytes("invalid"), None);
assert_eq!(parse_size_to_bytes(""), None);
}
#[test]
fn test_fetch_aur_metadata_not_found() {
let runner = MockRunner::default();
let meta = fetch_aur_metadata(&runner, "nonexistent-package", Some("1.0.0"));
assert_eq!(meta.download_size, None);
assert_eq!(meta.install_size, None);
}
#[test]
fn test_extract_aur_package_sizes() {
let temp_dir = std::env::temp_dir().join(format!("pacsea_test_{}", std::process::id()));
std::fs::create_dir_all(&temp_dir).expect("failed to create test temp directory");
let pkg_path = temp_dir.join("test-1.0.0-1-x86_64.pkg.tar.zst");
std::fs::write(&pkg_path, b"fake package data").expect("failed to write test package file");
let mut responses = HashMap::new();
responses.insert(
(
"pacman".into(),
vec!["-Qp".into(), pkg_path.to_string_lossy().to_string()],
),
Ok("Name : test\nInstalled Size : 5.00 MiB\n".to_string()),
);
let runner = MockRunner::with(responses);
let meta = extract_aur_package_sizes(&runner, &pkg_path);
assert_eq!(meta.download_size, Some(17));
assert_eq!(meta.install_size, Some(5 * 1024 * 1024));
let _ = std::fs::remove_dir_all(&temp_dir);
}
}