Skip to main content

qcode/ui/setup/
install.rs

1//! What the person has to run to get a container engine, on the system they are actually on.
2//!
3//! QCode works out the one line that installs the engine on this machine and offers it two
4//! ways: the person copies it and runs it themselves, or QCode runs that very line for them on
5//! a terminal they watch. Either way it is the one command they chose, run with the rights they
6//! already have; QCode never raises its own. Working the line out is a pure function of
7//! [`InstallHost`], which is plain data, so every system's guidance is checked from any other
8//! system, and starting it goes through [`Installer`], so a test drives the whole path without
9//! a package manager anywhere near it.
10
11use std::ffi::OsString;
12use std::path::PathBuf;
13use std::sync::Arc;
14
15use qframe::widgets::TerminalSession;
16
17use crate::engine::EngineKind;
18use crate::store::Platform;
19
20use super::gates::EngineProblem;
21
22/// What starts Docker's daemon on Linux, now and at every boot after.
23const DOCKER_SERVICE: &str = "sudo systemctl enable --now docker";
24
25/// What lets this account open Docker's socket. `$USER` is the shell's, which knows the name.
26const DOCKER_GROUP: &str = "sudo usermod -aG docker $USER";
27
28/// The installation guide of each engine, for a system whose package manager QCode does not
29/// know.
30const PODMAN_DOCS: &str = "https://podman.io/docs/installation";
31/// See [`PODMAN_DOCS`].
32const DOCKER_DOCS: &str = "https://docs.docker.com/engine/install/";
33
34/// The program that installs software on this machine.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum PackageManager {
37    /// Arch and its family, through the AUR helper the person already has.
38    Paru,
39    /// Arch and its family.
40    Pacman,
41    /// Debian, Ubuntu and their family.
42    Apt,
43    /// Fedora, RHEL and their family.
44    Dnf,
45    /// openSUSE.
46    Zypper,
47    /// macOS, through Homebrew.
48    Brew,
49    /// Windows.
50    Winget,
51}
52
53impl PackageManager {
54    /// The line that installs `package`, with the rights raising the system itself needs.
55    fn install(self, package: &str) -> String {
56        match self {
57            Self::Paru => format!("paru -S {package}"),
58            Self::Pacman => format!("sudo pacman -S {package}"),
59            Self::Apt => format!("sudo apt install {package}"),
60            Self::Dnf => format!("sudo dnf install {package}"),
61            Self::Zypper => format!("sudo zypper install {package}"),
62            // Docker on macOS is an application, not a formula, and casks say so.
63            Self::Brew if package == "docker" => "brew install --cask docker".to_owned(),
64            Self::Brew => format!("brew install {package}"),
65            Self::Winget => format!("winget install {package}"),
66        }
67    }
68
69    /// What this manager calls `kind`. The name is the manager's, not the engine's: Debian's
70    /// docker is `docker.io`, and winget wants the publisher's identifier.
71    fn package(self, kind: EngineKind) -> &'static str {
72        match (self, kind) {
73            (Self::Winget, EngineKind::Podman) => "RedHat.Podman",
74            (Self::Winget, EngineKind::Docker) => "Docker.DockerDesktop",
75            (Self::Apt, EngineKind::Docker) => "docker.io",
76            (_, EngineKind::Podman) => "podman",
77            (_, EngineKind::Docker) => "docker",
78        }
79    }
80}
81
82/// The machine the guidance is written for.
83///
84/// Carried as data rather than read from `cfg!` where it is needed, so the guidance for all
85/// three operating systems is checked on one machine.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct InstallHost {
88    /// Which system's rules to follow.
89    pub platform: Platform,
90    /// The program that installs software here, when QCode recognises one.
91    pub manager: Option<PackageManager>,
92    /// The engines already on this machine, Podman before Docker. It is what the wizard ticks
93    /// when the settings file names no engine: offering to install what is already installed
94    /// would be the wizard ignoring the machine it is standing on.
95    pub engines: Vec<EngineKind>,
96}
97
98/// The engines QCode offers, in the order it offers them.
99const ENGINES: [EngineKind; 2] = [EngineKind::Podman, EngineKind::Docker];
100
101impl InstallHost {
102    /// Reads the machine this program runs on.
103    #[must_use]
104    pub fn detect() -> Self {
105        let platform = Platform::host();
106        let release = (platform == Platform::Linux).then(|| std::fs::read_to_string("/etc/os-release").ok()).flatten();
107        // The engine layer's own search, not the bare `PATH`: a terminal started from a desktop
108        // launcher has a `PATH` that names neither Homebrew directory, and Docker Desktop puts
109        // its binary where only its installer knows. Asking `PATH` alone would tell the person
110        // to install an engine they already have, and QCode would then fail to find it twice
111        // for two different reasons.
112        Self::read(platform, release.as_deref(), crate::engine::installed)
113    }
114
115    /// The host `platform` describes, with `release` the text of the Linux `os-release` file and
116    /// `installed` answering whether a program is on the person's `PATH`.
117    ///
118    /// Both are parameters so a test can describe any distribution from any machine.
119    #[must_use]
120    pub fn read(platform: Platform, release: Option<&str>, installed: impl Fn(&str) -> bool) -> Self {
121        let manager = match platform {
122            Platform::MacOs => Some(PackageManager::Brew),
123            Platform::Windows => Some(PackageManager::Winget),
124            // A distribution QCode has never heard of gets no invented command: it is sent to
125            // the engine's own guide instead, which is always right.
126            Platform::Linux => family(release.unwrap_or_default()).map(|family| match family {
127                PackageManager::Pacman if installed("paru") => PackageManager::Paru,
128                manager => manager,
129            }),
130        };
131        let engines = ENGINES.into_iter().filter(|kind| installed(kind.dialect().binary)).collect();
132        Self { platform, manager, engines }
133    }
134}
135
136/// How the install command the person chose is started.
137///
138/// It is a function rather than a call to [`TerminalSession::spawn`] where the command is run,
139/// so the whole path — the button, the terminal inside the page, the output on it and the end
140/// of the command — is driven by a test that starts a harmless script instead. Nothing a test
141/// runs is ever an installation.
142#[derive(Clone)]
143pub struct Installer(Arc<Start>);
144
145/// What an [`Installer`] does with a command line: it starts it and hands back the terminal it
146/// is running on, or says why it could not be started at all.
147type Start = dyn Fn(&str) -> std::io::Result<TerminalSession> + Send + Sync;
148
149impl std::fmt::Debug for Installer {
150    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        formatter.write_str("Installer")
152    }
153}
154
155impl Installer {
156    /// The real one: the line is handed to the person's own shell with the rights they already
157    /// have. Nothing here raises them. A command that needs more asks for it itself, on the
158    /// terminal, where the person is the one who answers.
159    #[must_use]
160    pub fn shell() -> Self {
161        Self::new(|command| {
162            let (program, flag): (OsString, &str) = if cfg!(windows) {
163                (OsString::from("cmd"), "/C")
164            } else {
165                (std::env::var_os("SHELL").unwrap_or_else(|| "/bin/sh".into()), "-c")
166            };
167            let folder = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
168            TerminalSession::spawn(&program, &[flag, command], &folder)
169        })
170    }
171
172    /// An installer that starts commands the way `start` says.
173    #[must_use]
174    pub fn new(start: impl Fn(&str) -> std::io::Result<TerminalSession> + Send + Sync + 'static) -> Self {
175        Self(Arc::new(start))
176    }
177
178    /// Starts `command` on a terminal of its own.
179    ///
180    /// # Errors
181    ///
182    /// When no pseudo-terminal can be opened or the command cannot be started at all.
183    pub fn start(&self, command: &str) -> std::io::Result<TerminalSession> {
184        (self.0)(command)
185    }
186}
187
188/// The family of a Linux distribution, from its own `ID` and the families it says it is like.
189///
190/// `ID_LIKE` is what derivatives are for: Linux Mint says `ubuntu debian`, Rocky says
191/// `rhel centos fedora`, and reading it means a distribution QCode has never seen still gets
192/// the right command as long as it names its family honestly.
193fn family(release: &str) -> Option<PackageManager> {
194    let mut ids = Vec::new();
195    for line in release.lines() {
196        let Some((key, value)) = line.split_once('=') else { continue };
197        if !matches!(key.trim(), "ID" | "ID_LIKE") {
198            continue;
199        }
200        let value = value.trim().trim_matches('"').trim_matches('\'');
201        ids.extend(value.split_whitespace().map(str::to_lowercase));
202    }
203    ids.iter().find_map(|id| match id.as_str() {
204        "arch" | "archlinux" | "manjaro" | "endeavouros" | "cachyos" | "garuda" => Some(PackageManager::Pacman),
205        "debian" | "ubuntu" | "linuxmint" | "pop" | "raspbian" => Some(PackageManager::Apt),
206        "fedora" | "rhel" | "centos" | "rocky" | "almalinux" => Some(PackageManager::Dnf),
207        "opensuse" | "opensuse-tumbleweed" | "opensuse-leap" | "suse" | "sles" => Some(PackageManager::Zypper),
208        _ => None,
209    })
210}
211
212/// What the person has to do before the chosen engine works.
213///
214/// The words belong to the screen, which knows from the [`EngineProblem`] itself what to say;
215/// what is here is only the one line the person may want to run, and never a command QCode is
216/// not sure of.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum Remedy {
219    /// The engine is not there and has to be installed.
220    Install {
221        /// The line that installs it here, when QCode knows this system's package manager, with
222        /// what else the engine needs before it answers.
223        command: Option<String>,
224        /// The engine's own installation guide, which is right on every system.
225        docs: &'static str,
226        /// Whether the person has to log out and back in afterwards, as a new group needs.
227        relogin: bool,
228    },
229    /// The engine is installed and something it needs has to be started.
230    Start {
231        /// The line that starts it, when this system has one QCode can promise.
232        command: Option<String>,
233    },
234    /// The engine works and this account is not set up for it yet: it has to join the `docker`
235    /// group, or be given id ranges for podman. The line does it.
236    Grant {
237        /// The line that does it.
238        command: String,
239        /// Whether the change only takes hold at the next login, as a new group does.
240        relogin: bool,
241    },
242    /// The account is set up already and this login began before it was: logging out and back
243    /// in is all that is left, and no command does that.
244    Relogin,
245    /// The engine refused for a reason QCode cannot read; only its own words help.
246    Unknown,
247}
248
249impl Remedy {
250    /// The line QCode can run for the person on a terminal in the page, when there is one.
251    #[must_use]
252    pub fn runnable(&self) -> Option<&str> {
253        match self {
254            Self::Install { command, .. } | Self::Start { command } => command.as_deref(),
255            Self::Grant { command, .. } => Some(command),
256            Self::Relogin | Self::Unknown => None,
257        }
258    }
259
260    /// What to do about `problem` with `kind` on `host`.
261    #[must_use]
262    pub fn for_problem(problem: &EngineProblem, kind: EngineKind, host: &InstallHost) -> Self {
263        match problem {
264            EngineProblem::NotInstalled | EngineProblem::NotRunnable { .. } => Self::Install {
265                command: host.manager.map(|manager| {
266                    let install = manager.install(manager.package(kind));
267                    // Docker on Linux is a service with a socket only its group may open, and the
268                    // package sets up neither: without the rest, the next thing the person reads
269                    // is that the daemon is not running, and then that they may not use it.
270                    match (kind, host.platform) {
271                        (EngineKind::Docker, Platform::Linux) => {
272                            format!("{install} && {DOCKER_SERVICE} && {DOCKER_GROUP}")
273                        }
274                        _ => install,
275                    }
276                }),
277                docs: match kind {
278                    EngineKind::Podman => PODMAN_DOCS,
279                    EngineKind::Docker => DOCKER_DOCS,
280                },
281                relogin: kind == EngineKind::Docker && host.platform == Platform::Linux,
282            },
283            EngineProblem::NoPermission { in_group: true, .. } => Self::Relogin,
284            EngineProblem::NoPermission { in_group: false, .. } => {
285                Self::Grant { command: DOCKER_GROUP.to_owned(), relogin: true }
286            }
287            EngineProblem::NoIdRanges { line, .. } => Self::Grant { command: line.clone(), relogin: false },
288            EngineProblem::DaemonStopped { .. } => Self::Start {
289                command: match host.platform {
290                    // Enabled as well as started, so it is still there after the next boot.
291                    Platform::Linux => Some(DOCKER_SERVICE.to_owned()),
292                    Platform::MacOs => Some("open -a Docker".to_owned()),
293                    // Docker Desktop on Windows is started from the desktop; there is no line
294                    // QCode can promise, so it shows none and says so instead.
295                    Platform::Windows => None,
296                },
297            },
298            // The machine is podman's own, and the command is the same on every system.
299            EngineProblem::MachineStopped { .. } => Self::Start { command: Some("podman machine start".to_owned()) },
300            EngineProblem::Refused { .. } => Self::Unknown,
301        }
302    }
303}
304
305/// The first id a range of subordinate ids is given from when a machine has none yet, and how
306/// many ids a range holds: what `useradd` itself hands a new account on the common
307/// distributions, so a range added here looks like one the system made.
308const FIRST_SUBORDINATE_ID: u64 = 100_000;
309/// See [`FIRST_SUBORDINATE_ID`].
310const SUBORDINATE_IDS: u64 = 65_536;
311
312/// The account QCode runs as and what `/etc/passwd`, `/etc/subuid` and `/etc/subgid` say, which
313/// is everything needed to know whether rootless podman can map the ids an image uses.
314///
315/// Plain data, so every case is checked without touching the machine's files; only
316/// [`IdRanges::here`] reads them.
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct IdRanges {
319    /// The account's name.
320    pub user: String,
321    /// Whether `user` is the name the environment gave. It is not when the environment named
322    /// nobody, which is the case for a qcode a service or `env -i` started: the name then comes
323    /// from `/etc/passwd`, so [`IdRanges::line`] writes it out in full instead of leaving
324    /// `$USER` to a shell that has none.
325    pub from_env: bool,
326    /// The account's numeric id; either may stand at the start of a line.
327    pub uid: u32,
328    /// The text of `/etc/subuid`, empty when it is missing.
329    pub subuid: String,
330    /// The text of `/etc/subgid`, empty when it is missing.
331    pub subgid: String,
332}
333
334impl IdRanges {
335    /// Reads this machine: the account from the environment, or from `/etc/passwd` when the
336    /// environment names nobody, the owner of this process, and the two id files as they are. A
337    /// file that cannot be read counts as empty, which is what podman makes of it too.
338    #[must_use]
339    pub fn here() -> Self {
340        #[cfg(unix)]
341        let uid = {
342            use std::os::unix::fs::MetadataExt;
343            std::fs::metadata("/proc/self").map(|meta| meta.uid()).unwrap_or(u32::MAX)
344        };
345        #[cfg(not(unix))]
346        let uid = u32::MAX;
347        let environment =
348            std::env::var("USER").or_else(|_| std::env::var("LOGNAME")).ok().filter(|name| !name.is_empty());
349        Self::of(
350            environment.as_deref(),
351            uid,
352            &std::fs::read_to_string("/etc/passwd").unwrap_or_default(),
353            &std::fs::read_to_string("/etc/subuid").unwrap_or_default(),
354            &std::fs::read_to_string("/etc/subgid").unwrap_or_default(),
355        )
356    }
357
358    /// The ranges of the account `environment` names, or of the one the `passwd` file holds for
359    /// `uid` when the environment names nobody.
360    ///
361    /// `/etc/subuid` names an account by name far more often than by number, so an empty name
362    /// makes a machine on which podman works look like one that has no id ranges for it: the
363    /// account is read from `passwd`, whose own lines name every account there is.
364    ///
365    /// The files are parameters so a test can describe any machine from any other one.
366    #[must_use]
367    pub fn of(environment: Option<&str>, uid: u32, passwd: &str, subuid: &str, subgid: &str) -> Self {
368        let (user, from_env) = match environment {
369            Some(name) if !name.is_empty() => (name.to_owned(), true),
370            _ => (passwd_name(passwd, uid).unwrap_or_default(), false),
371        };
372        Self { user, from_env, uid, subuid: subuid.to_owned(), subgid: subgid.to_owned() }
373    }
374
375    /// Whether both files give this account a range.
376    #[must_use]
377    pub fn present(&self) -> bool {
378        self.has(&self.subuid) && self.has(&self.subgid)
379    }
380
381    fn has(&self, text: &str) -> bool {
382        let uid = self.uid.to_string();
383        entries(text).any(|(owner, _, count)| (owner == self.user || owner == uid) && count > 0)
384    }
385
386    /// The line that gives this account a range in both files, then tells podman to take it up.
387    ///
388    /// The range starts after every range either file already hands out, so it never overlaps
389    /// another account's: two accounts sharing ids could read each other's container files.
390    /// `$USER` is left for the shell, which knows the name for certain; a name `/etc/passwd` gave
391    /// is written out instead, because a shell as bare as the one this program was started from
392    /// would leave `$USER` empty and `usermod` would range nobody.
393    #[must_use]
394    pub fn line(&self) -> String {
395        let taken = entries(&self.subuid).chain(entries(&self.subgid)).map(|(_, start, count)| start + count).max();
396        let start = taken.unwrap_or(0).max(FIRST_SUBORDINATE_ID);
397        let end = start + SUBORDINATE_IDS - 1;
398        let account = if self.from_env { "$USER" } else { self.user.as_str() };
399        format!(
400            "sudo usermod --add-subuids {start}-{end} --add-subgids {start}-{end} {account} && podman system migrate"
401        )
402    }
403}
404
405/// The name the `passwd` file gives `uid`: the first field of the line whose third field is
406/// `uid`, as every account a machine has is in there.
407///
408/// A line that does not parse is skipped, so one unreadable line does not hide the accounts the
409/// rest of the file names; a `uid` the file holds no line for names nobody, which is what an
410/// empty name already meant.
411fn passwd_name(passwd: &str, uid: u32) -> Option<String> {
412    passwd.lines().find_map(|line| {
413        let mut fields = line.split(':');
414        let name = fields.next()?;
415        let _password = fields.next()?;
416        (fields.next()?.trim().parse::<u32>().ok()? == uid).then_some(name.to_owned())
417    })
418}
419
420/// The `owner:start:count` lines of a subordinate id file, skipping anything that is not one.
421fn entries(text: &str) -> impl Iterator<Item = (&str, u64, u64)> {
422    text.lines().filter_map(|line| {
423        let mut parts = line.trim().split(':');
424        let owner = parts.next()?;
425        let start = parts.next()?.trim().parse().ok()?;
426        let count = parts.next()?.trim().parse().ok()?;
427        (!owner.is_empty() && !owner.starts_with('#')).then_some((owner, start, count))
428    })
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use crate::engine::EngineKind;
435    use crate::store::Platform;
436
437    /// A Linux host whose `os-release` says `id`, with `paru` installed or not.
438    fn linux(id: &str, paru: bool) -> InstallHost {
439        let text = format!("NAME=\"Something\"\nID={id}\n");
440        InstallHost::read(Platform::Linux, Some(&text), |tool| paru && tool == "paru")
441    }
442
443    fn install(host: &InstallHost, kind: EngineKind) -> Option<String> {
444        match Remedy::for_problem(&EngineProblem::NotInstalled, kind, host) {
445            Remedy::Install { command, .. } => command,
446            other => panic!("a missing engine is installed, not {other:?}"),
447        }
448    }
449
450    /// `ada`'s ranges, an account the environment named, with the two id files as given.
451    fn ranges(subuid: &str, subgid: &str) -> IdRanges {
452        IdRanges::of(Some("ada"), 1001, "", subuid, subgid)
453    }
454
455    /// The text of `/etc/passwd` on a machine with two accounts and a line nothing can read.
456    fn passwd() -> &'static str {
457        "root:x:0:0:root:/root:/bin/bash\nnot an account\nalice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
458    }
459
460    #[test]
461    fn an_account_has_ranges_only_when_both_files_name_it() {
462        assert!(ranges("ada:100000:65536\n", "ada:100000:65536\n").present());
463        // By number as well as by name, the way shadow-utils reads them.
464        assert!(ranges("1001:100000:65536\n", "ada:100000:65536\n").present());
465        assert!(!ranges("ada:100000:65536\n", "").present(), "a group range is needed too");
466        assert!(!ranges("bob:100000:65536\n", "bob:100000:65536\n").present(), "another account's range");
467        assert!(!ranges("ada:100000:0\n", "ada:100000:0\n").present(), "a range of nothing");
468    }
469
470    #[test]
471    fn the_name_a_uid_has_in_passwd_is_found_past_a_line_that_does_not_parse() {
472        assert_eq!(passwd_name(passwd(), 1000).as_deref(), Some("alice"));
473        assert_eq!(passwd_name(passwd(), 0).as_deref(), Some("root"));
474        assert_eq!(passwd_name(passwd(), 4242), None, "a uid the file holds no line for names nobody");
475        assert_eq!(passwd_name("", 0), None, "a passwd file that could not be read names nobody");
476    }
477
478    #[test]
479    fn an_account_the_environment_leaves_unnamed_is_found_in_passwd() {
480        // A qcode a service or `env -i` started has neither `$USER` nor `$LOGNAME`, and the id
481        // files name an account by name, so an empty name reads as an account podman has no
482        // ranges for on a machine where it works.
483        let ranges = IdRanges::of(None, 1000, passwd(), "alice:100000:65536\n", "alice:100000:65536\n");
484        assert_eq!(ranges.user, "alice", "the uid is the one thing qcode knows of its own account");
485        assert!(ranges.present());
486        assert_eq!(IdRanges::of(Some(""), 1000, passwd(), "", "").user, "alice", "an empty name is no name");
487    }
488
489    #[test]
490    fn the_range_added_starts_after_every_range_already_handed_out() {
491        assert_eq!(
492            ranges("", "").line(),
493            "sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER && podman system migrate"
494        );
495        let crowded = ranges("bob:100000:65536\n# a note\ncarol:165536:65536\n", "bob:100000:65536\n");
496        assert!(
497            crowded.line().contains("--add-subuids 231072-296607 --add-subgids 231072-296607"),
498            "{}",
499            crowded.line()
500        );
501    }
502
503    #[test]
504    fn the_line_names_the_account_the_way_the_shell_that_runs_it_will_know_it() {
505        let from_passwd = IdRanges::of(None, 1000, passwd(), "", "");
506        let line = from_passwd.line();
507        assert!(line.contains(" --add-subgids 100000-165535 alice &&"), "{line}");
508        assert!(!line.contains("$USER"), "a shell as bare as the one qcode was started from has none: {line}");
509        assert!(ranges("", "").line().contains("$USER"), "a name the environment gave is left to the shell");
510    }
511
512    #[test]
513    fn arch_installs_with_paru_when_it_is_there() {
514        let host = linux("arch", true);
515        assert_eq!(install(&host, EngineKind::Podman).as_deref(), Some("paru -S podman"));
516    }
517
518    #[test]
519    fn arch_falls_back_to_pacman_when_paru_is_not_installed() {
520        let host = linux("arch", false);
521        assert_eq!(install(&host, EngineKind::Podman).as_deref(), Some("sudo pacman -S podman"));
522    }
523
524    #[test]
525    fn a_distribution_is_recognised_by_what_it_is_like() {
526        // Derivatives name their own ID and say which family they follow.
527        let text = "ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n";
528        let host = InstallHost::read(Platform::Linux, Some(text), |_| false);
529        assert_eq!(install(&host, EngineKind::Podman).as_deref(), Some("sudo apt install podman"));
530    }
531
532    #[test]
533    fn debian_installs_docker_under_the_name_debian_gives_it() {
534        let host = linux("debian", false);
535        let line = install(&host, EngineKind::Docker).expect("a line");
536        assert!(line.starts_with("sudo apt install docker.io && "), "{line}");
537    }
538
539    #[test]
540    fn docker_on_linux_is_installed_started_and_let_in_by_one_line() {
541        let host = linux("arch", true);
542        assert_eq!(
543            install(&host, EngineKind::Docker).as_deref(),
544            Some("paru -S docker && sudo systemctl enable --now docker && sudo usermod -aG docker $USER")
545        );
546        let Remedy::Install { relogin, .. } =
547            Remedy::for_problem(&EngineProblem::NotInstalled, EngineKind::Docker, &host)
548        else {
549            panic!("a missing engine is installed")
550        };
551        assert!(relogin, "the new group only holds after a new login, and the person is told so");
552        // Podman needs no service and no group; its line is the package alone.
553        assert_eq!(install(&host, EngineKind::Podman).as_deref(), Some("paru -S podman"));
554        // Docker Desktop on macOS is an application that does both itself.
555        let mac = InstallHost::read(Platform::MacOs, None, |_| false);
556        assert_eq!(install(&mac, EngineKind::Docker).as_deref(), Some("brew install --cask docker"));
557    }
558
559    #[test]
560    fn an_account_docker_will_not_let_in_is_added_to_the_group_or_only_asked_to_log_in_again() {
561        let host = linux("arch", true);
562        let outside = EngineProblem::NoPermission { output: String::new(), in_group: false };
563        let remedy = Remedy::for_problem(&outside, EngineKind::Docker, &host);
564        assert_eq!(remedy, Remedy::Grant { command: "sudo usermod -aG docker $USER".to_owned(), relogin: true });
565        assert_eq!(remedy.runnable(), Some("sudo usermod -aG docker $USER"));
566        let inside = EngineProblem::NoPermission { output: String::new(), in_group: true };
567        assert_eq!(Remedy::for_problem(&inside, EngineKind::Docker, &host), Remedy::Relogin);
568        assert_eq!(Remedy::Relogin.runnable(), None, "no command logs anyone out");
569    }
570
571    #[test]
572    fn fedora_and_its_family_install_with_dnf() {
573        assert_eq!(install(&linux("fedora", false), EngineKind::Podman).as_deref(), Some("sudo dnf install podman"));
574        let rocky = InstallHost::read(Platform::Linux, Some("ID=rocky\nID_LIKE=\"rhel centos fedora\"\n"), |_| false);
575        assert!(
576            install(&rocky, EngineKind::Docker).is_some_and(|line| line.starts_with("sudo dnf install docker && "))
577        );
578    }
579
580    #[test]
581    fn opensuse_installs_with_zypper() {
582        let host = linux("opensuse-tumbleweed", false);
583        assert_eq!(install(&host, EngineKind::Podman).as_deref(), Some("sudo zypper install podman"));
584    }
585
586    #[test]
587    fn a_system_qcode_does_not_know_is_sent_to_the_engines_own_guide() {
588        let host = InstallHost::read(Platform::Linux, None, |_| false);
589        let Remedy::Install { command, docs, .. } =
590            Remedy::for_problem(&EngineProblem::NotInstalled, EngineKind::Podman, &host)
591        else {
592            panic!("a missing engine is installed")
593        };
594        assert_eq!(command, None, "no command is invented for a package manager nobody found");
595        assert!(docs.starts_with("https://"), "the guide is a link the person can open: {docs}");
596    }
597
598    #[test]
599    fn macos_installs_with_homebrew_and_knows_docker_is_an_application() {
600        let host = InstallHost::read(Platform::MacOs, None, |_| false);
601        assert_eq!(install(&host, EngineKind::Podman).as_deref(), Some("brew install podman"));
602        assert_eq!(install(&host, EngineKind::Docker).as_deref(), Some("brew install --cask docker"));
603    }
604
605    #[test]
606    fn windows_installs_with_winget() {
607        let host = InstallHost::read(Platform::Windows, None, |_| false);
608        assert_eq!(install(&host, EngineKind::Podman).as_deref(), Some("winget install RedHat.Podman"));
609        assert_eq!(install(&host, EngineKind::Docker).as_deref(), Some("winget install Docker.DockerDesktop"));
610    }
611
612    #[test]
613    fn a_stopped_docker_daemon_is_started_the_way_each_system_starts_it() {
614        let problem = EngineProblem::DaemonStopped { output: String::new() };
615        let linux = Remedy::for_problem(&problem, EngineKind::Docker, &linux("debian", false));
616        assert!(matches!(linux, Remedy::Start { command: Some(ref c) } if c == "sudo systemctl enable --now docker"));
617
618        let mac = InstallHost::read(Platform::MacOs, None, |_| false);
619        let mac = Remedy::for_problem(&problem, EngineKind::Docker, &mac);
620        assert!(matches!(mac, Remedy::Start { command: Some(ref c) } if c == "open -a Docker"));
621
622        // Docker Desktop on Windows has no command QCode can promise, so none is shown; the
623        // screen says to start the application instead of printing something that may not work.
624        let windows = InstallHost::read(Platform::Windows, None, |_| false);
625        let windows = Remedy::for_problem(&problem, EngineKind::Docker, &windows);
626        assert!(matches!(windows, Remedy::Start { command: None }));
627    }
628
629    #[test]
630    fn a_podman_machine_that_is_not_running_is_started_the_same_way_everywhere() {
631        let problem = EngineProblem::MachineStopped { output: String::new() };
632        let host = InstallHost::read(Platform::MacOs, None, |_| false);
633        let remedy = Remedy::for_problem(&problem, EngineKind::Podman, &host);
634        assert!(matches!(remedy, Remedy::Start { command: Some(ref c) } if c == "podman machine start"));
635    }
636
637    #[test]
638    fn the_engines_this_machine_already_has_are_read_with_the_rest_of_it() {
639        let both = InstallHost::read(Platform::Linux, Some("ID=arch\n"), |tool| matches!(tool, "podman" | "docker"));
640        assert_eq!(both.engines, [EngineKind::Podman, EngineKind::Docker], "podman is named before docker");
641        let docker = InstallHost::read(Platform::Linux, Some("ID=arch\n"), |tool| tool == "docker");
642        assert_eq!(docker.engines, [EngineKind::Docker]);
643        let bare = InstallHost::read(Platform::Linux, Some("ID=arch\n"), |_| false);
644        assert!(bare.engines.is_empty(), "a machine with neither engine says so");
645    }
646
647    #[test]
648    fn a_refusal_qcode_cannot_read_offers_no_command_at_all() {
649        let host = linux("arch", true);
650        let problem = EngineProblem::Refused { code: Some(125), output: "no tty".to_owned() };
651        assert!(matches!(Remedy::for_problem(&problem, EngineKind::Podman, &host), Remedy::Unknown));
652    }
653}