use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::process::Stdio;
use async_trait::async_trait;
use eyre::bail;
use super::{InstallOpts, PackageRequest, PackageState, PackageStatus, SystemPackageManager};
use crate::result::Result;
use crate::system::sudo;
pub(crate) struct PacmanManager {}
impl PacmanManager {
pub(crate) fn new() -> Self {
Self {}
}
fn dbs_missing(&self) -> bool {
let sync = Path::new("/var/lib/pacman/sync");
!crate::file::ls(sync).unwrap_or_default().iter().any(|p| {
p.extension()
.map(|e| e.to_string_lossy() == "db")
.unwrap_or(false)
})
}
fn refresh(&self, opts: &InstallOpts) -> Result<()> {
let args = vec!["-Sy".to_string()];
if opts.dry_run {
miseprintln!("{}", sudo::argv("pacman", &args).join(" "));
return Ok(());
}
sudo::run("pacman", &args, &[])
}
}
fn parse_pacman_query(output: &str, requests: &[PackageRequest]) -> Vec<PackageStatus> {
let mut installed: HashMap<&str, &str> = HashMap::new();
for line in output.lines() {
if let Some((name, version)) = line.split_once(' ') {
installed.insert(name, version);
}
}
requests
.iter()
.map(|req| {
let state = match installed.get(req.name.as_str()) {
Some(version) => package_state(req, version),
None => PackageState::Missing,
};
PackageStatus {
request: req.clone(),
state,
}
})
.collect()
}
fn package_state(req: &PackageRequest, version: &str) -> PackageState {
match &req.version {
Some(requested)
if version != requested && !version.starts_with(&format!("{requested}-")) =>
{
PackageState::VersionMismatch {
installed: version.to_string(),
}
}
_ => PackageState::Installed {
version: version.to_string(),
},
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
struct PacmanProvide {
name: String,
version: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
struct PacmanPackageMetadata {
name: String,
version: String,
provides: HashSet<PacmanProvide>,
}
fn parse_pacman_info(output: &str) -> Vec<PacmanPackageMetadata> {
let mut packages = Vec::new();
let mut name = None;
let mut version = None;
let mut provides = HashSet::new();
let mut reading_provides = false;
for line in output.lines().chain(std::iter::once("")) {
if line.trim().is_empty() {
if let (Some(name), Some(version)) = (name.take(), version.take()) {
packages.push(PacmanPackageMetadata {
name,
version,
provides: std::mem::take(&mut provides),
});
}
reading_provides = false;
continue;
}
if let Some((key, value)) = line.split_once(':') {
reading_provides = false;
match key.trim() {
"Name" => name = Some(value.trim().to_string()),
"Version" => version = Some(value.trim().to_string()),
"Provides" => {
reading_provides = true;
provides.extend(parse_provides(value));
}
_ => {}
}
} else if reading_provides {
provides.extend(parse_provides(line));
}
}
packages
}
fn parse_provides(value: &str) -> impl Iterator<Item = PacmanProvide> + '_ {
value
.split_whitespace()
.filter(|provide| *provide != "None")
.map(|provide| {
let (name, version) = provide
.split_once('=')
.map(|(name, version)| (name, Some(version.to_string())))
.unwrap_or_else(|| (provide.split(['<', '>']).next().unwrap_or(provide), None));
PacmanProvide {
name: name.to_string(),
version,
}
})
}
fn find_provider<'a>(
packages: &'a [PacmanPackageMetadata],
request: &PackageRequest,
constraint_satisfied: bool,
eligible: impl Fn(&PacmanPackageMetadata) -> bool,
) -> Option<&'a PacmanPackageMetadata> {
packages
.iter()
.filter(|package| eligible(package))
.find(|package| {
package.name == request.name
&& (!constraint_satisfied
|| request
.version
.as_ref()
.is_none_or(|version| pacman_version_matches(version, &package.version)))
})
.or_else(|| {
packages
.iter()
.filter(|package| eligible(package))
.find(|package| {
package.provides.iter().any(|provide| {
provide.name == request.name
&& (!constraint_satisfied
|| request.version.as_ref().is_none_or(|version| {
provide.version.as_ref().is_some_and(|provided| {
pacman_version_matches(version, provided)
})
}))
})
})
})
}
fn matching_provider_names(packages: &[PacmanPackageMetadata], requested: &str) -> Vec<String> {
packages
.iter()
.filter(|package| {
package.name == requested
|| package
.provides
.iter()
.any(|provide| provide.name == requested)
})
.map(|package| package.name.clone())
.collect()
}
fn pacman_version_matches(requested: &str, provided: &str) -> bool {
match std::process::Command::new("vercmp")
.args([provided, requested])
.stdin(Stdio::null())
.output()
{
Ok(output) if output.status.success() => {
String::from_utf8_lossy(&output.stdout).trim() == "0"
}
_ => provided == requested || provided.starts_with(&format!("{requested}-")),
}
}
fn parse_pacman_deptest(output: &str) -> HashSet<&str> {
output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect()
}
fn deptest_requirement(req: &PackageRequest) -> String {
match &req.version {
Some(version) => format!("{}={version}", req.name),
None => req.name.clone(),
}
}
fn remove_args(names: &[String]) -> Vec<String> {
let mut args = vec![
"-R".to_string(),
"--noconfirm".to_string(),
"--".to_string(),
];
args.extend(names.iter().cloned());
args
}
async fn concrete_remove_names(pkgs: &[PackageRequest]) -> Result<Vec<String>> {
let info = pacman_info().await?;
let packages = parse_pacman_info(&info);
let mut names = Vec::new();
for pkg in pkgs {
let providers = matching_provider_names(&packages, &pkg.name);
if providers.is_empty() {
return Err(eyre::eyre!(
"pacman -Qi returned no provider for satisfied requirement '{}'",
pkg.name
));
}
for provider in providers {
if !names.contains(&provider) {
names.push(provider);
}
}
}
Ok(names)
}
fn apply_provider_query<'a>(
status: &mut PackageStatus,
packages: &'a [PacmanPackageMetadata],
constraint_satisfied: bool,
) -> Result<&'a PacmanPackageMetadata> {
let provider = find_provider(packages, &status.request, constraint_satisfied, |_| true)
.ok_or_else(|| {
eyre::eyre!(
"pacman -Qi returned no provider for satisfied requirement '{}'",
status.request.name
)
})?;
status.state = if constraint_satisfied {
PackageState::Installed {
version: provider.version.clone(),
}
} else {
PackageState::VersionMismatch {
installed: provider.version.clone(),
}
};
Ok(provider)
}
async fn pacman_info() -> Result<String> {
let args = ["-Qi"];
debug!("$ pacman {}", args.join(" "));
let output = tokio::process::Command::new("pacman")
.args(args)
.env("LC_ALL", "C")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;
if !output.status.success() {
bail!(
"pacman -Qi failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
async fn pacman_query(names: &[String]) -> Result<String> {
if names.is_empty() {
return Ok(String::new());
}
let mut args = vec!["-Q", "--"];
args.extend(names.iter().map(String::as_str));
debug!("$ pacman {}", args.join(" "));
let output = tokio::process::Command::new("pacman")
.args(&args)
.env("LC_ALL", "C")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;
let stderr = String::from_utf8_lossy(&output.stderr);
let missing = stderr
.lines()
.filter_map(|line| {
line.strip_prefix("error: package '")?
.strip_suffix("' was not found")
})
.filter(|name| names.iter().any(|requested| requested == name))
.collect::<HashSet<_>>();
let only_missing = !missing.is_empty()
&& stderr.lines().all(|line| {
line.trim().is_empty()
|| missing.iter().any(|name| {
line == format!("error: package '{name}' was not found")
|| line
== format!(
"warning: '{name}' is a file, you might want to use -p/--file."
)
})
});
let valid_result = (output.status.success() && stderr.is_empty())
|| (output.status.code() == Some(1) && only_missing);
if !valid_result {
bail!("pacman -Q failed: {}", stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
async fn pacman_deptest(names: &[String]) -> Result<String> {
if names.is_empty() {
return Ok(String::new());
}
let mut args = vec!["-T", "--"];
args.extend(names.iter().map(String::as_str));
debug!("$ pacman {}", args.join(" "));
let output = tokio::process::Command::new("pacman")
.args(&args)
.env("LC_ALL", "C")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;
if !matches!(output.status.code(), Some(0 | 127)) {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("pacman -T failed: {}", stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub(super) async fn resolve_installed_provider(
request: &PackageRequest,
eligible_names: &HashSet<String>,
) -> Result<Option<(String, PackageState)>> {
let requirement = deptest_requirement(request);
let deptest = pacman_deptest(&[requirement]).await?;
let unsatisfied = parse_pacman_deptest(&deptest);
let constraint_satisfied = unsatisfied.is_empty();
if !constraint_satisfied {
if request.version.is_none() {
return Ok(None);
}
let bare_deptest = pacman_deptest(std::slice::from_ref(&request.name)).await?;
let bare_missing = parse_pacman_deptest(&bare_deptest);
if !bare_missing.is_empty() {
return Ok(None);
}
}
let info = pacman_info().await?;
let packages = parse_pacman_info(&info);
let Some(provider) = find_provider(&packages, request, constraint_satisfied, |package| {
eligible_names.contains(&package.name)
}) else {
return Ok(None);
};
let state = if constraint_satisfied {
PackageState::Installed {
version: provider.version.clone(),
}
} else {
PackageState::VersionMismatch {
installed: provider.version.clone(),
}
};
Ok(Some((provider.name.clone(), state)))
}
#[async_trait(?Send)]
impl SystemPackageManager for PacmanManager {
fn name(&self) -> &str {
"pacman"
}
fn is_available(&self) -> bool {
cfg!(target_os = "linux") && crate::file::which("pacman").is_some()
}
fn unavailable_reason(&self) -> String {
if cfg!(target_os = "linux") {
"pacman not found".to_string()
} else {
"only available on linux".to_string()
}
}
async fn installed(&self, pkgs: &[PackageRequest]) -> Result<Vec<PackageStatus>> {
if pkgs.is_empty() {
return Ok(vec![]);
}
let names = pkgs.iter().map(|pkg| pkg.name.clone()).collect::<Vec<_>>();
let stdout = pacman_query(&names).await?;
let mut statuses = parse_pacman_query(&stdout, pkgs);
let apparent_missing = statuses
.iter()
.filter(|status| matches!(status.state, PackageState::Missing))
.map(|status| deptest_requirement(&status.request))
.collect::<Vec<_>>();
if apparent_missing.is_empty() {
return Ok(statuses);
}
let deptest = pacman_deptest(&apparent_missing).await?;
let unsatisfied = parse_pacman_deptest(&deptest);
let versioned_unsatisfied = statuses
.iter()
.filter(|status| {
status.request.version.is_some()
&& unsatisfied.contains(deptest_requirement(&status.request).as_str())
})
.map(|status| status.request.name.clone())
.collect::<Vec<_>>();
let bare_deptest = pacman_deptest(&versioned_unsatisfied).await?;
let bare_missing = parse_pacman_deptest(&bare_deptest);
let info = pacman_info().await?;
let packages = parse_pacman_info(&info);
for status in statuses
.iter_mut()
.filter(|status| matches!(status.state, PackageState::Missing))
{
let constraint_satisfied =
!unsatisfied.contains(deptest_requirement(&status.request).as_str());
if !constraint_satisfied
&& (status.request.version.is_none()
|| bare_missing.contains(status.request.name.as_str()))
{
continue;
}
let provider = apply_provider_query(status, &packages, constraint_satisfied)?;
debug!(
"pacman: {} is satisfied by installed provider {}",
status.request.name, provider.name,
);
}
Ok(statuses)
}
fn supports_version_pins(&self) -> bool {
false
}
async fn install(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> {
if let Some(p) = pkgs.iter().find(|p| p.version.is_some()) {
bail!(
"pacman cannot install a pinned version ('{p}'): Arch repositories only \
provide the latest version"
);
}
if opts.update || self.dbs_missing() {
self.refresh(opts)?;
}
let mut args = vec![
"-S".to_string(),
"--noconfirm".to_string(),
"--needed".to_string(),
"--".to_string(),
];
args.extend(pkgs.iter().map(|p| p.name.clone()));
if opts.dry_run {
miseprintln!("{}", sudo::argv("pacman", &args).join(" "));
return Ok(());
}
sudo::run("pacman", &args, &[])
}
fn supports_remove(&self) -> bool {
true
}
async fn remove(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> {
let names = concrete_remove_names(pkgs).await?;
let args = remove_args(&names);
if opts.dry_run {
miseprintln!("{}", sudo::argv("pacman", &args).join(" "));
return Ok(());
}
sudo::run("pacman", &args, &[])
}
async fn upgrade(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> {
let names = pkgs.iter().map(|pkg| pkg.name.clone()).collect::<Vec<_>>();
let stdout = pacman_query(&names).await?;
let installed_names = stdout
.lines()
.filter_map(|line| line.split_once(' ').map(|(name, _)| name))
.collect::<HashSet<_>>();
let pkgs = pkgs
.iter()
.filter(|pkg| installed_names.contains(pkg.name.as_str()))
.collect::<Vec<_>>();
let skipped = names.len() - pkgs.len();
if skipped > 0 {
warn!(
"pacman: {skipped} package(s) satisfied by an installed provider; skipping targeted upgrade"
);
}
if pkgs.is_empty() {
return Ok(());
}
self.refresh(opts)?;
let mut args = vec![
"-S".to_string(),
"--noconfirm".to_string(),
"--needed".to_string(),
"--".to_string(),
];
args.extend(pkgs.iter().map(|p| p.name.clone()));
if opts.dry_run {
miseprintln!("{}", sudo::argv("pacman", &args).join(" "));
return Ok(());
}
sudo::run("pacman", &args, &[])
}
}
#[cfg(test)]
mod tests {
use super::*;
fn req(name: &str, version: Option<&str>) -> PackageRequest {
PackageRequest {
name: name.to_string(),
version: version.map(str::to_string),
tap_url: None,
desired: crate::system::packages::PackageDesiredState::Present,
}
}
#[cfg(unix)]
#[tokio::test]
async fn test_pacman_query_file_advisory() {
let dir = tempfile::tempdir().unwrap();
let pacman = dir.path().join("pacman");
let mut env = crate::test::EnvVarGuard::new();
let path = std::env::join_paths(std::iter::once(dir.path().to_path_buf()).chain(
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()),
))
.unwrap();
env.set("PATH", path);
let missing = "error: package 'fish' was not found\n";
let advisory = "warning: 'fish' is a file, you might want to use -p/--file.\n";
let cases = [
(0, String::new(), true),
(0, advisory.to_string(), false),
(1, missing.to_string(), true),
(1, format!("{missing}{advisory}"), true),
(1, advisory.to_string(), false),
(
1,
format!("{missing}error: could not open database\n"),
false,
),
(
1,
format!("{missing}warning: 'git' is a file, you might want to use -p/--file.\n"),
false,
),
(2, format!("{missing}{advisory}"), false),
];
for (code, stderr, accepted) in cases {
std::fs::write(
&pacman,
format!(
"#!/bin/sh\n\
[ \"$LC_ALL\" = C ] || exit 99\n\
[ \"$*\" = '-Q -- curl fish' ] || exit 98\n\
printf 'curl 8.19.0-1\\n'\n\
cat >&2 <<'STDERR'\n{stderr}STDERR\n\
exit {code}\n"
),
)
.unwrap();
crate::file::make_executable(&pacman).unwrap();
let result = pacman_query(&["curl".to_string(), "fish".to_string()]).await;
if accepted {
assert_eq!(result.unwrap(), "curl 8.19.0-1\n");
} else {
assert!(result.is_err(), "accepted exit {code}: {stderr}");
}
}
}
#[test]
fn test_parse_pacman_query() {
let requests = vec![
req("bc", None),
req("nonexistent", None),
req("zsh", Some("5.9")),
req("tmux", Some("3.3")),
];
let output = "bc 1.08.2-1\nzsh 5.9-5\ntmux 3.4-2\n";
let statuses = parse_pacman_query(output, &requests);
assert_eq!(
statuses[0].state,
PackageState::Installed {
version: "1.08.2-1".to_string()
}
);
assert_eq!(statuses[1].state, PackageState::Missing);
assert_eq!(
statuses[2].state,
PackageState::Installed {
version: "5.9-5".to_string()
}
);
assert_eq!(
statuses[3].state,
PackageState::VersionMismatch {
installed: "3.4-2".to_string()
}
);
}
#[test]
fn test_apply_provider_query() {
let mut status = PackageStatus {
request: req("mariadb-clients", Some("12.3.2")),
state: PackageState::Missing,
};
let packages = parse_pacman_info(
"Name : percona-server-clients\n\
Version : 9.7.1_1-1\n\
Provides : mariadb-clients=12.3.2\n\
mysql-clients\n\
Description : database clients\n",
);
let provider = apply_provider_query(&mut status, &packages, true).unwrap();
assert_eq!(provider.name, "percona-server-clients");
assert!(
provider
.provides
.iter()
.any(|provide| provide.name == "mariadb-clients")
);
assert!(
provider
.provides
.iter()
.any(|provide| provide.name == "mysql-clients")
);
assert_eq!(
status.state,
PackageState::Installed {
version: "9.7.1_1-1".to_string()
}
);
}
#[test]
fn test_find_provider_prefers_exact_package_name() {
let packages = parse_pacman_info(
"Name : alternate-foo\n\
Version : 2.0-1\n\
Provides : foo\n\
\n\
Name : foo\n\
Version : 1.0-1\n\
Provides : None\n",
);
assert_eq!(
find_provider(&packages, &req("foo", None), true, |_| true)
.unwrap()
.name,
"foo"
);
}
#[test]
fn test_find_provider_matches_virtual_version() {
let packages = parse_pacman_info(
"Name : foo-one\n\
Version : 10.0-1\n\
Provides : foo=1\n\
\n\
Name : foo-two\n\
Version : 20.0-1\n\
Provides : foo=2.0-13\n",
);
assert_eq!(
find_provider(&packages, &req("foo", Some("2.0")), true, |_| true)
.unwrap()
.name,
"foo-two"
);
}
#[test]
fn test_find_provider_filters_ineligible_exact_match() {
let packages = parse_pacman_info(
"Name : foo\n\
Version : 1.0-1\n\
Provides : None\n\
\n\
Name : aur-foo\n\
Version : 2.0-1\n\
Provides : foo\n",
);
assert_eq!(
find_provider(&packages, &req("foo", None), true, |package| {
package.name == "aur-foo"
})
.unwrap()
.name,
"aur-foo"
);
}
#[test]
fn test_matching_provider_names_returns_every_provider() {
let packages = parse_pacman_info(
"Name : foo-one\n\
Version : 1.0-1\n\
Provides : foo\n\
\n\
Name : foo-two\n\
Version : 2.0-1\n\
Provides : foo\n",
);
assert_eq!(
matching_provider_names(&packages, "foo"),
["foo-one", "foo-two"]
);
}
#[test]
fn test_remove_args_remove_exact_packages_without_cascading() {
assert_eq!(
remove_args(&["omarchy".to_string(), "omarchy-settings".to_string()]),
["-R", "--noconfirm", "--", "omarchy", "omarchy-settings"]
);
}
#[test]
fn test_apply_provider_query_version_mismatch() {
let mut status = PackageStatus {
request: req("virtual-package", Some("2.0")),
state: PackageState::Missing,
};
let packages = parse_pacman_info(
"Name : provider-package\n\
Version : 2.0-1\n\
Provides : virtual-package=1.0\n",
);
apply_provider_query(&mut status, &packages, false).unwrap();
assert_eq!(
status.state,
PackageState::VersionMismatch {
installed: "2.0-1".to_string()
}
);
}
#[test]
fn test_deptest_requirement_includes_version() {
assert_eq!(
deptest_requirement(&req("virtual-package", Some("2.0"))),
"virtual-package=2.0"
);
assert_eq!(
deptest_requirement(&req("virtual-package", None)),
"virtual-package"
);
}
#[test]
fn test_parse_pacman_deptest() {
let missing = parse_pacman_deptest("missing-one\nmissing-two\n");
assert_eq!(missing, HashSet::from(["missing-one", "missing-two"]));
}
}