use crate::output;
use anyhow::{bail, Context, Result};
use std::env;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoPackage {
pub repo: String,
pub name: String,
pub version: String,
pub desc: Option<String>,
pub installed: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Toolchain {
pub pacman: String,
pub pacman_key: String,
pub makepkg: String,
pub git: String,
pub gpg: String,
pub reflector: String,
pub sudo: String,
pub build_dir: PathBuf,
}
pub(crate) trait PackageBackend {
fn build_dir(&self) -> &Path;
async fn system_upgrade(
&self,
refresh: bool,
sysupgrade: bool,
no_confirm: bool,
dry_run: bool,
) -> Result<i32>;
async fn install_repo_packages(
&self,
packages: &[String],
needed: bool,
no_confirm: bool,
as_deps: bool,
dry_run: bool,
) -> Result<i32>;
async fn sync_git_repo(&self, url: &str, dest: &Path, dry_run: bool) -> Result<i32>;
async fn build_aur_package(
&self,
dir: &Path,
no_confirm: bool,
no_check: bool,
dry_run: bool,
) -> Result<i32>;
async fn package_list(&self, dir: &Path) -> Result<Vec<PathBuf>>;
async fn install_local_packages(
&self,
packages: &[PathBuf],
needed: bool,
no_confirm: bool,
dry_run: bool,
) -> Result<i32>;
async fn pacman_database_check(&self, dry_run: bool) -> Result<i32>;
async fn query_installed_version(&self, package: &str) -> Result<Option<String>>;
}
impl PackageBackend for Toolchain {
fn build_dir(&self) -> &Path {
&self.build_dir
}
async fn system_upgrade(
&self,
refresh: bool,
sysupgrade: bool,
no_confirm: bool,
dry_run: bool,
) -> Result<i32> {
Toolchain::system_upgrade(self, refresh, sysupgrade, no_confirm, dry_run).await
}
async fn install_repo_packages(
&self,
packages: &[String],
needed: bool,
no_confirm: bool,
as_deps: bool,
dry_run: bool,
) -> Result<i32> {
Toolchain::install_repo_packages(self, packages, needed, no_confirm, as_deps, dry_run).await
}
async fn sync_git_repo(&self, url: &str, dest: &Path, dry_run: bool) -> Result<i32> {
Toolchain::sync_git_repo(self, url, dest, dry_run).await
}
async fn build_aur_package(
&self,
dir: &Path,
no_confirm: bool,
no_check: bool,
dry_run: bool,
) -> Result<i32> {
Toolchain::build_aur_package(self, dir, no_confirm, no_check, dry_run).await
}
async fn package_list(&self, dir: &Path) -> Result<Vec<PathBuf>> {
Toolchain::package_list(self, dir).await
}
async fn install_local_packages(
&self,
packages: &[PathBuf],
needed: bool,
no_confirm: bool,
dry_run: bool,
) -> Result<i32> {
Toolchain::install_local_packages(self, packages, needed, no_confirm, dry_run).await
}
async fn pacman_database_check(&self, dry_run: bool) -> Result<i32> {
Toolchain::pacman_database_check(self, dry_run).await
}
async fn query_installed_version(&self, package: &str) -> Result<Option<String>> {
Toolchain::query_installed_version(self, package).await
}
}
impl Toolchain {
pub fn from_env() -> Self {
Self {
pacman: env::var("KNOTT_PACMAN").unwrap_or_else(|_| "pacman".to_string()),
pacman_key: env::var("KNOTT_PACMAN_KEY").unwrap_or_else(|_| "pacman-key".to_string()),
makepkg: env::var("KNOTT_MAKEPKG").unwrap_or_else(|_| "makepkg".to_string()),
git: env::var("KNOTT_GIT").unwrap_or_else(|_| "git".to_string()),
gpg: env::var("KNOTT_GPG").unwrap_or_else(|_| "gpg".to_string()),
reflector: env::var("KNOTT_REFLECTOR").unwrap_or_else(|_| "reflector".to_string()),
sudo: env::var("KNOTT_SUDO").unwrap_or_else(|_| "sudo".to_string()),
build_dir: env::var_os("KNOTT_BUILDDIR")
.map(PathBuf::from)
.unwrap_or_else(default_build_dir),
}
}
pub async fn forward(&self, args: &[String]) -> Result<i32> {
self.run_interactive(&self.pacman, args, None).await
}
pub async fn system_upgrade(
&self,
refresh: bool,
sysupgrade: bool,
no_confirm: bool,
dry_run: bool,
) -> Result<i32> {
let mut args = Vec::new();
let mut op = String::from("-S");
if refresh {
op.push('y');
}
if sysupgrade {
op.push('u');
}
args.push(op);
if no_confirm {
args.push("--noconfirm".to_string());
}
self.run_root_interactive(&self.pacman, &args, dry_run)
.await
}
pub async fn force_system_upgrade(&self, no_confirm: bool, dry_run: bool) -> Result<i32> {
let mut args = vec!["-Syyu".to_string()];
if no_confirm {
args.push("--noconfirm".to_string());
}
self.run_root_interactive(&self.pacman, &args, dry_run)
.await
}
pub async fn install_keyring_packages(
&self,
packages: &[String],
no_confirm: bool,
dry_run: bool,
) -> Result<i32> {
if packages.is_empty() {
return Ok(0);
}
let mut args = vec!["-Sy".to_string(), "--needed".to_string()];
if no_confirm {
args.push("--noconfirm".to_string());
}
args.push("--".to_string());
args.extend(packages.iter().cloned());
self.run_root_interactive(&self.pacman, &args, dry_run)
.await
}
pub async fn install_repo_packages(
&self,
packages: &[String],
needed: bool,
no_confirm: bool,
as_deps: bool,
dry_run: bool,
) -> Result<i32> {
if packages.is_empty() {
return Ok(0);
}
let mut args = vec!["-S".to_string()];
if needed {
args.push("--needed".to_string());
}
if no_confirm {
args.push("--noconfirm".to_string());
}
if as_deps {
args.push("--asdeps".to_string());
}
args.push("--".to_string());
args.extend(packages.iter().cloned());
self.run_root_interactive(&self.pacman, &args, dry_run)
.await
}
pub async fn build_aur_package(
&self,
dir: &Path,
no_confirm: bool,
no_check: bool,
dry_run: bool,
) -> Result<i32> {
if is_root() {
bail!("refusing to run makepkg as root");
}
let mut args = vec!["-s".to_string()];
if no_confirm {
args.push("--noconfirm".to_string());
}
if no_check {
args.push("--nocheck".to_string());
}
if dry_run {
let line = format!(
"dry-run: cd {} && {} {}",
dir.display(),
self.makepkg,
args.join(" ")
);
output::line(line);
return Ok(0);
}
self.run_interactive(&self.makepkg, &args, Some(dir)).await
}
pub async fn package_list(&self, dir: &Path) -> Result<Vec<PathBuf>> {
let output = self
.output(&self.makepkg, ["--packagelist"], Some(dir))
.await?;
if !output.status.success() {
bail!("makepkg --packagelist failed in {}", dir.display());
}
let mut packages = Vec::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let path = PathBuf::from(line);
let path = if path.is_absolute() {
path
} else {
dir.join(path)
};
if !path.exists() {
bail!("package artifact not found after build: {}", path.display());
}
packages.push(path);
}
if packages.is_empty() {
bail!(
"makepkg --packagelist found no package artifacts in {}",
dir.display()
);
}
Ok(packages)
}
pub async fn install_local_packages(
&self,
packages: &[PathBuf],
needed: bool,
no_confirm: bool,
dry_run: bool,
) -> Result<i32> {
if packages.is_empty() {
return Ok(0);
}
let mut args = vec!["-U".to_string()];
if needed {
args.push("--needed".to_string());
}
if no_confirm {
args.push("--noconfirm".to_string());
}
args.push("--".to_string());
args.extend(packages.iter().map(|path| path.display().to_string()));
self.run_root_interactive(&self.pacman, &args, dry_run)
.await
}
pub async fn populate_pacman_keys(&self, dry_run: bool) -> Result<i32> {
let args = vec!["--populate".to_string(), "archlinux".to_string()];
self.run_root_interactive(&self.pacman_key, &args, dry_run)
.await
}
pub async fn init_pacman_keys(&self, dry_run: bool) -> Result<i32> {
let args = vec!["--init".to_string()];
self.run_root_interactive(&self.pacman_key, &args, dry_run)
.await
}
pub async fn update_pacman_key_database(&self, dry_run: bool) -> Result<i32> {
let args = vec!["--updatedb".to_string()];
self.run_root_interactive(&self.pacman_key, &args, dry_run)
.await
}
pub async fn update_mirrors(&self, country: &str, dry_run: bool) -> Result<i32> {
let args = reflector_args(country);
self.run_root_interactive(&self.reflector, &args, dry_run)
.await
}
pub async fn import_gpg_keys(&self, keys: &[String], dry_run: bool) -> Result<i32> {
if keys.is_empty() {
return Ok(0);
}
let args = gpg_recv_args(keys);
if dry_run {
output::line(format!("dry-run: {} {}", self.gpg, args.join(" ")));
return Ok(0);
}
self.run_interactive(&self.gpg, &args, None).await
}
pub async fn pacman_database_check(&self, dry_run: bool) -> Result<i32> {
let args = vec!["-Dk".to_string()];
if dry_run {
output::line(format!("dry-run: {} {}", self.pacman, args.join(" ")));
return Ok(0);
}
self.run_interactive(&self.pacman, &args, None).await
}
pub async fn query_installed_version(&self, package: &str) -> Result<Option<String>> {
let output = self
.output(&self.pacman, ["-Q", "--", package], None)
.await?;
if !output.status.success() {
return Ok(None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let Some(line) = stdout.lines().next() else {
return Ok(None);
};
let mut parts = line.split_whitespace();
if parts.next() == Some(package) {
Ok(parts.next().map(|version| version.to_string()))
} else {
Ok(None)
}
}
pub async fn repo_has_package(&self, name: &str) -> bool {
let args = ["-Si", "--", name];
self.output(&self.pacman, args, None)
.await
.is_ok_and(|output| output.status.success())
}
pub async fn repo_info(&self, name: &str) -> Result<Option<String>> {
let args = ["-Si", "--", name];
let output = match self.output(&self.pacman, args, None).await {
Ok(output) => output,
Err(err) if is_not_found(&err) => return Ok(None),
Err(err) => return Err(err),
};
if !output.status.success() {
return Ok(None);
}
Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
}
pub async fn search_repo(&self, terms: &[String]) -> Result<Vec<RepoPackage>> {
if terms.is_empty() {
return Ok(Vec::new());
}
let mut args = vec![
"-Ss".to_string(),
"--color=never".to_string(),
"--".to_string(),
];
args.extend(terms.iter().cloned());
let output = match self.output(&self.pacman, &args, None).await {
Ok(output) => output,
Err(err) if is_not_found(&err) => return Ok(Vec::new()),
Err(err) => return Err(err),
};
if !output.status.success() {
return Ok(Vec::new());
}
Ok(parse_pacman_search(&String::from_utf8_lossy(
&output.stdout,
)))
}
pub async fn foreign_names(&self) -> Result<Vec<String>> {
let args = ["-Qmq"];
let output = self.output(&self.pacman, args, None).await?;
if !output.status.success() {
return Ok(Vec::new());
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string())
.collect())
}
pub async fn sync_git_repo(&self, url: &str, dest: &Path, dry_run: bool) -> Result<i32> {
if dry_run {
if dest.join(".git").is_dir() {
let line = format!("dry-run: {} -C {} pull --ff-only", self.git, dest.display());
output::line(line);
} else {
let line = format!(
"dry-run: {} clone --depth 1 {} {}",
self.git,
url,
dest.display()
);
output::line(line);
}
return Ok(0);
}
if dest.join(".git").is_dir() {
let args = vec![
"-C".to_string(),
dest.display().to_string(),
"pull".to_string(),
"--ff-only".to_string(),
];
self.run_interactive(&self.git, &args, None).await
} else {
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let args = vec![
"clone".to_string(),
"--depth".to_string(),
"1".to_string(),
url.to_string(),
dest.display().to_string(),
];
self.run_interactive(&self.git, &args, None).await
}
}
pub async fn run_interactive(
&self,
program: &str,
args: &[String],
cwd: Option<&Path>,
) -> Result<i32> {
let mut command = Command::new(program);
command.args(args);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
command
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let status = command
.status()
.await
.with_context(|| format!("run {program}"))?;
Ok(status.code().unwrap_or(1))
}
async fn run_root_interactive(
&self,
program: &str,
args: &[String],
dry_run: bool,
) -> Result<i32> {
let (program, final_args) = root_command(&self.sudo, program, args);
if dry_run {
let line = format!("dry-run: {} {}", program, final_args.join(" "));
output::line(line);
return Ok(0);
}
self.run_interactive(&program, &final_args, None).await
}
async fn output<I, S>(
&self,
program: &str,
args: I,
cwd: Option<&Path>,
) -> Result<std::process::Output>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut command = Command::new(program);
command.args(args);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
command
.output()
.await
.with_context(|| format!("run {program}"))
}
}
fn root_command(sudo: &str, program: &str, args: &[String]) -> (String, Vec<String>) {
if is_root() {
(program.to_string(), args.to_vec())
} else {
let mut final_args = vec![program.to_string()];
final_args.extend(args.iter().cloned());
(sudo.to_string(), final_args)
}
}
fn reflector_args(country: &str) -> Vec<String> {
vec![
"--country".to_string(),
country.to_string(),
"--protocol".to_string(),
"https".to_string(),
"--age".to_string(),
"12".to_string(),
"--completion-percent".to_string(),
"100".to_string(),
"--latest".to_string(),
"20".to_string(),
"--sort".to_string(),
"rate".to_string(),
"--save".to_string(),
"/etc/pacman.d/mirrorlist".to_string(),
]
}
fn gpg_recv_args(keys: &[String]) -> Vec<String> {
let mut args = vec!["--recv-keys".to_string()];
args.extend(keys.iter().cloned());
args
}
fn default_build_dir() -> PathBuf {
if let Some(cache_home) = env::var_os("XDG_CACHE_HOME") {
return PathBuf::from(cache_home).join("knott").join("build");
}
env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".cache")
.join("knott")
.join("build")
}
fn is_root() -> bool {
#[cfg(unix)]
unsafe {
libc::geteuid() == 0
}
#[cfg(not(unix))]
{
false
}
}
fn is_not_found(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
})
}
fn parse_pacman_search(output: &str) -> Vec<RepoPackage> {
let mut packages = Vec::new();
let mut current: Option<RepoPackage> = None;
for line in output.lines() {
if line.starts_with(' ') || line.starts_with('\t') {
if let Some(pkg) = current.as_mut() {
let desc = line.trim();
if !desc.is_empty() {
pkg.desc = Some(desc.to_string());
}
}
continue;
}
if let Some(pkg) = current.take() {
packages.push(pkg);
}
let Some((repo_name, rest)) = line.split_once('/') else {
continue;
};
let mut parts = rest.split_whitespace();
let Some(name) = parts.next() else {
continue;
};
let Some(version) = parts.next() else {
continue;
};
let installed = line
.split('[')
.find_map(|part| part.strip_suffix(']'))
.filter(|tag| tag.starts_with("installed"))
.map(|tag| tag.to_string());
current = Some(RepoPackage {
repo: repo_name.to_string(),
name: name.to_string(),
version: version.to_string(),
desc: None,
installed,
});
}
if let Some(pkg) = current.take() {
packages.push(pkg);
}
packages
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_pacman_search_output() {
let output = "core/pacman 6.1.0-1 [installed]\n A library-based package manager\nextra/ripgrep 14.1.0-1\n A search tool\n";
let parsed = parse_pacman_search(output);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].repo, "core");
assert_eq!(parsed[0].name, "pacman");
assert_eq!(
parsed[0].desc.as_deref(),
Some("A library-based package manager")
);
assert!(parsed[0].installed.is_some());
}
#[test]
fn builds_reflector_args() {
assert_eq!(
reflector_args("United States"),
[
"--country",
"United States",
"--protocol",
"https",
"--age",
"12",
"--completion-percent",
"100",
"--latest",
"20",
"--sort",
"rate",
"--save",
"/etc/pacman.d/mirrorlist"
]
);
}
#[test]
fn builds_gpg_recv_args() {
assert_eq!(
gpg_recv_args(&["ABCD1234".into(), "432705FACDD40325".into()]),
["--recv-keys", "ABCD1234", "432705FACDD40325"]
);
}
}