use std::collections::HashSet;
use std::hash::BuildHasher;
use crate::error::{ArchToolkitError, Result};
use crate::types::dependency::{PackageRef, PackageSource};
use crate::types::install::{AurHelper, CommandSpec, InstallOptions, PrivilegeTool};
use super::command::{build_aur_install, build_pacman_install, with_privilege};
#[derive(Clone, Debug, Default)]
pub struct InstallPlan {
pub commands: Vec<CommandSpec>,
pub official: Vec<String>,
pub aur: Vec<String>,
}
impl InstallPlan {
#[must_use]
pub fn to_shell_string(&self) -> String {
self.commands
.iter()
.map(CommandSpec::to_shell_string)
.collect::<Vec<_>>()
.join(" && ")
}
}
pub fn build_batch_install<S: BuildHasher>(
targets: &[PackageRef],
helper: Option<AurHelper>,
privilege: Option<PrivilegeTool>,
options: &InstallOptions,
installed: Option<&HashSet<String, S>>,
) -> Result<InstallPlan> {
let mut official: Vec<String> = Vec::new();
let mut aur: Vec<String> = Vec::new();
for target in targets {
match &target.source {
PackageSource::Official { .. } => official.push(target.name.clone()),
PackageSource::Aur => aur.push(target.name.clone()),
}
}
let mut commands = Vec::new();
if !official.is_empty() {
let opts = group_options(*options, &official, installed);
let mut spec = build_pacman_install(&official, &opts)?;
if let Some(tool) = privilege {
spec = with_privilege(tool, spec);
}
commands.push(spec);
}
if !aur.is_empty() {
let Some(helper) = helper else {
return Err(ArchToolkitError::InvalidInput(format!(
"{} AUR target(s) present but no AUR helper provided; \
pass one explicitly or use detect_aur_helper()",
aur.len()
)));
};
let opts = group_options(*options, &aur, installed);
commands.push(build_aur_install(helper, &aur, &opts)?);
}
Ok(InstallPlan {
commands,
official,
aur,
})
}
fn group_options<S: BuildHasher>(
options: InstallOptions,
names: &[String],
installed: Option<&HashSet<String, S>>,
) -> InstallOptions {
let has_reinstall = installed.is_some_and(|set| names.iter().any(|name| set.contains(name)));
InstallOptions {
needed: options.needed && !has_reinstall,
..options
}
}
#[cfg(test)]
mod tests {
use super::*;
fn official(name: &str) -> PackageRef {
PackageRef::official(name, "1.0", "extra", "x86_64")
}
fn aur_pkg(name: &str) -> PackageRef {
PackageRef::aur(name, "1.0")
}
const NO_INSTALLED: Option<&HashSet<String>> = None;
#[test]
fn official_only_batch() {
let targets = vec![official("a"), official("b")];
let plan = build_batch_install(
&targets,
None,
Some(PrivilegeTool::Sudo),
&InstallOptions::default(),
NO_INSTALLED,
)
.expect("plan");
assert_eq!(plan.commands.len(), 1);
assert_eq!(
plan.commands[0].to_shell_string(),
"sudo pacman -S --needed --noconfirm -- a b"
);
assert_eq!(
plan.commands[0].args,
["pacman", "-S", "--needed", "--noconfirm", "--", "a", "b"]
);
assert_eq!(plan.official, ["a", "b"]);
assert!(plan.aur.is_empty());
}
#[test]
fn aur_only_batch_never_privileged() {
let targets = vec![aur_pkg("x"), aur_pkg("y")];
let plan = build_batch_install(
&targets,
Some(AurHelper::Paru),
Some(PrivilegeTool::Sudo),
&InstallOptions::default(),
NO_INSTALLED,
)
.expect("plan");
assert_eq!(plan.commands.len(), 1);
assert_eq!(
plan.commands[0].to_shell_string(),
"paru -S --aur --needed --noconfirm -- x y"
);
}
#[test]
fn batch_rejects_option_like_names() {
for evil in ["--help", "-S", ".hidden"] {
for target in [official(evil), aur_pkg(evil)] {
let err = build_batch_install(
std::slice::from_ref(&target),
Some(AurHelper::Paru),
Some(PrivilegeTool::Sudo),
&InstallOptions::default(),
NO_INSTALLED,
)
.expect_err("batch should reject option-like names");
assert!(matches!(err, ArchToolkitError::InvalidPackageName { .. }));
}
}
}
#[test]
fn mixed_batch_ordering() {
let targets = vec![aur_pkg("helper-pkg"), official("base-pkg")];
let plan = build_batch_install(
&targets,
Some(AurHelper::Yay),
Some(PrivilegeTool::Doas),
&InstallOptions::default(),
NO_INSTALLED,
)
.expect("plan");
assert_eq!(plan.commands.len(), 2);
assert!(
plan.commands[0]
.to_shell_string()
.starts_with("doas pacman")
);
assert!(
plan.commands[1]
.to_shell_string()
.starts_with("yay -S --aur")
);
}
#[test]
fn reinstall_detection_per_group() {
let targets = vec![official("vim"), aur_pkg("fresh-pkg")];
let installed: HashSet<String> = HashSet::from(["vim".to_string()]);
let plan = build_batch_install(
&targets,
Some(AurHelper::Paru),
None,
&InstallOptions::default(),
Some(&installed),
)
.expect("plan");
assert_eq!(
plan.commands[0].to_shell_string(),
"pacman -S --noconfirm -- vim"
);
assert_eq!(
plan.commands[1].to_shell_string(),
"paru -S --aur --needed --noconfirm -- fresh-pkg"
);
}
#[test]
fn aur_without_helper_errors() {
let targets = vec![aur_pkg("x")];
let err = build_batch_install(
&targets,
None,
None,
&InstallOptions::default(),
NO_INSTALLED,
)
.expect_err("should fail");
assert!(matches!(err, ArchToolkitError::InvalidInput(_)));
assert!(err.to_string().contains("AUR helper"));
}
#[test]
fn empty_targets_empty_plan() {
let plan = build_batch_install(&[], None, None, &InstallOptions::default(), NO_INSTALLED)
.expect("plan");
assert!(plan.commands.is_empty());
assert!(plan.official.is_empty());
assert!(plan.aur.is_empty());
}
}