use std::ffi::OsString;
use std::path::PathBuf;
use crate::runtime::{Confirm, Handoff, HandoffOutcome};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Manager {
Pacman,
Apt,
Dnf,
Zypper,
Apk,
Brew,
}
impl Manager {
#[must_use]
pub fn all() -> &'static [Manager] {
&[Manager::Pacman, Manager::Apt, Manager::Dnf, Manager::Zypper, Manager::Apk, Manager::Brew]
}
#[must_use]
pub fn name(self) -> &'static str {
match self {
Manager::Pacman => "pacman",
Manager::Apt => "apt-get",
Manager::Dnf => "dnf",
Manager::Zypper => "zypper",
Manager::Apk => "apk",
Manager::Brew => "brew",
}
}
fn install_words(self) -> &'static [&'static str] {
match self {
Manager::Pacman => &["-S", "--needed"],
Manager::Apt | Manager::Dnf | Manager::Zypper | Manager::Brew => &["install"],
Manager::Apk => &["add"],
}
}
fn needs_root(self) -> bool {
self != Manager::Brew
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Install {
manager: Manager,
package: String,
root: bool,
}
impl Install {
#[must_use]
pub fn package(name: &str) -> Option<Install> {
let path = std::env::var_os("PATH");
let lookup = |program: &str| {
crate::desktop::program::find_program(program, path.as_deref(), crate::desktop::program::is_executable)
};
Self::package_with(name, lookup, running_as_root())
}
#[must_use]
pub fn package_with(name: &str, lookup: impl Fn(&str) -> Option<PathBuf>, root: bool) -> Option<Install> {
if !is_package_name(name) {
return None;
}
let manager = Manager::all()
.iter()
.copied()
.filter(|manager| !(root && *manager == Manager::Brew))
.find(|manager| lookup(manager.name()).is_some())?;
Some(Install { manager, package: name.to_owned(), root })
}
#[must_use]
pub fn name_for(mut self, manager: Manager, name: &str) -> Self {
if manager == self.manager && is_package_name(name) {
name.clone_into(&mut self.package);
}
self
}
#[must_use]
pub fn manager(&self) -> Manager {
self.manager
}
#[must_use]
pub fn name(&self) -> &str {
&self.package
}
#[must_use]
pub fn program(&self) -> Vec<OsString> {
self.words().into_iter().map(OsString::from).collect()
}
#[must_use]
pub fn command_line(&self) -> String {
self.words().join(" ")
}
#[must_use]
pub fn confirm<Msg>(&self, on_yes: Msg) -> Confirm<Msg> {
let manager = self.manager.name();
let message = format!(
"{}\n\n{}\n\n{}",
crate::t!("quvyta.install.runs"),
self.command_line(),
crate::t!("quvyta.install.asks", manager = manager),
);
Confirm::new(crate::t!("quvyta.install.title", package = self.package.as_str()), on_yes)
.message(message)
.confirm_label(crate::t!("quvyta.install.confirm"))
}
#[must_use]
pub fn handoff<Msg: Send + 'static>(
&self,
on_finish: impl FnOnce(HandoffOutcome) -> Msg + Send + 'static,
) -> Handoff<Msg> {
let words = self.words();
let notice = crate::t!("quvyta.install.notice", package = self.package.as_str(), manager = self.manager.name());
Handoff::new(words[0], on_finish).args(words[1..].iter().copied()).notice(notice).pause(true)
}
fn words(&self) -> Vec<&str> {
let mut words = Vec::with_capacity(5);
if !self.root && self.manager.needs_root() {
words.push("sudo");
}
words.push(self.manager.name());
words.extend_from_slice(self.manager.install_words());
words.push(&self.package);
words
}
}
fn is_package_name(name: &str) -> bool {
!name.is_empty() && !name.starts_with('-') && !name.chars().any(|c| c.is_whitespace() || c.is_control())
}
fn running_as_root() -> bool {
#[cfg(unix)]
{
rustix::process::geteuid().is_root()
}
#[cfg(not(unix))]
{
false
}
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::path::PathBuf;
use std::time::Duration;
use super::{Install, Manager};
use crate::runtime::{App, Command, HandoffOutcome, Harness};
use crate::widget::View;
use crate::widgets::{Button, Text};
fn machine(programs: &'static [&'static str]) -> impl Fn(&str) -> Option<PathBuf> {
move |program| programs.contains(&program).then(|| PathBuf::from("/usr/bin").join(program))
}
fn words(line: &str) -> Vec<OsString> {
line.split(' ').map(OsString::from).collect()
}
#[test]
fn without_a_package_manager_there_is_nothing_to_offer() {
assert_eq!(Install::package_with("libarchive", machine(&["ls", "paru", "yay"]), false), None);
}
#[test]
fn pacman_installs_with_sudo_and_only_what_is_missing() {
let install = Install::package_with("libarchive", machine(&["pacman"]), false).expect("pacman");
assert_eq!(install.manager(), Manager::Pacman);
assert_eq!(install.command_line(), "sudo pacman -S --needed libarchive");
assert_eq!(install.program(), words("sudo pacman -S --needed libarchive"));
}
#[test]
fn as_root_the_command_has_no_sudo() {
let install = Install::package_with("libarchive", machine(&["pacman"]), true).expect("pacman");
assert_eq!(install.command_line(), "pacman -S --needed libarchive");
assert_eq!(install.program()[0], OsString::from("pacman"));
}
#[test]
fn apt_get_installs_and_asks_its_own_question() {
let install = Install::package_with("bsdtar", machine(&["apt-get"]), false).expect("apt-get");
assert_eq!(install.command_line(), "sudo apt-get install bsdtar");
}
#[test]
fn every_manager_has_its_own_command() {
let cases: [(&'static [&'static str], &str); 4] = [
(&["dnf"], "sudo dnf install bsdtar"),
(&["zypper"], "sudo zypper install bsdtar"),
(&["apk"], "sudo apk add bsdtar"),
(&["brew"], "brew install bsdtar"),
];
for (programs, line) in cases {
let install = Install::package_with("bsdtar", machine(programs), false).expect("a manager");
assert_eq!(install.command_line(), line);
}
}
#[test]
fn the_first_manager_in_the_order_wins() {
let install = Install::package_with("bsdtar", machine(&["brew", "dnf", "apt-get"]), false).expect("a manager");
assert_eq!(install.manager(), Manager::Apt, "apt-get comes before dnf and brew");
}
#[test]
fn homebrew_is_passed_over_as_root() {
assert_eq!(Install::package_with("bsdtar", machine(&["brew"]), true), None);
let install = Install::package_with("bsdtar", machine(&["brew"]), false).expect("brew");
assert_eq!(install.manager(), Manager::Brew);
}
#[test]
fn a_distribution_specific_name_is_used_on_its_manager_only() {
let named = |programs| {
Install::package_with("libarchive", machine(programs), false)
.expect("a manager")
.name_for(Manager::Apt, "libarchive-tools")
.name_for(Manager::Dnf, "bsdtar")
};
assert_eq!(named(&["apt-get"]).command_line(), "sudo apt-get install libarchive-tools");
assert_eq!(named(&["dnf"]).name(), "bsdtar");
assert_eq!(named(&["pacman"]).command_line(), "sudo pacman -S --needed libarchive");
}
#[test]
fn a_name_that_is_not_one_package_is_refused() {
for name in ["", "-Syu", "libarchive; rm", "a\tb", "a\nb"] {
assert_eq!(Install::package_with(name, machine(&["pacman"]), false), None, "{name:?}");
}
let kept = Install::package_with("libarchive", machine(&["apt-get"]), false)
.expect("apt-get")
.name_for(Manager::Apt, "--force");
assert_eq!(kept.name(), "libarchive", "an option is never taken for a name");
}
struct Missing {
install: Install,
outcomes: Vec<HandoffOutcome>,
}
#[derive(Clone)]
enum Msg {
Ask,
Install,
Installed(HandoffOutcome),
}
impl App for Missing {
type Msg = Msg;
fn update(&mut self, msg: Msg) -> Command<Msg> {
match msg {
Msg::Ask => Command::confirm(self.install.confirm(Msg::Install)),
Msg::Install => Command::handoff(self.install.handoff(Msg::Installed)),
Msg::Installed(outcome) => {
self.outcomes.push(outcome);
Command::none()
}
}
}
fn view(&self, ui: &mut View<'_, Msg>) {
ui.column(|ui| {
ui.add(Text::new("bsdtar is missing"));
ui.add(Button::new("Install").on_press(Msg::Ask)).id("install");
});
}
}
fn missing() -> Harness<Missing> {
let install = Install::package_with("libarchive", machine(&["pacman"]), false).expect("pacman");
Harness::new(Missing { install, outcomes: Vec::new() }, 72, 16)
}
fn ask(h: &mut Harness<Missing>) {
h.click_text("Install").advance(Duration::from_millis(200));
}
#[test]
fn the_question_shows_the_command_and_nothing_runs_before_the_answer() {
let mut h = missing();
ask(&mut h);
let screen = h.screen();
assert!(screen.contains("Install libarchive?"), "{screen}");
assert!(screen.contains("sudo pacman -S --needed libarchive"), "the exact command is shown: {screen}");
assert!(screen.contains("pacman may ask for your password"), "{screen}");
assert!(h.handoffs().is_empty(), "nothing is handed over before the person answers");
}
#[test]
fn escape_installs_nothing() {
let mut h = missing();
ask(&mut h);
h.press("esc");
assert!(!h.screen().contains("Install libarchive?"), "{}", h.screen());
assert!(h.handoffs().is_empty());
assert!(h.app().outcomes.is_empty());
}
#[test]
fn confirming_hands_the_terminal_to_the_package_manager_once() {
let mut h = missing();
ask(&mut h);
let screen = h.screen();
let (cancel_x, row) = h.find("Cancel").unwrap_or_else(|| panic!("an answer row: {screen}"));
let line = screen.lines().nth(usize::try_from(row).unwrap_or(0)).unwrap_or_default();
let after_cancel = line.chars().skip(usize::try_from(cancel_x).unwrap_or(0)).collect::<String>();
let offset = after_cancel.find("Install").unwrap_or_else(|| panic!("Install beside Cancel: {line}"));
let x = cancel_x + i32::try_from(after_cancel[..offset].chars().count()).unwrap_or(0);
h.click(x + 1, row);
let asked = h.handoffs();
assert_eq!(asked.len(), 1, "exactly one handoff");
assert_eq!(asked[0].program, OsString::from("sudo"));
assert_eq!(asked[0].args, words("pacman -S --needed libarchive"));
assert!(asked[0].pause, "the manager's last lines stay until a key is pressed");
assert_eq!(asked[0].notice.as_deref(), Some("Installing libarchive with pacman…"));
assert_eq!(h.app().outcomes, [HandoffOutcome::Finished { code: Some(0) }], "the outcome comes back");
}
}