1use std::ffi::OsString;
44use std::path::PathBuf;
45
46use crate::runtime::{Confirm, Handoff, HandoffOutcome};
47
48#[non_exhaustive]
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum Manager {
56 Pacman,
58 Apt,
60 Dnf,
62 Zypper,
64 Apk,
66 Brew,
68}
69
70impl Manager {
71 #[must_use]
73 pub fn all() -> &'static [Manager] {
74 &[Manager::Pacman, Manager::Apt, Manager::Dnf, Manager::Zypper, Manager::Apk, Manager::Brew]
75 }
76
77 #[must_use]
80 pub fn name(self) -> &'static str {
81 match self {
82 Manager::Pacman => "pacman",
83 Manager::Apt => "apt-get",
84 Manager::Dnf => "dnf",
85 Manager::Zypper => "zypper",
86 Manager::Apk => "apk",
87 Manager::Brew => "brew",
88 }
89 }
90
91 fn install_words(self) -> &'static [&'static str] {
94 match self {
95 Manager::Pacman => &["-S", "--needed"],
96 Manager::Apt | Manager::Dnf | Manager::Zypper | Manager::Brew => &["install"],
97 Manager::Apk => &["add"],
98 }
99 }
100
101 fn needs_root(self) -> bool {
104 self != Manager::Brew
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct Install {
132 manager: Manager,
133 package: String,
134 root: bool,
135}
136
137impl Install {
138 #[must_use]
144 pub fn package(name: &str) -> Option<Install> {
145 let path = std::env::var_os("PATH");
146 let lookup = |program: &str| {
147 crate::desktop::program::find_program(program, path.as_deref(), crate::desktop::program::is_executable)
148 };
149 Self::package_with(name, lookup, running_as_root())
150 }
151
152 #[must_use]
156 pub fn package_with(name: &str, lookup: impl Fn(&str) -> Option<PathBuf>, root: bool) -> Option<Install> {
157 if !is_package_name(name) {
158 return None;
159 }
160 let manager = Manager::all()
161 .iter()
162 .copied()
163 .filter(|manager| !(root && *manager == Manager::Brew))
164 .find(|manager| lookup(manager.name()).is_some())?;
165 Some(Install { manager, package: name.to_owned(), root })
166 }
167
168 #[must_use]
172 pub fn name_for(mut self, manager: Manager, name: &str) -> Self {
173 if manager == self.manager && is_package_name(name) {
174 name.clone_into(&mut self.package);
175 }
176 self
177 }
178
179 #[must_use]
181 pub fn manager(&self) -> Manager {
182 self.manager
183 }
184
185 #[must_use]
187 pub fn name(&self) -> &str {
188 &self.package
189 }
190
191 #[must_use]
193 pub fn program(&self) -> Vec<OsString> {
194 self.words().into_iter().map(OsString::from).collect()
195 }
196
197 #[must_use]
200 pub fn command_line(&self) -> String {
201 self.words().join(" ")
202 }
203
204 #[must_use]
212 pub fn confirm<Msg>(&self, on_yes: Msg) -> Confirm<Msg> {
213 let manager = self.manager.name();
214 let message = format!(
215 "{}\n\n{}\n\n{}",
216 crate::t!("quvyta.install.runs"),
217 self.command_line(),
218 crate::t!("quvyta.install.asks", manager = manager),
219 );
220 Confirm::new(crate::t!("quvyta.install.title", package = self.package.as_str()), on_yes)
221 .message(message)
222 .confirm_label(crate::t!("quvyta.install.confirm"))
223 }
224
225 #[must_use]
232 pub fn handoff<Msg: Send + 'static>(
233 &self,
234 on_finish: impl FnOnce(HandoffOutcome) -> Msg + Send + 'static,
235 ) -> Handoff<Msg> {
236 let words = self.words();
237 let notice = crate::t!("quvyta.install.notice", package = self.package.as_str(), manager = self.manager.name());
238 Handoff::new(words[0], on_finish).args(words[1..].iter().copied()).notice(notice).pause(true)
239 }
240
241 fn words(&self) -> Vec<&str> {
243 let mut words = Vec::with_capacity(5);
244 if !self.root && self.manager.needs_root() {
245 words.push("sudo");
246 }
247 words.push(self.manager.name());
248 words.extend_from_slice(self.manager.install_words());
249 words.push(&self.package);
250 words
251 }
252}
253
254fn is_package_name(name: &str) -> bool {
257 !name.is_empty() && !name.starts_with('-') && !name.chars().any(|c| c.is_whitespace() || c.is_control())
258}
259
260fn running_as_root() -> bool {
262 #[cfg(unix)]
263 {
264 rustix::process::geteuid().is_root()
265 }
266 #[cfg(not(unix))]
267 {
268 false
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use std::ffi::OsString;
275 use std::path::PathBuf;
276 use std::time::Duration;
277
278 use super::{Install, Manager};
279 use crate::runtime::{App, Command, HandoffOutcome, Harness};
280 use crate::widget::View;
281 use crate::widgets::{Button, Text};
282
283 fn machine(programs: &'static [&'static str]) -> impl Fn(&str) -> Option<PathBuf> {
285 move |program| programs.contains(&program).then(|| PathBuf::from("/usr/bin").join(program))
286 }
287
288 fn words(line: &str) -> Vec<OsString> {
289 line.split(' ').map(OsString::from).collect()
290 }
291
292 #[test]
293 fn without_a_package_manager_there_is_nothing_to_offer() {
294 assert_eq!(Install::package_with("libarchive", machine(&["ls", "paru", "yay"]), false), None);
295 }
296
297 #[test]
298 fn pacman_installs_with_sudo_and_only_what_is_missing() {
299 let install = Install::package_with("libarchive", machine(&["pacman"]), false).expect("pacman");
300 assert_eq!(install.manager(), Manager::Pacman);
301 assert_eq!(install.command_line(), "sudo pacman -S --needed libarchive");
302 assert_eq!(install.program(), words("sudo pacman -S --needed libarchive"));
303 }
304
305 #[test]
306 fn as_root_the_command_has_no_sudo() {
307 let install = Install::package_with("libarchive", machine(&["pacman"]), true).expect("pacman");
308 assert_eq!(install.command_line(), "pacman -S --needed libarchive");
309 assert_eq!(install.program()[0], OsString::from("pacman"));
310 }
311
312 #[test]
313 fn apt_get_installs_and_asks_its_own_question() {
314 let install = Install::package_with("bsdtar", machine(&["apt-get"]), false).expect("apt-get");
315 assert_eq!(install.command_line(), "sudo apt-get install bsdtar");
316 }
317
318 #[test]
319 fn every_manager_has_its_own_command() {
320 let cases: [(&'static [&'static str], &str); 4] = [
321 (&["dnf"], "sudo dnf install bsdtar"),
322 (&["zypper"], "sudo zypper install bsdtar"),
323 (&["apk"], "sudo apk add bsdtar"),
324 (&["brew"], "brew install bsdtar"),
325 ];
326 for (programs, line) in cases {
327 let install = Install::package_with("bsdtar", machine(programs), false).expect("a manager");
328 assert_eq!(install.command_line(), line);
329 }
330 }
331
332 #[test]
333 fn the_first_manager_in_the_order_wins() {
334 let install = Install::package_with("bsdtar", machine(&["brew", "dnf", "apt-get"]), false).expect("a manager");
335 assert_eq!(install.manager(), Manager::Apt, "apt-get comes before dnf and brew");
336 }
337
338 #[test]
339 fn homebrew_is_passed_over_as_root() {
340 assert_eq!(Install::package_with("bsdtar", machine(&["brew"]), true), None);
341 let install = Install::package_with("bsdtar", machine(&["brew"]), false).expect("brew");
342 assert_eq!(install.manager(), Manager::Brew);
343 }
344
345 #[test]
346 fn a_distribution_specific_name_is_used_on_its_manager_only() {
347 let named = |programs| {
348 Install::package_with("libarchive", machine(programs), false)
349 .expect("a manager")
350 .name_for(Manager::Apt, "libarchive-tools")
351 .name_for(Manager::Dnf, "bsdtar")
352 };
353 assert_eq!(named(&["apt-get"]).command_line(), "sudo apt-get install libarchive-tools");
354 assert_eq!(named(&["dnf"]).name(), "bsdtar");
355 assert_eq!(named(&["pacman"]).command_line(), "sudo pacman -S --needed libarchive");
356 }
357
358 #[test]
359 fn a_name_that_is_not_one_package_is_refused() {
360 for name in ["", "-Syu", "libarchive; rm", "a\tb", "a\nb"] {
361 assert_eq!(Install::package_with(name, machine(&["pacman"]), false), None, "{name:?}");
362 }
363 let kept = Install::package_with("libarchive", machine(&["apt-get"]), false)
364 .expect("apt-get")
365 .name_for(Manager::Apt, "--force");
366 assert_eq!(kept.name(), "libarchive", "an option is never taken for a name");
367 }
368
369 struct Missing {
371 install: Install,
372 outcomes: Vec<HandoffOutcome>,
373 }
374
375 #[derive(Clone)]
376 enum Msg {
377 Ask,
378 Install,
379 Installed(HandoffOutcome),
380 }
381
382 impl App for Missing {
383 type Msg = Msg;
384 fn update(&mut self, msg: Msg) -> Command<Msg> {
385 match msg {
386 Msg::Ask => Command::confirm(self.install.confirm(Msg::Install)),
387 Msg::Install => Command::handoff(self.install.handoff(Msg::Installed)),
388 Msg::Installed(outcome) => {
389 self.outcomes.push(outcome);
390 Command::none()
391 }
392 }
393 }
394 fn view(&self, ui: &mut View<'_, Msg>) {
395 ui.column(|ui| {
396 ui.add(Text::new("bsdtar is missing"));
397 ui.add(Button::new("Install").on_press(Msg::Ask)).id("install");
398 });
399 }
400 }
401
402 fn missing() -> Harness<Missing> {
403 let install = Install::package_with("libarchive", machine(&["pacman"]), false).expect("pacman");
404 Harness::new(Missing { install, outcomes: Vec::new() }, 72, 16)
405 }
406
407 fn ask(h: &mut Harness<Missing>) {
409 h.click_text("Install").advance(Duration::from_millis(200));
410 }
411
412 #[test]
413 fn the_question_shows_the_command_and_nothing_runs_before_the_answer() {
414 let mut h = missing();
415 ask(&mut h);
416 let screen = h.screen();
417 assert!(screen.contains("Install libarchive?"), "{screen}");
418 assert!(screen.contains("sudo pacman -S --needed libarchive"), "the exact command is shown: {screen}");
419 assert!(screen.contains("pacman may ask for your password"), "{screen}");
420 assert!(h.handoffs().is_empty(), "nothing is handed over before the person answers");
421 }
422
423 #[test]
424 fn escape_installs_nothing() {
425 let mut h = missing();
426 ask(&mut h);
427 h.press("esc");
428 assert!(!h.screen().contains("Install libarchive?"), "{}", h.screen());
429 assert!(h.handoffs().is_empty());
430 assert!(h.app().outcomes.is_empty());
431 }
432
433 #[test]
434 fn confirming_hands_the_terminal_to_the_package_manager_once() {
435 let mut h = missing();
436 ask(&mut h);
437 let screen = h.screen();
439 let (cancel_x, row) = h.find("Cancel").unwrap_or_else(|| panic!("an answer row: {screen}"));
440 let line = screen.lines().nth(usize::try_from(row).unwrap_or(0)).unwrap_or_default();
441 let after_cancel = line.chars().skip(usize::try_from(cancel_x).unwrap_or(0)).collect::<String>();
442 let offset = after_cancel.find("Install").unwrap_or_else(|| panic!("Install beside Cancel: {line}"));
443 let x = cancel_x + i32::try_from(after_cancel[..offset].chars().count()).unwrap_or(0);
444 h.click(x + 1, row);
445 let asked = h.handoffs();
446 assert_eq!(asked.len(), 1, "exactly one handoff");
447 assert_eq!(asked[0].program, OsString::from("sudo"));
448 assert_eq!(asked[0].args, words("pacman -S --needed libarchive"));
449 assert!(asked[0].pause, "the manager's last lines stay until a key is pressed");
450 assert_eq!(asked[0].notice.as_deref(), Some("Installing libarchive with pacman…"));
451 assert_eq!(h.app().outcomes, [HandoffOutcome::Finished { code: Some(0) }], "the outcome comes back");
452 }
453}