Skip to main content

helixforge_engine/
lib.rs

1//! Turns entries into explicit plans, runs them, and records what happened.
2//!
3//! Every interface uses this crate the same way: `plan` an entry (or several)
4//! into a [`Plan`] of ordered [`Step`]s with what they need, whether they are
5//! privileged and how risky they are; show it; then run it through a
6//! [`Runner`]. The steps become one POSIX shell program that stops at the
7//! first failure. The shell only ever sees quoted literals from the validated
8//! catalog, never free text. Native operations run back inside HelixForge
9//! itself, so even they go through the same program and the same terminal.
10//!
11//! A package step lists its sources in order (the distribution's package,
12//! the AUR on Arch, Flathub); the program tries the next one only when the
13//! one before it fails or leaves the package missing. After a run, [`verify`]
14//! checks that each package step left its package installed, or removed.
15
16pub mod backups;
17pub mod doctor;
18pub mod history;
19pub mod inventory;
20pub mod preflight;
21pub mod profiles;
22pub mod pty;
23pub mod updates;
24
25use helixforge_core::{
26    Action, Entry, NativeAction, NixAction, PackageManager, Risk, valid_package_name,
27};
28use helixforge_platform::{LinuxInfo, binary_exists};
29use std::{
30    path::{Path, PathBuf},
31    process::{Command, ExitStatus, Stdio},
32};
33use thiserror::Error;
34use tracing::{info, warn};
35
36/// The variable scripts use to find the shared helpers.
37pub const LIB_VARIABLE: &str = "HELIXFORGE_LIB";
38
39#[derive(Debug, Error)]
40pub enum EngineError {
41    #[error("this distribution is not supported for package installation")]
42    UnsupportedPlatform,
43    #[error("{0} has no package for this distribution family")]
44    UnsupportedEntry(String),
45    #[error("unsafe catalog value: {0}")]
46    Invalid(String),
47    #[error("{0} is not a package entry that can be removed")]
48    NotRemovable(String),
49    #[error("profile {0} names an entry that is not in the catalog: {1}")]
50    UnknownMember(String, String),
51    #[error("required executable not found: {0}")]
52    MissingExecutable(String),
53    #[error("cannot start the shell: {0}")]
54    Spawn(#[source] std::io::Error),
55}
56
57/// One thing to run, with everything needed to run it.
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub enum Step {
60    /// Installs a package from the first of its sources that works, or
61    /// removes it through its one source.
62    Package {
63        id: String,
64        name: String,
65        /// A command the package provides, asked only where no probe can.
66        binary: Option<String>,
67        sources: Vec<Source>,
68        removal: bool,
69    },
70    Script {
71        id: String,
72        name: String,
73        file: PathBuf,
74        program: String,
75        args: Vec<String>,
76        interactive: bool,
77    },
78    Command {
79        id: String,
80        name: String,
81        line: String,
82    },
83    Nix {
84        id: String,
85        name: String,
86        action: NixAction,
87    },
88    /// Runs inside HelixForge itself, re-invoked from the shell program.
89    Native {
90        id: String,
91        name: String,
92        action: NativeAction,
93    },
94    /// A small program HelixForge builds itself from quoted values only:
95    /// restoring a backup, taking a snapshot.
96    Shell {
97        id: String,
98        name: String,
99        program: String,
100        /// How plans show it.
101        display: String,
102        requires: Vec<String>,
103    },
104    /// Brings installed packages up to date through one source; on Arch, the
105    /// whole system for every target at once.
106    Update {
107        id: String,
108        name: String,
109        source: Source,
110        targets: Vec<UpdateTarget>,
111    },
112}
113
114/// A package an update step brings up to date.
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct UpdateTarget {
117    /// The entry's name, for the checks.
118    pub name: String,
119    /// How to find the package, and its version, afterwards.
120    pub probe: Probe,
121    /// Its version before the update, when known.
122    pub from: Option<String>,
123}
124
125/// One way to install or remove a package.
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub struct Source {
128    /// How the run names it: "pacman", "the AUR (paru)", "Flathub".
129    pub label: String,
130    /// Commands run in order; each must succeed.
131    pub commands: Vec<Vec<String>>,
132    /// Executables the commands need.
133    pub requires: Vec<String>,
134    /// What shows the package is installed from this source, when there is a
135    /// way to ask.
136    pub probe: Option<Probe>,
137}
138
139impl Source {
140    /// The commands as plans show them, safe to print: package names and
141    /// paths may come from outside HelixForge.
142    pub fn command_line(&self) -> String {
143        helixforge_core::printable(
144            &self
145                .commands
146                .iter()
147                .map(|command| command.join(" "))
148                .collect::<Vec<_>>()
149                .join(" && "),
150        )
151    }
152}
153
154/// A question about the system whose answer says a package is installed.
155#[derive(Clone, Debug, Eq, PartialEq)]
156pub enum Probe {
157    /// The package manager lists the package as installed.
158    Package {
159        manager: PackageManager,
160        name: String,
161    },
162    /// The Flatpak application is installed, for the system or the user.
163    Flatpak(String),
164}
165
166impl Probe {
167    /// The probe for a package of this family; Solus has no simple query.
168    fn package(manager: PackageManager, name: &str) -> Option<Self> {
169        (manager != PackageManager::Solus).then(|| Probe::Package {
170            manager,
171            name: name.to_string(),
172        })
173    }
174
175    /// The query, for the families that answer through the exit status.
176    fn query(manager: PackageManager, name: &str) -> Vec<String> {
177        let base: &[&str] = match manager {
178            PackageManager::Arch => &["pacman", "-Q"],
179            PackageManager::Fedora | PackageManager::Suse => &["rpm", "-q"],
180            PackageManager::Alpine => &["apk", "info", "-e"],
181            PackageManager::Void => &["xbps-query"],
182            PackageManager::Debian | PackageManager::Solus => &[],
183        };
184        words(base.to_vec(), &[name])
185    }
186
187    /// A shell condition that is true when the probe holds.
188    pub fn shell(&self) -> String {
189        match self {
190            Probe::Package {
191                manager: PackageManager::Debian,
192                name,
193            } => format!(
194                "[ \"$(dpkg-query -W -f='${{db:Status-Status}}' {} 2>/dev/null)\" = installed ]",
195                quote(name)
196            ),
197            Probe::Package { manager, name } => {
198                format!("{} >/dev/null 2>&1", quoted(&Probe::query(*manager, name)))
199            }
200            Probe::Flatpak(id) => format!(
201                "{{ [ -d {} ] || [ -d \"$HOME\"/{} ]; }}",
202                quote(&format!("/var/lib/flatpak/app/{id}")),
203                quote(&format!(".local/share/flatpak/app/{id}"))
204            ),
205        }
206    }
207
208    /// Whether the probe holds now.
209    pub fn holds(&self) -> bool {
210        match self {
211            Probe::Package {
212                manager: PackageManager::Debian,
213                name,
214            } => Command::new("dpkg-query")
215                .args(["-W", "-f=${db:Status-Status}", name])
216                .stderr(Stdio::null())
217                .output()
218                .is_ok_and(|output| output.stdout == b"installed"),
219            Probe::Package { manager, name } => {
220                let query = Probe::query(*manager, name);
221                Command::new(&query[0])
222                    .args(&query[1..])
223                    .stdout(Stdio::null())
224                    .stderr(Stdio::null())
225                    .status()
226                    .is_ok_and(|status| status.success())
227            }
228            Probe::Flatpak(id) => flatpak_installed_in(&flatpak_roots(), id),
229        }
230    }
231
232    /// What answers the probe, for a sentence saying it did not.
233    fn subject(&self) -> String {
234        match self {
235            Probe::Package {
236                manager: PackageManager::Debian,
237                name,
238            } => format!("dpkg ({name})"),
239            Probe::Package { manager, name } => format!("{} ({name})", manager.executable()),
240            Probe::Flatpak(id) => format!("Flatpak ({id})"),
241        }
242    }
243
244    pub fn describe(&self) -> String {
245        match self {
246            Probe::Package {
247                manager: PackageManager::Debian,
248                name,
249            } => format!("dpkg lists {name}"),
250            Probe::Package { manager, name } => {
251                format!("{} lists {name}", manager.executable())
252            }
253            Probe::Flatpak(id) => format!("{id} is installed from Flathub"),
254        }
255    }
256}
257
258impl Step {
259    pub fn id(&self) -> &str {
260        match self {
261            Step::Package { id, .. }
262            | Step::Script { id, .. }
263            | Step::Command { id, .. }
264            | Step::Nix { id, .. }
265            | Step::Native { id, .. }
266            | Step::Shell { id, .. }
267            | Step::Update { id, .. } => id,
268        }
269    }
270
271    pub fn name(&self) -> &str {
272        match self {
273            Step::Package { name, .. }
274            | Step::Script { name, .. }
275            | Step::Command { name, .. }
276            | Step::Nix { name, .. }
277            | Step::Native { name, .. }
278            | Step::Shell { name, .. }
279            | Step::Update { name, .. } => name,
280        }
281    }
282
283    /// The command line as the shell will run it, for previews and plans.
284    pub fn display(&self) -> String {
285        match self {
286            Step::Package { sources, .. } => {
287                let mut shown = sources
288                    .first()
289                    .map(Source::command_line)
290                    .unwrap_or_default();
291                if sources.len() > 1 {
292                    let others: Vec<&str> = sources[1..]
293                        .iter()
294                        .map(|source| source.label.as_str())
295                        .collect();
296                    shown.push_str(&format!("  (else {})", others.join(", then ")));
297                }
298                shown
299            }
300            Step::Script {
301                file,
302                program,
303                args,
304                ..
305            } => {
306                let mut words = vec![program.clone()];
307                words.extend(args.iter().cloned());
308                words.push(
309                    file.file_name()
310                        .map(|name| name.to_string_lossy().into_owned())
311                        .unwrap_or_default(),
312                );
313                format!(
314                    "{}  (in {})",
315                    words.join(" "),
316                    file.parent().unwrap_or(Path::new(".")).display()
317                )
318            }
319            Step::Command { line, .. } => line.clone(),
320            Step::Nix { action, .. } => nix_command(action).join(" "),
321            Step::Native { action, .. } => format!("helixforge native {}", action.id()),
322            Step::Update { source, .. } => source.command_line(),
323            Step::Shell { display, .. } => display.clone(),
324        }
325    }
326
327    /// The sources tried when the first fails, as lines for a full plan.
328    pub fn fallbacks(&self) -> Vec<String> {
329        match self {
330            Step::Package { sources, .. } => sources
331                .iter()
332                .skip(1)
333                .map(|source| format!("if that fails, {}: {}", source.label, source.command_line()))
334                .collect(),
335            _ => Vec::new(),
336        }
337    }
338
339    /// Everything any of the step's sources needs.
340    fn all_requirements(&self) -> Vec<String> {
341        match self {
342            Step::Package { sources, .. } => sources
343                .iter()
344                .flat_map(|source| source.requires.iter().cloned())
345                .collect(),
346            _ => self.requirements(),
347        }
348    }
349
350    /// The executables a step needs before it can start; for a package, what
351    /// its first source needs.
352    fn requirements(&self) -> Vec<String> {
353        match self {
354            Step::Package { sources, .. } => sources
355                .first()
356                .map(|source| source.requires.clone())
357                .unwrap_or_default(),
358            Step::Nix { action, .. } => match action {
359                NixAction::ProfileInstall { .. } => vec!["nix".to_string()],
360                NixAction::HomeManagerSwitch { .. } => vec!["home-manager".to_string()],
361            },
362            Step::Script { program, .. } => vec![program.clone()],
363            Step::Update { source, .. } => source.requires.clone(),
364            Step::Shell { requires, .. } => requires.clone(),
365            Step::Command { .. } | Step::Native { .. } => Vec::new(),
366        }
367    }
368}
369
370/// What running a set of entries means on this machine: the steps in order,
371/// plus what a reviewer needs to know before saying yes.
372#[derive(Clone, Debug, Default, Eq, PartialEq)]
373pub struct Plan {
374    pub steps: Vec<Step>,
375    /// Entries that need nothing done, and why.
376    pub notes: Vec<String>,
377    /// Executables the steps need.
378    pub requirements: Vec<String>,
379    /// Whether any step asks for elevated privileges.
380    pub privileged: bool,
381    pub risk: Risk,
382    /// Entries left out because a path they write is managed by another tool.
383    pub blocked: Vec<String>,
384    /// For each step, the earlier steps it depends on: one installs a tool it
385    /// needs, or its entry runs `after` theirs. A failed step skips these.
386    pub depends: Vec<Vec<usize>>,
387    /// Commands the plan's own steps install, so needing them is no blocker.
388    pub provided: Vec<String>,
389    /// After a failed step, continue with the steps that do not depend on it
390    /// instead of stopping.
391    pub keep_going: bool,
392}
393
394impl Plan {
395    pub fn is_empty(&self) -> bool {
396        self.steps.is_empty()
397    }
398
399    pub fn entries(&self) -> Vec<(String, String)> {
400        self.steps
401            .iter()
402            .map(|step| (step.id().to_string(), step.name().to_string()))
403            .collect()
404    }
405}
406
407/// The questions planning asks the machine, so tests can answer them.
408#[derive(Clone, Copy)]
409pub struct Machine<'a> {
410    /// Whether a package manager or Flatpak lists a package as installed.
411    pub holds: &'a dyn Fn(&Probe) -> bool,
412    /// Whether a command is on the PATH.
413    pub on_path: &'a dyn Fn(&str) -> bool,
414}
415
416impl Machine<'static> {
417    /// This machine.
418    pub fn real() -> Self {
419        Machine {
420            holds: &|probe: &Probe| probe.holds(),
421            on_path: &binary_exists,
422        }
423    }
424}
425
426/// How a package entry stands on a machine.
427#[derive(Clone, Debug, Eq, PartialEq)]
428pub enum PackageState {
429    /// One of its own packages is installed; how it was found.
430    Installed(String),
431    /// None of its packages is, but its command is on the PATH from another
432    /// install (Nix, Home Manager, a download).
433    OnPath(String),
434    /// Not installed. `replaces` lists the installed packages it conflicts
435    /// with, which installing it would replace.
436    Absent { replaces: Vec<String> },
437}
438
439/// The probes for an entry's own packages on this machine: the family's
440/// package, on Arch its AUR package, and its Flatpak.
441pub(crate) fn own_probes(
442    packages: &helixforge_core::PackageNames,
443    aur: Option<&str>,
444    flatpak: Option<&str>,
445    manager: Option<PackageManager>,
446) -> Vec<Probe> {
447    let mut probes = Vec::new();
448    if let Some(manager) = manager {
449        probes.extend(
450            packages
451                .for_manager(manager)
452                .and_then(|name| Probe::package(manager, name)),
453        );
454        if manager == PackageManager::Arch {
455            probes.extend(aur.and_then(|name| Probe::package(manager, name)));
456        }
457    }
458    probes.extend(flatpak.map(|id| Probe::Flatpak(id.to_string())));
459    probes
460}
461
462/// How a package entry stands: its own packages first, then the packages it
463/// conflicts with, and its command only when neither is installed. None for
464/// an entry that is not a package.
465pub fn package_state(
466    entry: &Entry,
467    platform: &LinuxInfo,
468    machine: Machine,
469) -> Option<PackageState> {
470    let helixforge_core::Identity {
471        binary,
472        packages,
473        aur,
474        flatpak,
475        conflicts,
476    } = entry.identity()?;
477    let probes = own_probes(packages, aur, flatpak, platform.manager);
478    if let Some(found) = probes.iter().find(|probe| (machine.holds)(probe)) {
479        return Some(PackageState::Installed(found.describe()));
480    }
481    let replaces: Vec<String> = match platform.manager {
482        Some(manager) => conflicts
483            .iter()
484            .filter(|name| {
485                valid_package_name(name)
486                    && Probe::package(manager, name).is_some_and(|probe| (machine.holds)(&probe))
487            })
488            .cloned()
489            .collect(),
490        None => Vec::new(),
491    };
492    if replaces.is_empty()
493        && let Some(binary) = binary
494        && (machine.on_path)(binary)
495    {
496        return Some(PackageState::OnPath(format!("{binary} is on the PATH")));
497    }
498    Some(PackageState::Absent { replaces })
499}
500
501/// Plan one or more entries. Profiles expand into their members; a package
502/// that is already installed becomes a note instead of a step.
503pub fn plan<'a>(
504    entries: impl IntoIterator<Item = &'a Entry>,
505    platform: &LinuxInfo,
506    lookup: &dyn Fn(&str) -> Option<&'a Entry>,
507) -> Result<Plan, EngineError> {
508    plan_with(entries, platform, lookup, Machine::real())
509}
510
511/// `plan` with the machine's answers supplied.
512pub fn plan_with<'a>(
513    entries: impl IntoIterator<Item = &'a Entry>,
514    platform: &LinuxInfo,
515    lookup: &dyn Fn(&str) -> Option<&'a Entry>,
516    machine: Machine,
517) -> Result<Plan, EngineError> {
518    let mut result = Plan::default();
519    for entry in entries {
520        add_entry(
521            entry,
522            platform,
523            lookup,
524            machine,
525            &mut result,
526            &mut Vec::new(),
527        )?;
528    }
529    result.requirements.sort();
530    result.requirements.dedup();
531    arrange(&mut result, lookup);
532    info!(
533        steps = result.steps.len(),
534        risk = result.risk.label(),
535        privileged = result.privileged,
536        "planned"
537    );
538    Ok(result)
539}
540
541fn add_entry<'a>(
542    entry: &'a Entry,
543    platform: &LinuxInfo,
544    lookup: &dyn Fn(&str) -> Option<&'a Entry>,
545    machine: Machine,
546    into: &mut Plan,
547    trail: &mut Vec<String>,
548) -> Result<(), EngineError> {
549    let step = match &entry.action {
550        Action::Package {
551            binary,
552            packages,
553            aur,
554            flatpak,
555            ..
556        } => {
557            if let Some(PackageState::Installed(found) | PackageState::OnPath(found)) =
558                package_state(entry, platform, machine)
559            {
560                into.notes
561                    .push(format!("{}: already installed ({found})", entry.name));
562                return Ok(());
563            }
564            let sources = install_sources(
565                entry,
566                platform,
567                packages,
568                aur.as_deref(),
569                flatpak.as_deref(),
570            )?;
571            Step::Package {
572                id: entry.id.clone(),
573                name: entry.name.clone(),
574                binary: binary.clone(),
575                sources,
576                removal: false,
577            }
578        }
579        Action::Script { file, interactive } => {
580            // What it installs may be there already; the script still runs,
581            // since it may set more up.
582            if let Some(PackageState::Installed(found) | PackageState::OnPath(found)) =
583                package_state(entry, platform, machine)
584            {
585                into.notes.push(format!(
586                    "{}: already installed ({found}); its script runs anyway",
587                    entry.name
588                ));
589            }
590            let (program, args) = helixforge_catalog::interpreter(file);
591            Step::Script {
592                id: entry.id.clone(),
593                name: entry.name.clone(),
594                file: file.clone(),
595                program,
596                args,
597                interactive: *interactive,
598            }
599        }
600        Action::Command { line } => Step::Command {
601            id: entry.id.clone(),
602            name: entry.name.clone(),
603            line: line.clone(),
604        },
605        Action::Nix { action } => Step::Nix {
606            id: entry.id.clone(),
607            name: entry.name.clone(),
608            action: action.clone(),
609        },
610        Action::Native { action } => Step::Native {
611            id: entry.id.clone(),
612            name: entry.name.clone(),
613            action: *action,
614        },
615        Action::Profile { entries } => {
616            if trail.contains(&entry.id) {
617                return Err(EngineError::Invalid(format!(
618                    "profile {} includes itself",
619                    entry.id
620                )));
621            }
622            trail.push(entry.id.clone());
623            for member in entries {
624                let found = lookup(member)
625                    .ok_or_else(|| EngineError::UnknownMember(entry.id.clone(), member.clone()))?;
626                add_entry(found, platform, lookup, machine, into, trail)?;
627            }
628            trail.pop();
629            return Ok(());
630        }
631    };
632    if let Some(path) = managed_path(&entry.writes) {
633        into.blocked.push(format!(
634            "{}: {} is managed by Home Manager (a symlink into the Nix store); change it there instead",
635            entry.name,
636            path.display()
637        ));
638        return Ok(());
639    }
640    into.privileged |= helixforge_core::privileged(&entry.flags, &entry.action);
641    into.risk = into.risk.max(Risk::from_flags(&entry.flags));
642    into.requirements.extend(step.requirements());
643    into.steps.push(step);
644    Ok(())
645}
646
647/// Order a plan's steps so each comes after the steps it depends on (one
648/// that installs a tool it needs, or an entry it names in `needs.after`),
649/// keeping the queue's order otherwise, and record those dependencies.
650fn arrange<'a>(plan: &mut Plan, lookup: &dyn Fn(&str) -> Option<&'a Entry>) {
651    let count = plan.steps.len();
652    let entries: Vec<Option<&Entry>> = plan.steps.iter().map(|step| lookup(step.id())).collect();
653    let provides: Vec<Option<&str>> = entries
654        .iter()
655        .map(|entry| entry.and_then(|entry| entry.identity()?.binary))
656        .collect();
657    let mut depends: Vec<Vec<usize>> = vec![Vec::new(); count];
658    for (later, step) in plan.steps.iter().enumerate() {
659        let needs = step.all_requirements();
660        let after = entries[later]
661            .map(|entry| entry.needs.after.as_slice())
662            .unwrap_or(&[]);
663        for (earlier, other) in plan.steps.iter().enumerate() {
664            if earlier == later {
665                continue;
666            }
667            let tool = provides[earlier].is_some_and(|tool| needs.iter().any(|need| need == tool));
668            if tool || after.iter().any(|id| id == other.id()) {
669                depends[later].push(earlier);
670            }
671        }
672    }
673    // Kahn's algorithm, always taking the earliest ready step, so the queue's
674    // order stays where nothing forces another; a cycle keeps the rest as is.
675    let mut placed: Vec<usize> = Vec::with_capacity(count);
676    let mut done = vec![false; count];
677    while placed.len() < count {
678        let next = (0..count)
679            .find(|&index| {
680                !done[index] && depends[index].iter().all(|&dependency| done[dependency])
681            })
682            .or_else(|| (0..count).find(|&index| !done[index]));
683        let Some(next) = next else { break };
684        done[next] = true;
685        placed.push(next);
686    }
687    let mut position = vec![0; count];
688    for (new, &old) in placed.iter().enumerate() {
689        position[old] = new;
690    }
691    let mut steps: Vec<Option<Step>> = std::mem::take(&mut plan.steps)
692        .into_iter()
693        .map(Some)
694        .collect();
695    plan.steps = placed.iter().filter_map(|&old| steps[old].take()).collect();
696    plan.depends = placed
697        .iter()
698        .map(|&old| {
699            let mut list: Vec<usize> = depends[old]
700                .iter()
701                .map(|&dependency| position[dependency])
702                .filter(|&dependency| dependency < position[old])
703                .collect();
704            list.sort_unstable();
705            list
706        })
707        .collect();
708    plan.provided = provides.into_iter().flatten().map(String::from).collect();
709    plan.provided.sort();
710    plan.provided.dedup();
711}
712
713/// The first of the entry's paths that is a Home Manager symlink, or a child
714/// of one, with `~` expanded to the user's home.
715pub fn managed_path(writes: &[String]) -> Option<PathBuf> {
716    let home = std::env::var_os("HOME").map(PathBuf::from)?;
717    managed_path_in(&home, writes)
718}
719
720/// `managed_path` with an explicit home directory. Dangling links count: the
721/// store path may be garbage-collected while the link stays.
722pub fn managed_path_in(home: &Path, writes: &[String]) -> Option<PathBuf> {
723    for written in writes {
724        let path = match written.strip_prefix("~/") {
725            Some(rest) => home.join(rest),
726            None if written == "~" => home.to_path_buf(),
727            None => PathBuf::from(written),
728        };
729        for candidate in path.ancestors() {
730            if candidate == home {
731                break;
732            }
733            if let Ok(target) = std::fs::read_link(candidate)
734                && target.starts_with("/nix/store")
735            {
736                return Some(candidate.to_path_buf());
737            }
738        }
739    }
740    None
741}
742
743const FLATHUB: &str = "https://dl.flathub.org/repo/flathub.flatpakrepo";
744
745pub(crate) fn words(base: Vec<&str>, extra: &[&str]) -> Vec<String> {
746    base.into_iter()
747        .chain(extra.iter().copied())
748        .map(str::to_string)
749        .collect()
750}
751
752/// Where Flatpak keeps installed applications: the system's and the user's.
753fn flatpak_roots() -> Vec<PathBuf> {
754    let mut roots = vec![PathBuf::from("/var/lib/flatpak")];
755    if let Some(home) = std::env::var_os("HOME") {
756        roots.push(PathBuf::from(home).join(".local/share/flatpak"));
757    }
758    roots
759}
760
761/// Whether a Flatpak application is installed under any of the roots.
762pub fn flatpak_installed_in(roots: &[PathBuf], id: &str) -> bool {
763    roots.iter().any(|root| root.join("app").join(id).is_dir())
764}
765
766/// The AUR helper to use, paru before yay.
767pub fn aur_helper(exists: &dyn Fn(&str) -> bool) -> Option<&'static str> {
768    ["paru", "yay"].into_iter().find(|helper| exists(helper))
769}
770
771/// The sources that can install a package entry on this machine, in the
772/// order they are tried: the family's package, on Arch the AUR, then Flathub.
773fn install_sources(
774    entry: &Entry,
775    platform: &LinuxInfo,
776    packages: &helixforge_core::PackageNames,
777    aur: Option<&str>,
778    flatpak: Option<&str>,
779) -> Result<Vec<Source>, EngineError> {
780    let check = |name: &str| {
781        if valid_package_name(name) {
782            Ok(())
783        } else {
784            Err(EngineError::Invalid(entry.id.clone()))
785        }
786    };
787    let mut sources = Vec::new();
788    if let Some(manager) = platform.manager {
789        if let Some(package) = packages.for_manager(manager) {
790            check(package)?;
791            sources.push(Source {
792                label: manager.executable().to_string(),
793                commands: vec![words(manager.install_command(), &[package])],
794                requires: vec![manager.executable().to_string(), "sudo".to_string()],
795                probe: Probe::package(manager, package),
796            });
797        }
798        if manager == PackageManager::Arch
799            && let Some(package) = aur
800        {
801            check(package)?;
802            // A missing helper is skipped for the next source, or reported
803            // before the run starts when there is none.
804            let helper = aur_helper(&|name| binary_exists(name)).unwrap_or("paru");
805            sources.push(Source {
806                label: format!("the AUR ({helper})"),
807                commands: vec![words(vec![helper, "-S", "--needed"], &[package])],
808                requires: vec![helper.to_string()],
809                probe: Probe::package(manager, package),
810            });
811        }
812    }
813    if let Some(id) = flatpak {
814        if !helixforge_core::valid_flatpak_id(id) {
815            return Err(EngineError::Invalid(entry.id.clone()));
816        }
817        sources.push(Source {
818            label: "Flathub".to_string(),
819            commands: vec![
820                words(
821                    vec![
822                        "sudo",
823                        "flatpak",
824                        "remote-add",
825                        "--if-not-exists",
826                        "flathub",
827                    ],
828                    &[FLATHUB],
829                ),
830                words(vec!["sudo", "flatpak", "install", "flathub"], &[id]),
831            ],
832            requires: vec!["flatpak".to_string(), "sudo".to_string()],
833            probe: Some(Probe::Flatpak(id.to_string())),
834        });
835    }
836    if sources.is_empty() {
837        return Err(match platform.manager {
838            Some(_) => EngineError::UnsupportedEntry(entry.id.clone()),
839            None => EngineError::UnsupportedPlatform,
840        });
841    }
842    Ok(sources)
843}
844
845/// Plan removing package entries. An entry is removed the way it is found
846/// installed: its Flatpak, the family's package or, on Arch, its AUR package
847/// with pacman. Entries that are not installed become notes, as do entries
848/// found only by their command, which HelixForge did not install.
849pub fn plan_removal<'a>(
850    entries: impl IntoIterator<Item = &'a Entry>,
851    platform: &LinuxInfo,
852) -> Result<Plan, EngineError> {
853    plan_removal_with(entries, platform, Machine::real())
854}
855
856/// `plan_removal` with the machine's answers supplied.
857pub fn plan_removal_with<'a>(
858    entries: impl IntoIterator<Item = &'a Entry>,
859    platform: &LinuxInfo,
860    machine: Machine,
861) -> Result<Plan, EngineError> {
862    let mut result = Plan::default();
863    for entry in entries {
864        let Action::Package {
865            binary,
866            packages,
867            aur,
868            flatpak,
869            removable,
870            ..
871        } = &entry.action
872        else {
873            return Err(EngineError::NotRemovable(entry.id.clone()));
874        };
875        if !removable {
876            return Err(EngineError::NotRemovable(entry.id.clone()));
877        }
878        let probes = own_probes(
879            packages,
880            aur.as_deref(),
881            flatpak.as_deref(),
882            platform.manager,
883        );
884        let found = probes.into_iter().find(|probe| (machine.holds)(probe));
885        let on_path = binary
886            .as_deref()
887            .is_some_and(|binary| (machine.on_path)(binary));
888        let source = match found {
889            Some(Probe::Flatpak(id)) => Source {
890                label: "Flathub".to_string(),
891                commands: vec![words(vec!["sudo", "flatpak", "uninstall"], &[&id])],
892                requires: vec!["flatpak".to_string(), "sudo".to_string()],
893                probe: Some(Probe::Flatpak(id)),
894            },
895            Some(Probe::Package { manager, name }) => {
896                if !valid_package_name(&name) {
897                    return Err(EngineError::Invalid(entry.id.clone()));
898                }
899                Source {
900                    label: manager.executable().to_string(),
901                    commands: vec![words(manager.remove_command(), &[&name])],
902                    requires: vec![manager.executable().to_string(), "sudo".to_string()],
903                    probe: Some(Probe::Package { manager, name }),
904                }
905            }
906            // Solus cannot be asked; there the command stands in for its package.
907            None if on_path
908                && platform.manager == Some(PackageManager::Solus)
909                && packages.solus.is_some() =>
910            {
911                let name = packages.solus.as_deref().unwrap_or_default();
912                if !valid_package_name(name) {
913                    return Err(EngineError::Invalid(entry.id.clone()));
914                }
915                Source {
916                    label: PackageManager::Solus.executable().to_string(),
917                    commands: vec![words(PackageManager::Solus.remove_command(), &[name])],
918                    requires: vec![
919                        PackageManager::Solus.executable().to_string(),
920                        "sudo".to_string(),
921                    ],
922                    probe: None,
923                }
924            }
925            None if on_path => {
926                result.notes.push(format!(
927                    "{}: installed, but not by a package HelixForge knows; remove it the way it was installed",
928                    entry.name
929                ));
930                continue;
931            }
932            None => {
933                result
934                    .notes
935                    .push(format!("{}: not installed, nothing to remove", entry.name));
936                continue;
937            }
938        };
939        result.privileged = true;
940        result.risk = result.risk.max(Risk::Medium);
941        result.requirements.extend(source.requires.iter().cloned());
942        result.steps.push(Step::Package {
943            id: entry.id.clone(),
944            name: format!("Remove {}", entry.name),
945            binary: binary.clone(),
946            sources: vec![source],
947            removal: true,
948        });
949    }
950    result.requirements.sort();
951    result.requirements.dedup();
952    info!(steps = result.steps.len(), "planned removal");
953    Ok(result)
954}
955
956fn nix_command(action: &NixAction) -> Vec<String> {
957    match action {
958        NixAction::ProfileInstall { installable } => {
959            vec![
960                "nix".into(),
961                "profile".into(),
962                "install".into(),
963                installable.clone(),
964            ]
965        }
966        NixAction::HomeManagerSwitch { flake } => {
967            vec![
968                "home-manager".into(),
969                "switch".into(),
970                "--flake".into(),
971                flake.clone(),
972            ]
973        }
974    }
975}
976
977/// Quote a value for the POSIX shell.
978pub fn quote(value: &str) -> String {
979    format!("'{}'", value.replace('\'', "'\\''"))
980}
981
982/// The shell program that runs the steps in order and stops at the first
983/// failure. `lib_dir` is exported as `HELIXFORGE_LIB` for scripts; `this` is
984/// the HelixForge executable that native steps re-invoke.
985pub fn shell_program(plan: &Plan, lib_dir: &Path, this: &Path) -> String {
986    shell_program_with(plan, lib_dir, this, None)
987}
988
989/// `shell_program` that also writes each step's outcome to `status`, one
990/// `index state exit` line per step (done, failed, interrupted, skipped).
991pub fn shell_program_with(
992    plan: &Plan,
993    lib_dir: &Path,
994    this: &Path,
995    status: Option<&Path>,
996) -> String {
997    let mut text = format!(
998        "{LIB_VARIABLE}={}\nexport {LIB_VARIABLE}\nhf_steps={}\nhf_last=0\n\
999         hf_status() {{ [ -z \"$hf_steps\" ] || printf '%s %s %s\\n' \"$1\" \"$2\" \"$3\" >> \"$hf_steps\"; }}\n",
1000        quote(&lib_dir.to_string_lossy()),
1001        quote(
1002            &status
1003                .map(|path| path.to_string_lossy().into_owned())
1004                .unwrap_or_default()
1005        )
1006    );
1007    for (index, step) in plan.steps.iter().enumerate() {
1008        let body = match step {
1009            Step::Package {
1010                name,
1011                binary,
1012                sources,
1013                removal,
1014                ..
1015            } => package_program(name, binary.as_deref(), sources, *removal),
1016            Step::Script {
1017                file,
1018                program,
1019                args,
1020                ..
1021            } => {
1022                let directory = file.parent().unwrap_or(Path::new("."));
1023                let mut words = vec![program.clone()];
1024                words.extend(args.iter().cloned());
1025                words.push(file.to_string_lossy().into_owned());
1026                format!(
1027                    "cd {} || exit 1\n{}",
1028                    quote(&directory.to_string_lossy()),
1029                    quoted(&words)
1030                )
1031            }
1032            Step::Command { line, .. } => format!("sh -e -c {}", quote(line)),
1033            Step::Nix { action, .. } => quoted(&nix_command(action)),
1034            Step::Native { action, .. } => quoted(&[
1035                this.to_string_lossy().into_owned(),
1036                "native".to_string(),
1037                action.id().to_string(),
1038            ]),
1039            Step::Shell { program, .. } => program.clone(),
1040            Step::Update {
1041                source, targets, ..
1042            } => {
1043                let mut body = source
1044                    .commands
1045                    .iter()
1046                    .map(|command| quoted(command))
1047                    .collect::<Vec<_>>()
1048                    .join(" &&\n");
1049                body.push_str("\nhf_rc=$?\n[ \"$hf_rc\" -eq 0 ] || exit \"$hf_rc\"\n");
1050                for target in targets {
1051                    body.push_str(&format!(
1052                        "if ! {}; then\n  printf 'HelixForge: %s is not installed after the update\\n' {}\n  exit 1\nfi\n",
1053                        target.probe.shell(),
1054                        quote(&target.name)
1055                    ));
1056                }
1057                body
1058            }
1059        };
1060        let run = format!(
1061            "printf '\\n==> %s\\n' {name}\n(\n{body}\n)\nrc=$?\n\
1062             if [ \"$rc\" -eq 0 ]; then\n  hf_status {index} done 0\n\
1063             else\n  hf_failed_{index}=1\n  hf_last=$rc\n\
1064             \x20 if [ \"$rc\" -ge 128 ]; then hf_status {index} interrupted \"$rc\"; exit \"$rc\"; fi\n\
1065             \x20 hf_status {index} failed \"$rc\"\n{stop}fi\n",
1066            name = quote(step.name()),
1067            stop = if plan.keep_going {
1068                ""
1069            } else {
1070                "  exit \"$rc\"\n"
1071            },
1072        );
1073        // A step whose dependency did not finish is skipped, and counts as
1074        // not finished for the steps after it.
1075        let depends = plan.depends.get(index).map(Vec::as_slice).unwrap_or(&[]);
1076        if depends.is_empty() {
1077            text.push_str(&run);
1078        } else {
1079            let failed: String = depends
1080                .iter()
1081                .map(|other| format!("${{hf_failed_{other}}}"))
1082                .collect();
1083            let names: Vec<&str> = depends
1084                .iter()
1085                .filter_map(|&other| plan.steps.get(other))
1086                .map(Step::name)
1087                .collect();
1088            text.push_str(&format!(
1089                "if [ -n \"{failed}\" ]; then\n\
1090                 \x20 printf '\\n==> %s skipped: %s did not finish\\n' {} {}\n\
1091                 \x20 hf_failed_{index}=1\n  hf_status {index} skipped 0\nelse\n{run}fi\n",
1092                quote(step.name()),
1093                quote(&names.join(", "))
1094            ));
1095        }
1096    }
1097    text.push_str("exit \"$hf_last\"\n");
1098    text
1099}
1100
1101/// The body of a package step. An install tries each source in turn until
1102/// one succeeds and leaves the package installed; an interrupted command
1103/// stops the run instead. A removal fails if the package is still there.
1104fn package_program(name: &str, binary: Option<&str>, sources: &[Source], removal: bool) -> String {
1105    let commands = |source: &Source, indent: &str| {
1106        source
1107            .commands
1108            .iter()
1109            .map(|command| format!("{indent}{}", quoted(command)))
1110            .collect::<Vec<_>>()
1111            .join(" &&\n")
1112    };
1113    // The package manager's answer; the command only where it cannot give one.
1114    let on_path = binary.map(|binary| format!("command -v {} >/dev/null 2>&1", quote(binary)));
1115    if removal {
1116        let Some(source) = sources.first() else {
1117            return "exit 1".to_string();
1118        };
1119        let present = source
1120            .probe
1121            .as_ref()
1122            .map(Probe::shell)
1123            .or(on_path)
1124            .unwrap_or_else(|| "false".to_string());
1125        return format!(
1126            "{}\nhf_rc=$?\n[ \"$hf_rc\" -eq 0 ] || exit \"$hf_rc\"\n\
1127             if {present}; then\n  printf 'HelixForge: %s is still installed\\n' {}\n  exit 1\nfi\n\
1128             printf 'HelixForge: %s is removed\\n' {}",
1129            commands(source, ""),
1130            quote(name),
1131            quote(name)
1132        );
1133    }
1134    let mut text = String::from("hf_rc=1\n");
1135    for (index, source) in sources.iter().enumerate() {
1136        let available = if source.requires.is_empty() {
1137            "true".to_string()
1138        } else {
1139            source
1140                .requires
1141                .iter()
1142                .map(|tool| format!("command -v {} >/dev/null 2>&1", quote(tool)))
1143                .collect::<Vec<_>>()
1144                .join(" && ")
1145        };
1146        let installed = match (&source.probe, &on_path) {
1147            (Some(probe), _) => probe.shell(),
1148            (None, Some(on_path)) => on_path.clone(),
1149            (None, None) => "true".to_string(),
1150        };
1151        let label = quote(&source.label);
1152        let trying = if index == 0 {
1153            String::new()
1154        } else {
1155            format!("    printf 'HelixForge: trying %s\\n' {label}\n")
1156        };
1157        text.push_str(&format!(
1158            "if [ \"$hf_rc\" -ne 0 ]; then\n  if {available}; then\n{trying}\
1159             {}\n    hf_rc=$?\n    [ \"$hf_rc\" -lt 128 ] || exit \"$hf_rc\"\n\
1160             \x20   if [ \"$hf_rc\" -ne 0 ]; then\n\
1161             \x20     printf 'HelixForge: %s failed (exit %s)\\n' {label} \"$hf_rc\"\n\
1162             \x20   elif ! {installed}; then\n\
1163             \x20     printf 'HelixForge: %s finished, but %s is not installed\\n' {label} {}\n\
1164             \x20     hf_rc=1\n    fi\n  else\n\
1165             \x20   printf 'HelixForge: skipping %s: it needs %s\\n' {label} {}\n  fi\nfi\n",
1166            commands(source, "    "),
1167            quote(name),
1168            quote(&source.requires.join(", "))
1169        ));
1170    }
1171    text.push_str(&format!(
1172        "[ \"$hf_rc\" -eq 0 ] || printf 'HelixForge: %s was not installed\\n' {}\nexit \"$hf_rc\"",
1173        quote(name)
1174    ));
1175    text
1176}
1177
1178fn quoted(words: &[String]) -> String {
1179    words
1180        .iter()
1181        .map(|word| quote(word))
1182        .collect::<Vec<_>>()
1183        .join(" ")
1184}
1185
1186/// Check that what the plan needs is installed before anything starts.
1187/// A package step whose first source cannot run still starts when another
1188/// of its sources can.
1189pub fn check_requirements(plan: &Plan) -> Result<(), EngineError> {
1190    match missing_requirements(plan, &requirement_present)
1191        .into_iter()
1192        .next()
1193    {
1194        Some(missing) => Err(EngineError::MissingExecutable(missing)),
1195        None => Ok(()),
1196    }
1197}
1198
1199/// Whether a requirement, a command or an absolute path, is on this machine.
1200pub fn requirement_present(requirement: &str) -> bool {
1201    let path = Path::new(requirement);
1202    if path.is_absolute() {
1203        path.is_file()
1204    } else {
1205        binary_exists(requirement)
1206    }
1207}
1208
1209/// The plan's requirements that are missing and that no other source of the
1210/// steps needing them can do without.
1211pub fn missing_requirements(plan: &Plan, present: &dyn Fn(&str) -> bool) -> Vec<String> {
1212    let mut missing = Vec::new();
1213    for requirement in &plan.requirements {
1214        // A tool an earlier step installs is there by the time it is needed.
1215        if present(requirement) || plan.provided.contains(requirement) {
1216            continue;
1217        }
1218        let needing: Vec<&Step> = plan
1219            .steps
1220            .iter()
1221            .filter(|step| step.requirements().contains(requirement))
1222            .collect();
1223        let excused = !needing.is_empty()
1224            && needing.iter().all(|step| match step {
1225                Step::Package { sources, .. } => sources
1226                    .iter()
1227                    .any(|source| source.requires.iter().all(|tool| present(tool))),
1228                _ => false,
1229            });
1230        if !excused {
1231            missing.push(requirement.clone());
1232        }
1233    }
1234    missing
1235}
1236
1237/// Whether one package step left its package as intended.
1238#[derive(Clone, Debug, Eq, PartialEq)]
1239pub struct Check {
1240    /// The step's name, such as "Ripgrep" or "Remove Ripgrep".
1241    pub name: String,
1242    pub passed: bool,
1243    /// What showed it, or what is wrong.
1244    pub detail: String,
1245}
1246
1247/// Check every package and update step of a finished plan against the system.
1248pub fn verify(plan: &Plan) -> Vec<Check> {
1249    let mut checks = verify_with(plan, &Probe::holds, &binary_exists);
1250    let manager = plan.steps.iter().find_map(|step| match step {
1251        Step::Update { targets, .. } => targets.iter().find_map(|target| match &target.probe {
1252            Probe::Package { manager, .. } => Some(*manager),
1253            Probe::Flatpak(_) => None,
1254        }),
1255        _ => None,
1256    });
1257    if plan
1258        .steps
1259        .iter()
1260        .any(|step| matches!(step, Step::Update { .. }))
1261    {
1262        let after = inventory::Inventory::gather_for(manager);
1263        checks.extend(verify_updates_with(
1264            plan,
1265            &|probe| after.version(probe),
1266            &|probe| after.holds(probe),
1267        ));
1268    }
1269    checks
1270}
1271
1272/// The checks of a plan's update steps: each package's version now against
1273/// its version before. `version` and `holds` answer for the machine.
1274pub fn verify_updates_with(
1275    plan: &Plan,
1276    version: &dyn Fn(&Probe) -> Option<String>,
1277    holds: &dyn Fn(&Probe) -> bool,
1278) -> Vec<Check> {
1279    let mut checks = Vec::new();
1280    for step in &plan.steps {
1281        let Step::Update { targets, .. } = step else {
1282            continue;
1283        };
1284        for target in targets {
1285            let (passed, detail) = match (version(&target.probe), &target.from) {
1286                (Some(now), Some(from)) if &now != from => {
1287                    (true, format!("updated {from} → {now}"))
1288                }
1289                (Some(now), Some(_)) => (
1290                    false,
1291                    format!("still {now}: no newer version was installed"),
1292                ),
1293                (Some(now), None) => (true, format!("now {now}")),
1294                (None, _) if holds(&target.probe) => {
1295                    (true, "installed; no version to compare".to_string())
1296                }
1297                (None, _) => (false, "not installed after the update".to_string()),
1298            };
1299            checks.push(Check {
1300                name: format!("Update {}", target.name),
1301                passed,
1302                detail,
1303            });
1304        }
1305    }
1306    checks
1307}
1308
1309/// `verify` with the questions it asks supplied, for tests.
1310pub fn verify_with(
1311    plan: &Plan,
1312    holds: &dyn Fn(&Probe) -> bool,
1313    on_path: &dyn Fn(&str) -> bool,
1314) -> Vec<Check> {
1315    let mut checks = Vec::new();
1316    for step in &plan.steps {
1317        let Step::Package {
1318            name,
1319            binary,
1320            sources,
1321            removal,
1322            ..
1323        } = step
1324        else {
1325            continue;
1326        };
1327        let on_path = |binary: &Option<String>| binary.as_deref().is_some_and(on_path);
1328        let shown = binary.as_deref().unwrap_or("its command");
1329        let check = if *removal {
1330            let probe = sources.first().and_then(|source| source.probe.as_ref());
1331            let still = match probe {
1332                Some(probe) => holds(probe),
1333                None => on_path(binary),
1334            };
1335            Check {
1336                name: name.clone(),
1337                passed: !still,
1338                detail: match (still, probe) {
1339                    (true, Some(probe)) => format!("still installed: {}", probe.describe()),
1340                    (true, None) => format!("still installed: {shown} is on the PATH"),
1341                    (false, _) if on_path(binary) => {
1342                        format!("removed; {shown} is still on the PATH from another install")
1343                    }
1344                    (false, _) => "removed".to_string(),
1345                },
1346            }
1347        } else {
1348            // Its own packages answer; the command only where none can.
1349            let probes: Vec<&Probe> = sources
1350                .iter()
1351                .filter_map(|source| source.probe.as_ref())
1352                .collect();
1353            match probes.iter().find(|probe| holds(probe)) {
1354                Some(probe) => Check {
1355                    name: name.clone(),
1356                    passed: true,
1357                    detail: probe.describe(),
1358                },
1359                None if probes.is_empty() && on_path(binary) => Check {
1360                    name: name.clone(),
1361                    passed: true,
1362                    detail: format!("{shown} is on the PATH"),
1363                },
1364                None if probes.is_empty() && binary.is_none() => Check {
1365                    name: name.clone(),
1366                    passed: true,
1367                    detail: "finished; nothing here can confirm it".to_string(),
1368                },
1369                None if probes.is_empty() => Check {
1370                    name: name.clone(),
1371                    passed: false,
1372                    detail: format!("not found after the run: {shown} is not on the PATH"),
1373                },
1374                None => Check {
1375                    name: name.clone(),
1376                    passed: false,
1377                    detail: format!(
1378                        "not found after the run: {} {} not list it",
1379                        probes
1380                            .iter()
1381                            .map(|probe| probe.subject())
1382                            .collect::<Vec<_>>()
1383                            .join(" and "),
1384                        if probes.len() == 1 { "does" } else { "do" }
1385                    ),
1386                },
1387            }
1388        };
1389        checks.push(check);
1390    }
1391    checks
1392}
1393
1394/// The outcome of a run, as the history records it.
1395#[derive(Clone, Debug, Eq, PartialEq)]
1396pub enum Outcome {
1397    Success,
1398    Failed {
1399        exit_code: u32,
1400    },
1401    /// Stopped by a signal or by the user; nothing is assumed about what finished.
1402    Interrupted,
1403    CouldNotStart(String),
1404}
1405
1406impl Outcome {
1407    pub fn from_status(status: &ExitStatus) -> Self {
1408        match status.code() {
1409            Some(0) => Outcome::Success,
1410            Some(code) if code >= 128 => Outcome::Interrupted,
1411            Some(code) => Outcome::Failed {
1412                exit_code: code as u32,
1413            },
1414            None => Outcome::Interrupted,
1415        }
1416    }
1417
1418    pub fn succeeded(&self) -> bool {
1419        matches!(self, Outcome::Success)
1420    }
1421
1422    pub fn describe(&self) -> String {
1423        match self {
1424            Outcome::Success => "exit 0".to_string(),
1425            Outcome::Failed { exit_code } => format!("exit {exit_code}"),
1426            Outcome::Interrupted => "interrupted".to_string(),
1427            Outcome::CouldNotStart(reason) => format!("could not start: {reason}"),
1428        }
1429    }
1430}
1431
1432/// How one step of a run ended.
1433#[derive(Clone, Debug, Eq, PartialEq)]
1434pub enum StepState {
1435    Done,
1436    Failed(u32),
1437    Interrupted,
1438    /// A step it depends on did not finish.
1439    Skipped,
1440    /// The run stopped before it.
1441    NotRun,
1442}
1443
1444impl StepState {
1445    pub fn describe(&self) -> String {
1446        match self {
1447            StepState::Done => "done".to_string(),
1448            StepState::Failed(code) => format!("failed (exit {code})"),
1449            StepState::Interrupted => "interrupted".to_string(),
1450            StepState::Skipped => "skipped: a step it needs did not finish".to_string(),
1451            StepState::NotRun => "not run: the run stopped first".to_string(),
1452        }
1453    }
1454}
1455
1456/// One step of a finished run.
1457#[derive(Clone, Debug, Eq, PartialEq)]
1458pub struct StepResult {
1459    pub id: String,
1460    pub name: String,
1461    pub state: StepState,
1462}
1463
1464/// Read the step outcomes a run's program wrote to `status`; a step with no
1465/// line did not run, unless the whole program succeeded without a file.
1466pub fn step_results(plan: &Plan, outcome: &Outcome, status: Option<&Path>) -> Vec<StepResult> {
1467    let text = status.and_then(|path| std::fs::read_to_string(path).ok());
1468    let mut states: Vec<Option<StepState>> = vec![None; plan.steps.len()];
1469    for line in text.as_deref().unwrap_or_default().lines() {
1470        let mut words = line.split_whitespace();
1471        let (Some(index), Some(state), Some(code)) = (words.next(), words.next(), words.next())
1472        else {
1473            continue;
1474        };
1475        let (Ok(index), Ok(code)) = (index.parse::<usize>(), code.parse::<u32>()) else {
1476            continue;
1477        };
1478        let state = match state {
1479            "done" => StepState::Done,
1480            "failed" => StepState::Failed(code),
1481            "interrupted" => StepState::Interrupted,
1482            "skipped" => StepState::Skipped,
1483            _ => continue,
1484        };
1485        if let Some(slot) = states.get_mut(index) {
1486            *slot = Some(state);
1487        }
1488    }
1489    let unknown = if text.is_none() && outcome.succeeded() {
1490        StepState::Done
1491    } else {
1492        StepState::NotRun
1493    };
1494    plan.steps
1495        .iter()
1496        .zip(states)
1497        .map(|(step, state)| StepResult {
1498            id: step.id().to_string(),
1499            name: step.name().to_string(),
1500            state: state.unwrap_or_else(|| unknown.clone()),
1501        })
1502        .collect()
1503}
1504
1505/// A finished run: how the program ended, how each step ended, and what the
1506/// checks of the package steps that finished found.
1507#[derive(Clone, Debug, Eq, PartialEq)]
1508pub struct Finished {
1509    pub outcome: Outcome,
1510    pub checks: Vec<Check>,
1511    pub steps: Vec<StepResult>,
1512}
1513
1514impl Finished {
1515    /// Check a run's package steps if the program succeeded.
1516    pub fn new(plan: &Plan, outcome: Outcome) -> Self {
1517        Self::from_run(plan, outcome, None)
1518    }
1519
1520    /// A run whose program wrote its step outcomes to `status`: the steps
1521    /// that finished are checked even when another failed.
1522    pub fn from_run(plan: &Plan, outcome: Outcome, status: Option<&Path>) -> Self {
1523        let steps = step_results(plan, &outcome, status);
1524        let finished = Plan {
1525            steps: plan
1526                .steps
1527                .iter()
1528                .zip(&steps)
1529                .filter(|(_, result)| result.state == StepState::Done)
1530                .map(|(step, _)| step.clone())
1531                .collect(),
1532            ..Plan::default()
1533        };
1534        let checks =
1535            if outcome == Outcome::Interrupted || matches!(outcome, Outcome::CouldNotStart(_)) {
1536                Vec::new()
1537            } else {
1538                verify(&finished)
1539            };
1540        Self {
1541            outcome,
1542            checks,
1543            steps,
1544        }
1545    }
1546
1547    /// The steps that did not finish, to try again.
1548    pub fn unfinished(&self) -> Vec<&StepResult> {
1549        self.steps
1550            .iter()
1551            .filter(|step| step.state != StepState::Done)
1552            .collect()
1553    }
1554
1555    /// The program succeeded and every check passed.
1556    pub fn succeeded(&self) -> bool {
1557        self.outcome.succeeded() && self.checks.iter().all(|check| check.passed)
1558    }
1559
1560    pub fn verified(&self) -> bool {
1561        !self.checks.is_empty() && self.succeeded()
1562    }
1563
1564    /// The result as the history records it: "exit 0, verified",
1565    /// "exit 0, not verified: Meld (…)", or the outcome alone.
1566    pub fn describe(&self) -> String {
1567        let mut text = self.outcome.describe();
1568        let unfinished = self.unfinished();
1569        if !unfinished.is_empty() && self.steps.len() > 1 {
1570            text.push_str(&format!(
1571                ", {} of {} steps done",
1572                self.steps.len() - unfinished.len(),
1573                self.steps.len()
1574            ));
1575        }
1576        if self.checks.is_empty() {
1577            return text;
1578        }
1579        let failed: Vec<String> = self
1580            .checks
1581            .iter()
1582            .filter(|check| !check.passed)
1583            .map(|check| format!("{} ({})", check.name, check.detail))
1584            .collect();
1585        if failed.is_empty() {
1586            text.push_str(", verified");
1587        } else {
1588            text.push_str(&format!(", not verified: {}", failed.join("; ")));
1589        }
1590        text
1591    }
1592}
1593
1594/// A fresh, empty file for a run's step outcomes, only this user can write.
1595pub fn status_file() -> Option<PathBuf> {
1596    use std::os::unix::fs::OpenOptionsExt;
1597    let directory = std::env::temp_dir();
1598    for attempt in 0..16u32 {
1599        let nanos = std::time::SystemTime::now()
1600            .duration_since(std::time::UNIX_EPOCH)
1601            .map(|elapsed| elapsed.subsec_nanos())
1602            .unwrap_or_default();
1603        let path = directory.join(format!(
1604            "helixforge-steps-{}-{nanos}-{attempt}",
1605            std::process::id()
1606        ));
1607        if std::fs::OpenOptions::new()
1608            .write(true)
1609            .create_new(true)
1610            .mode(0o600)
1611            .open(&path)
1612            .is_ok()
1613        {
1614            return Some(path);
1615        }
1616    }
1617    None
1618}
1619
1620/// Something that can run a shell program: the caller's terminal, a
1621/// pseudo-terminal, or a test double.
1622pub trait Runner {
1623    fn run(&mut self, program: &str, lib_dir: &Path) -> Outcome;
1624}
1625
1626/// Runs in the caller's terminal, inheriting stdin and stdout.
1627pub struct InheritedRunner;
1628
1629impl Runner for InheritedRunner {
1630    fn run(&mut self, program: &str, lib_dir: &Path) -> Outcome {
1631        match Command::new("sh")
1632            .arg("-c")
1633            .arg(program)
1634            .env(LIB_VARIABLE, lib_dir)
1635            .status()
1636        {
1637            Ok(status) => Outcome::from_status(&status),
1638            Err(error) => Outcome::CouldNotStart(error.to_string()),
1639        }
1640    }
1641}
1642
1643/// Records the program instead of running it, for tests.
1644#[derive(Default)]
1645pub struct MockRunner {
1646    pub programs: Vec<String>,
1647    pub outcome: Option<Outcome>,
1648}
1649
1650impl Runner for MockRunner {
1651    fn run(&mut self, program: &str, _lib_dir: &Path) -> Outcome {
1652        self.programs.push(program.to_string());
1653        self.outcome.clone().unwrap_or(Outcome::Success)
1654    }
1655}
1656
1657/// Run a plan through a runner and record the outcome in the default history.
1658pub fn execute(
1659    plan: &Plan,
1660    lib_dir: &Path,
1661    this: &Path,
1662    runner: &mut dyn Runner,
1663) -> Result<Finished, EngineError> {
1664    let history = history::state_dir();
1665    execute_in(plan, lib_dir, this, runner, history.as_deref())
1666}
1667
1668/// Run a plan through a runner and record the outcome in `history_dir`.
1669pub fn execute_in(
1670    plan: &Plan,
1671    lib_dir: &Path,
1672    this: &Path,
1673    runner: &mut dyn Runner,
1674    history_dir: Option<&Path>,
1675) -> Result<Finished, EngineError> {
1676    check_requirements(plan)?;
1677    info!(steps = ?plan.entries(), "run started");
1678    let not_recorded = |directory: &Path, error: std::io::Error| {
1679        warn!(%error, "the run was not recorded");
1680        eprintln!(
1681            "HelixForge could not record this run in {}: {error}",
1682            directory.display()
1683        );
1684    };
1685    // The marker records the run even if this process dies before it ends.
1686    let pending = history_dir.and_then(|directory| {
1687        history::begin_in(directory, &plan.entries())
1688            .inspect_err(|error| warn!(%error, "the run start was not recorded"))
1689            .ok()
1690    });
1691    let status = status_file();
1692    let outcome = runner.run(
1693        &shell_program_with(plan, lib_dir, this, status.as_deref()),
1694        lib_dir,
1695    );
1696    let finished = Finished::from_run(plan, outcome, status.as_deref());
1697    if let Some(status) = &status {
1698        let _ = std::fs::remove_file(status);
1699    }
1700    info!(outcome = finished.describe(), "run finished");
1701    if let Some(directory) = history_dir {
1702        let written = match pending {
1703            Some(pending) => pending.finish(finished.succeeded(), &finished.describe(), None),
1704            None => history::record_in(
1705                directory,
1706                &plan.entries(),
1707                finished.succeeded(),
1708                &finished.describe(),
1709                None,
1710            ),
1711        };
1712        if let Err(error) = written {
1713            not_recorded(directory, error);
1714        }
1715    }
1716    Ok(finished)
1717}
1718
1719/// Run a native action and return its text.
1720pub fn run_native(action: NativeAction, platform: &LinuxInfo) -> String {
1721    match action {
1722        NativeAction::SystemReport => {
1723            let mut lines = vec![
1724                format!(
1725                    "Distribution:    {} ({})",
1726                    platform.pretty_name, platform.id
1727                ),
1728                format!(
1729                    "Package manager: {}",
1730                    platform
1731                        .manager
1732                        .map(|manager| manager.label())
1733                        .unwrap_or("none supported for package entries")
1734                ),
1735                format!(
1736                    "Kernel:          {}",
1737                    std::fs::read_to_string("/proc/sys/kernel/osrelease")
1738                        .unwrap_or_default()
1739                        .trim()
1740                ),
1741                format!("Architecture:    {}", std::env::consts::ARCH),
1742            ];
1743            for tool in ["sudo", "nix", "home-manager", "flatpak"] {
1744                lines.push(format!(
1745                    "{:<16} {}",
1746                    format!("{tool}:"),
1747                    if binary_exists(tool) {
1748                        "present"
1749                    } else {
1750                        "missing"
1751                    }
1752                ));
1753            }
1754            lines.join("\n") + "\n"
1755        }
1756    }
1757}
1758
1759#[cfg(test)]
1760mod tests {
1761    use super::*;
1762    use helixforge_core::PackageNames;
1763
1764    fn platform(manager: Option<PackageManager>) -> LinuxInfo {
1765        LinuxInfo {
1766            id: "test".into(),
1767            pretty_name: "Test Linux".into(),
1768            id_like: vec![],
1769            manager,
1770        }
1771    }
1772
1773    fn entry(id: &str, flags: &str, action: Action) -> Entry {
1774        Entry {
1775            id: id.into(),
1776            name: id.to_uppercase(),
1777            description: String::new(),
1778            flags: flags.into(),
1779            multi_select: true,
1780            writes: Vec::new(),
1781            action,
1782            detect: None,
1783            needs: Default::default(),
1784        }
1785    }
1786
1787    fn package_entry() -> Entry {
1788        entry(
1789            "sample",
1790            "I",
1791            Action::Package {
1792                binary: Some("helixforge-nonexistent-binary-12345".into()),
1793                packages: PackageNames {
1794                    arch: Some("sample".into()),
1795                    debian: Some("sample-deb".into()),
1796                    ..Default::default()
1797                },
1798                aur: None,
1799                flatpak: None,
1800                conflicts: Vec::new(),
1801                removable: true,
1802            },
1803        )
1804    }
1805
1806    fn none<'a>(_: &str) -> Option<&'a Entry> {
1807        None
1808    }
1809
1810    #[test]
1811    fn package_plans_use_the_native_manager_and_say_what_they_need() {
1812        let sample = package_entry();
1813        let arch = plan([&sample], &platform(Some(PackageManager::Arch)), &none).unwrap();
1814        assert_eq!(arch.steps[0].display(), "sudo pacman -S --needed sample");
1815        assert!(arch.privileged);
1816        assert_eq!(arch.risk, Risk::Medium);
1817        assert_eq!(arch.requirements, ["pacman", "sudo"]);
1818        let debian = plan([&sample], &platform(Some(PackageManager::Debian)), &none).unwrap();
1819        assert_eq!(debian.steps[0].display(), "sudo apt-get install sample-deb");
1820        assert!(matches!(
1821            plan([&sample], &platform(Some(PackageManager::Fedora)), &none),
1822            Err(EngineError::UnsupportedEntry(_))
1823        ));
1824        assert!(matches!(
1825            plan([&sample], &platform(None), &none),
1826            Err(EngineError::UnsupportedPlatform)
1827        ));
1828    }
1829
1830    #[test]
1831    fn aur_and_flatpak_routes_and_the_other_families() {
1832        let mut entry = package_entry();
1833        entry.action = Action::Package {
1834            binary: Some("helixforge-nonexistent-binary-12345".into()),
1835            packages: PackageNames {
1836                suse: Some("sample-suse".into()),
1837                ..Default::default()
1838            },
1839            aur: Some("sample-bin".into()),
1840            flatpak: Some("org.example.Sample".into()),
1841            conflicts: Vec::new(),
1842            removable: false,
1843        };
1844        let suse = plan([&entry], &platform(Some(PackageManager::Suse)), &none).unwrap();
1845        assert_eq!(
1846            suse.steps[0].display(),
1847            "sudo zypper install sample-suse  (else Flathub)"
1848        );
1849        assert_eq!(suse.requirements, ["sudo", "zypper"]);
1850        assert_eq!(
1851            suse.steps[0].fallbacks(),
1852            [format!(
1853                "if that fails, Flathub: sudo flatpak remote-add --if-not-exists flathub {FLATHUB} && sudo flatpak install flathub org.example.Sample"
1854            )]
1855        );
1856        let arch = plan([&entry], &platform(Some(PackageManager::Arch)), &none).unwrap();
1857        let shown = arch.steps[0].display();
1858        assert!(
1859            shown == "paru -S --needed sample-bin  (else Flathub)"
1860                || shown == "yay -S --needed sample-bin  (else Flathub)",
1861            "{shown}"
1862        );
1863        let fedora = plan([&entry], &platform(Some(PackageManager::Fedora)), &none).unwrap();
1864        assert_eq!(
1865            fedora.steps[0].display(),
1866            format!(
1867                "sudo flatpak remote-add --if-not-exists flathub {FLATHUB} && sudo flatpak install flathub org.example.Sample"
1868            )
1869        );
1870        assert_eq!(fedora.requirements, ["flatpak", "sudo"]);
1871        assert!(fedora.steps[0].fallbacks().is_empty());
1872        let unknown = plan([&entry], &platform(None), &none).unwrap();
1873        assert!(unknown.steps[0].display().contains("flatpak install"));
1874        assert_eq!(aur_helper(&|name| name == "yay"), Some("yay"));
1875        assert_eq!(aur_helper(&|_| true), Some("paru"));
1876        assert_eq!(aur_helper(&|_| false), None);
1877    }
1878
1879    #[test]
1880    fn installed_state_comes_from_packages_and_conflicts_before_commands() {
1881        let arch = platform(Some(PackageManager::Arch));
1882        let package = |name: &str, binary: Option<&str>, conflicts: &[&str]| {
1883            entry(
1884                name,
1885                "I",
1886                Action::Package {
1887                    binary: binary.map(Into::into),
1888                    packages: PackageNames {
1889                        arch: Some(name.into()),
1890                        ..Default::default()
1891                    },
1892                    aur: None,
1893                    flatpak: Some(format!("org.example.{name}")),
1894                    conflicts: conflicts.iter().map(|name| name.to_string()).collect(),
1895                    removable: true,
1896                },
1897            )
1898        };
1899        let staging = package("wine-staging", None, &["wine"]);
1900        let wine = package("wine", Some("wine"), &["wine-staging"]);
1901        // Plain WINE installed: wine on the PATH, pacman lists wine.
1902        let plain = Machine {
1903            holds: &|probe| probe == &Probe::package(PackageManager::Arch, "wine").unwrap(),
1904            on_path: &|binary| binary == "wine",
1905        };
1906        assert_eq!(
1907            package_state(&staging, &arch, plain),
1908            Some(PackageState::Absent {
1909                replaces: vec!["wine".into()]
1910            })
1911        );
1912        let planned = plan_with([&staging], &arch, &none, plain).unwrap();
1913        assert_eq!(
1914            planned.steps.len(),
1915            1,
1916            "staging is planned, not taken as installed"
1917        );
1918        assert!(planned.notes.is_empty());
1919        // The preflight says what it replaces.
1920        let facts = preflight::Facts {
1921            machine: plain,
1922            present: &|_| true,
1923            free_bytes: &|_| Some(u64::MAX),
1924            gpus: &Vec::new,
1925            repositories: &|| None,
1926            sizes: &|_, _| None,
1927            active_runs: &Vec::new,
1928        };
1929        let lookup = |id: &str| (id == "wine-staging").then_some(&staging);
1930        assert_eq!(
1931            preflight::check(&planned, &arch, &lookup, &facts),
1932            [preflight::Finding {
1933                severity: preflight::Severity::Warning,
1934                message: "WINE-STAGING replaces wine, which is installed; the package manager asks before removing it".into(),
1935            }]
1936        );
1937        assert_eq!(
1938            package_state(&wine, &arch, plain),
1939            Some(PackageState::Installed("pacman lists wine".into()))
1940        );
1941        // Staging installed: its wine command does not make plain WINE installed.
1942        let staged = Machine {
1943            holds: &|probe| probe == &Probe::package(PackageManager::Arch, "wine-staging").unwrap(),
1944            on_path: &|binary| binary == "wine",
1945        };
1946        assert_eq!(
1947            package_state(&wine, &arch, staged),
1948            Some(PackageState::Absent {
1949                replaces: vec!["wine-staging".into()]
1950            })
1951        );
1952        // From Nix or a download: no package listed, the command on the PATH.
1953        let elsewhere = Machine {
1954            holds: &|_| false,
1955            on_path: &|binary| binary == "wine",
1956        };
1957        assert_eq!(
1958            package_state(&wine, &arch, elsewhere),
1959            Some(PackageState::OnPath("wine is on the PATH".into()))
1960        );
1961        let noted = plan_with([&wine], &arch, &none, elsewhere).unwrap();
1962        assert!(noted.steps.is_empty());
1963        assert_eq!(
1964            noted.notes,
1965            ["WINE: already installed (wine is on the PATH)"]
1966        );
1967        // Without a command, nothing on the PATH counts.
1968        assert_eq!(
1969            package_state(&staging, &arch, elsewhere),
1970            Some(PackageState::Absent { replaces: vec![] })
1971        );
1972        // Its Flatpak counts on any family.
1973        let flatpak = Machine {
1974            holds: &|probe| matches!(probe, Probe::Flatpak(_)),
1975            on_path: &|_| false,
1976        };
1977        assert_eq!(
1978            package_state(&staging, &platform(Some(PackageManager::Fedora)), flatpak),
1979            Some(PackageState::Installed(
1980                "org.example.wine-staging is installed from Flathub".into()
1981            ))
1982        );
1983        let script = entry(
1984            "script",
1985            "",
1986            Action::Command {
1987                line: "true".into(),
1988            },
1989        );
1990        assert_eq!(package_state(&script, &arch, plain), None);
1991    }
1992
1993    #[test]
1994    fn removal_follows_how_the_package_is_installed() {
1995        let mut present = package_entry();
1996        present.action = Action::Package {
1997            binary: Some("sh".into()),
1998            packages: PackageNames {
1999                debian: Some("dash".into()),
2000                ..Default::default()
2001            },
2002            aur: Some("sh-bin".into()),
2003            flatpak: None,
2004            conflicts: Vec::new(),
2005            removable: true,
2006        };
2007        // Every package listed, and sh on the PATH.
2008        let listed = Machine {
2009            holds: &|_| true,
2010            on_path: &|binary| binary == "sh",
2011        };
2012        let remove = |entry: &Entry, manager, machine| {
2013            plan_removal_with([entry], &platform(Some(manager)), machine).unwrap()
2014        };
2015        let debian = remove(&present, PackageManager::Debian, listed);
2016        assert_eq!(debian.steps[0].display(), "sudo apt-get remove dash");
2017        assert_eq!(debian.steps[0].name(), "Remove SAMPLE");
2018        assert!(debian.privileged);
2019        assert_eq!(debian.risk, Risk::Medium);
2020        let arch = remove(&present, PackageManager::Arch, listed);
2021        assert_eq!(arch.steps[0].display(), "sudo pacman -R sh-bin");
2022        // Nothing listed: the command alone is someone else's install.
2023        let unlisted = Machine {
2024            holds: &|_| false,
2025            on_path: &|binary| binary == "sh",
2026        };
2027        let other = remove(&present, PackageManager::Arch, unlisted);
2028        assert!(other.steps.is_empty());
2029        assert!(other.notes[0].contains("not by a package"));
2030        let fedora = remove(&present, PackageManager::Fedora, listed);
2031        assert!(fedora.steps.is_empty(), "Fedora has no package for it");
2032        let nothing = Machine {
2033            holds: &|_| false,
2034            on_path: &|_| false,
2035        };
2036        let absent = remove(&package_entry(), PackageManager::Arch, nothing);
2037        assert!(absent.steps.is_empty());
2038        assert!(absent.notes[0].contains("not installed"));
2039        // The Flatpak is found and removed as such.
2040        let mut flatpak = package_entry();
2041        if let Action::Package { flatpak: id, .. } = &mut flatpak.action {
2042            *id = Some("org.example.Sample".into());
2043        }
2044        let only_flatpak = Machine {
2045            holds: &|probe| matches!(probe, Probe::Flatpak(_)),
2046            on_path: &|_| false,
2047        };
2048        let removed = remove(&flatpak, PackageManager::Arch, only_flatpak);
2049        assert_eq!(
2050            removed.steps[0].display(),
2051            "sudo flatpak uninstall org.example.Sample"
2052        );
2053        let mut fixed = package_entry();
2054        if let Action::Package { removable, .. } = &mut fixed.action {
2055            *removable = false;
2056        }
2057        assert!(matches!(
2058            plan_removal([&fixed], &platform(Some(PackageManager::Arch))),
2059            Err(EngineError::NotRemovable(_))
2060        ));
2061        let script = entry(
2062            "script",
2063            "",
2064            Action::Command {
2065                line: "true".into(),
2066            },
2067        );
2068        assert!(matches!(
2069            plan_removal([&script], &platform(Some(PackageManager::Arch))),
2070            Err(EngineError::NotRemovable(_))
2071        ));
2072        let root = tempfile::tempdir().unwrap();
2073        std::fs::create_dir_all(root.path().join("app/org.example.Sample")).unwrap();
2074        assert!(flatpak_installed_in(
2075            &[root.path().to_path_buf()],
2076            "org.example.Sample"
2077        ));
2078        assert!(!flatpak_installed_in(
2079            &[root.path().to_path_buf()],
2080            "org.example.Other"
2081        ));
2082    }
2083
2084    #[test]
2085    fn installed_packages_become_notes_and_profiles_expand() {
2086        let installed = entry(
2087            "shell",
2088            "I",
2089            Action::Package {
2090                binary: Some("sh".into()),
2091                packages: PackageNames {
2092                    arch: Some("sh".into()),
2093                    ..Default::default()
2094                },
2095                aur: None,
2096                flatpak: None,
2097                conflicts: Vec::new(),
2098                removable: true,
2099            },
2100        );
2101        let report = entry(
2102            "report",
2103            "",
2104            Action::Native {
2105                action: NativeAction::SystemReport,
2106            },
2107        );
2108        let profile = entry(
2109            "basics",
2110            "",
2111            Action::Profile {
2112                entries: vec!["shell".into(), "report".into()],
2113            },
2114        );
2115        let members = [installed.clone(), report.clone()];
2116        let lookup = |id: &str| members.iter().find(|entry| entry.id == id);
2117        let planned = plan([&profile], &platform(Some(PackageManager::Arch)), &lookup).unwrap();
2118        assert_eq!(planned.steps.len(), 1);
2119        assert_eq!(planned.steps[0].id(), "report");
2120        assert_eq!(planned.notes.len(), 1);
2121        assert!(!planned.privileged);
2122        assert_eq!(planned.risk, Risk::Low);
2123        let broken = entry(
2124            "broken",
2125            "",
2126            Action::Profile {
2127                entries: vec!["ghost".into()],
2128            },
2129        );
2130        assert!(matches!(
2131            super::plan([&broken], &platform(Some(PackageManager::Arch)), &lookup),
2132            Err(EngineError::UnknownMember(_, _))
2133        ));
2134    }
2135
2136    #[test]
2137    fn the_shell_program_runs_steps_in_order_and_stops_on_failure() {
2138        let directory = tempfile::tempdir().unwrap();
2139        let script = directory.path().join("it's.sh");
2140        std::fs::write(
2141            &script,
2142            "#!/bin/sh\nprintf 'lib=%s\\n' \"$HELIXFORGE_LIB\"\npwd\n",
2143        )
2144        .unwrap();
2145        let plan = Plan {
2146            steps: vec![
2147                Step::Script {
2148                    id: "s".into(),
2149                    name: "S".into(),
2150                    file: script,
2151                    program: "/bin/sh".into(),
2152                    args: vec!["-e".into()],
2153                    interactive: true,
2154                },
2155                Step::Command {
2156                    id: "fail".into(),
2157                    name: "Fail".into(),
2158                    line: "echo before; exit 7".into(),
2159                },
2160                Step::Command {
2161                    id: "never".into(),
2162                    name: "Never".into(),
2163                    line: "echo never".into(),
2164                },
2165            ],
2166            ..Default::default()
2167        };
2168        let program = shell_program(
2169            &plan,
2170            Path::new("/tmp/lib dir"),
2171            Path::new("/usr/bin/helixforge"),
2172        );
2173        let output = Command::new("sh").arg("-c").arg(&program).output().unwrap();
2174        let text = String::from_utf8_lossy(&output.stdout);
2175        assert_eq!(output.status.code(), Some(7));
2176        assert!(text.contains("==> S"));
2177        assert!(text.contains("lib=/tmp/lib dir"));
2178        assert!(text.contains(&directory.path().to_string_lossy().to_string()));
2179        assert!(text.contains("before"));
2180        assert!(!text.contains("never"));
2181    }
2182
2183    fn command(id: &str, line: &str) -> Step {
2184        Step::Command {
2185            id: id.into(),
2186            name: id.to_uppercase(),
2187            line: line.into(),
2188        }
2189    }
2190
2191    /// Run a plan's program with a status file; its exit code, output and results.
2192    fn run_tracked(plan: &Plan) -> (Option<i32>, String, Vec<StepResult>) {
2193        let status = status_file().unwrap();
2194        let program =
2195            shell_program_with(plan, Path::new("/lib"), Path::new("/bin/hf"), Some(&status));
2196        let output = Command::new("sh").arg("-c").arg(&program).output().unwrap();
2197        let outcome = Outcome::from_status(&output.status);
2198        let results = step_results(plan, &outcome, Some(&status));
2199        std::fs::remove_file(&status).unwrap();
2200        (
2201            output.status.code(),
2202            String::from_utf8_lossy(&output.stdout).into_owned(),
2203            results,
2204        )
2205    }
2206
2207    #[test]
2208    fn steps_run_after_what_they_need_and_their_tools_count_as_coming() {
2209        let app = entry(
2210            "flatpak-app",
2211            "I",
2212            Action::Package {
2213                binary: None,
2214                packages: PackageNames::default(),
2215                aur: None,
2216                flatpak: Some("org.example.App".into()),
2217                conflicts: Vec::new(),
2218                removable: true,
2219            },
2220        );
2221        let mut setup = entry(
2222            "flatpak-flathub",
2223            "I",
2224            Action::Command {
2225                line: "true".into(),
2226            },
2227        );
2228        setup.detect = Some(helixforge_core::Detect {
2229            binary: Some("flatpak".into()),
2230            ..Default::default()
2231        });
2232        let mut last = entry(
2233            "last",
2234            "",
2235            Action::Command {
2236                line: "true".into(),
2237            },
2238        );
2239        last.needs.after = vec!["report".into()];
2240        let report = entry(
2241            "report",
2242            "",
2243            Action::Command {
2244                line: "true".into(),
2245            },
2246        );
2247        let all = [&app, &setup, &last, &report];
2248        let lookup = |id: &str| all.iter().copied().find(|entry| entry.id == id);
2249        let nothing = Machine {
2250            holds: &|_| false,
2251            on_path: &|_| false,
2252        };
2253        let planned = plan_with(
2254            all,
2255            &platform(Some(PackageManager::Fedora)),
2256            &lookup,
2257            nothing,
2258        )
2259        .unwrap();
2260        let order: Vec<&str> = planned.steps.iter().map(Step::id).collect();
2261        assert_eq!(order, ["flatpak-flathub", "flatpak-app", "report", "last"]);
2262        assert_eq!(planned.depends, [vec![], vec![0], vec![], vec![2]]);
2263        assert_eq!(planned.provided, ["flatpak"]);
2264        // flatpak is missing now, but the first step installs it.
2265        assert!(
2266            missing_requirements(&planned, &|_| false)
2267                .iter()
2268                .all(|missing| missing != "flatpak")
2269        );
2270    }
2271
2272    #[test]
2273    fn a_failure_stops_the_run_or_skips_only_what_depends_on_it() {
2274        let mut plan = Plan {
2275            steps: vec![
2276                command("a", "echo a-ran; exit 3"),
2277                command("b", "echo b-ran"),
2278                command("c", "echo c-ran"),
2279            ],
2280            depends: vec![vec![], vec![], vec![0]],
2281            ..Default::default()
2282        };
2283        // Stop: B and C never start.
2284        let (code, out, results) = run_tracked(&plan);
2285        assert_eq!(code, Some(3));
2286        assert!(out.contains("a-ran") && !out.contains("b-ran"));
2287        let states: Vec<&StepState> = results.iter().map(|step| &step.state).collect();
2288        assert_eq!(
2289            states,
2290            [
2291                &StepState::Failed(3),
2292                &StepState::NotRun,
2293                &StepState::NotRun
2294            ]
2295        );
2296        // Keep going: B runs, C depends on A and is skipped; the run still fails.
2297        plan.keep_going = true;
2298        let (code, out, results) = run_tracked(&plan);
2299        assert_eq!(code, Some(3));
2300        assert!(out.contains("b-ran") && !out.contains("c-ran"));
2301        assert!(out.contains("==> C skipped: A did not finish"), "{out}");
2302        let states: Vec<&StepState> = results.iter().map(|step| &step.state).collect();
2303        assert_eq!(
2304            states,
2305            [&StepState::Failed(3), &StepState::Done, &StepState::Skipped]
2306        );
2307        let finished = Finished::from_run(&plan, Outcome::Failed { exit_code: 3 }, None);
2308        assert!(finished.describe().starts_with("exit 3"));
2309        let finished = Finished {
2310            outcome: Outcome::Failed { exit_code: 3 },
2311            checks: Vec::new(),
2312            steps: results,
2313        };
2314        assert_eq!(finished.describe(), "exit 3, 1 of 3 steps done");
2315        let unfinished: Vec<&str> = finished
2316            .unfinished()
2317            .iter()
2318            .map(|step| step.id.as_str())
2319            .collect();
2320        assert_eq!(unfinished, ["a", "c"]);
2321        // An interruption always stops.
2322        plan.steps[0] = command("a", "exit 130");
2323        let (code, _, results) = run_tracked(&plan);
2324        assert_eq!(code, Some(130));
2325        assert_eq!(results[0].state, StepState::Interrupted);
2326        assert_eq!(results[1].state, StepState::NotRun);
2327        // Everything done.
2328        plan.steps[0] = command("a", "true");
2329        let (code, _, results) = run_tracked(&plan);
2330        assert_eq!(code, Some(0));
2331        assert!(results.iter().all(|step| step.state == StepState::Done));
2332    }
2333
2334    #[test]
2335    fn native_steps_reinvoke_helixforge() {
2336        let plan = Plan {
2337            steps: vec![Step::Native {
2338                id: "report".into(),
2339                name: "Report".into(),
2340                action: NativeAction::SystemReport,
2341            }],
2342            ..Default::default()
2343        };
2344        let program = shell_program(&plan, Path::new("/lib"), Path::new("/opt/helix forge"));
2345        assert!(program.contains("'/opt/helix forge' 'native' 'system-report'"));
2346    }
2347
2348    #[test]
2349    fn outcomes_tell_failure_from_interruption() {
2350        assert_eq!(
2351            Outcome::from_status(&Command::new("true").status().unwrap()),
2352            Outcome::Success
2353        );
2354        assert_eq!(
2355            Outcome::from_status(&Command::new("sh").args(["-c", "exit 3"]).status().unwrap()),
2356            Outcome::Failed { exit_code: 3 }
2357        );
2358        assert_eq!(
2359            Outcome::from_status(
2360                &Command::new("sh")
2361                    .args(["-c", "kill -TERM $$"])
2362                    .status()
2363                    .unwrap()
2364            ),
2365            Outcome::Interrupted
2366        );
2367        assert_eq!(
2368            Outcome::from_status(
2369                &Command::new("sh")
2370                    .args(["-c", "exit 130"])
2371                    .status()
2372                    .unwrap()
2373            ),
2374            Outcome::Interrupted
2375        );
2376    }
2377
2378    #[test]
2379    fn execution_goes_through_the_runner_and_is_recorded() {
2380        let state = tempfile::tempdir().unwrap();
2381        let plan = Plan {
2382            steps: vec![Step::Command {
2383                id: "hello".into(),
2384                name: "Hello".into(),
2385                line: "echo hello".into(),
2386            }],
2387            ..Default::default()
2388        };
2389        let mut runner = MockRunner {
2390            outcome: Some(Outcome::Failed { exit_code: 4 }),
2391            ..Default::default()
2392        };
2393        let outcome = execute_in(
2394            &plan,
2395            Path::new("/lib"),
2396            Path::new("/bin/helixforge"),
2397            &mut runner,
2398            Some(state.path()),
2399        )
2400        .unwrap();
2401        assert_eq!(outcome.outcome, Outcome::Failed { exit_code: 4 });
2402        assert!(outcome.checks.is_empty());
2403        assert_eq!(runner.programs.len(), 1);
2404        assert!(runner.programs[0].contains("sh -e -c 'echo hello'"));
2405        let records = history::read_in(state.path(), 1).unwrap();
2406        assert_eq!(records[0].entries, ["hello"]);
2407        assert!(!records[0].success);
2408        assert_eq!(records[0].result, "exit 4");
2409    }
2410
2411    #[test]
2412    fn missing_requirements_stop_a_run_before_it_starts() {
2413        let plan = Plan {
2414            requirements: vec!["helixforge-no-such-tool".into()],
2415            ..Default::default()
2416        };
2417        let mut runner = MockRunner::default();
2418        assert!(matches!(
2419            execute(
2420                &plan,
2421                Path::new("/lib"),
2422                Path::new("/bin/helixforge"),
2423                &mut runner
2424            ),
2425            Err(EngineError::MissingExecutable(_))
2426        ));
2427        assert!(runner.programs.is_empty());
2428    }
2429
2430    #[test]
2431    fn home_manager_managed_paths_block_an_entry() {
2432        let home = tempfile::tempdir().unwrap();
2433        std::fs::create_dir_all(home.path().join(".config")).unwrap();
2434        std::os::unix::fs::symlink("/nix/store/abc-nvim", home.path().join(".config/nvim"))
2435            .unwrap();
2436        std::fs::create_dir_all(home.path().join(".config/ghostty")).unwrap();
2437        assert_eq!(
2438            managed_path_in(home.path(), &["~/.config/nvim/init.lua".to_string()]),
2439            Some(home.path().join(".config/nvim"))
2440        );
2441        assert_eq!(
2442            managed_path_in(home.path(), &["~/.config/ghostty/config".to_string()]),
2443            None
2444        );
2445        // Planning reads HOME; an unmanaged absolute path outside it is fine.
2446        let mut free = entry(
2447            "free",
2448            "FM",
2449            Action::Command {
2450                line: "true".into(),
2451            },
2452        );
2453        free.writes = vec![
2454            home.path()
2455                .join(".config/ghostty")
2456                .to_string_lossy()
2457                .into_owned(),
2458        ];
2459        let planned = plan([&free], &platform(None), &none).unwrap();
2460        assert_eq!(planned.steps.len(), 1);
2461        assert!(planned.blocked.is_empty());
2462        let mut managed = entry(
2463            "nvim",
2464            "FM",
2465            Action::Command {
2466                line: "true".into(),
2467            },
2468        );
2469        managed.writes = vec![
2470            home.path()
2471                .join(".config/nvim")
2472                .to_string_lossy()
2473                .into_owned(),
2474        ];
2475        let planned = plan([&managed], &platform(None), &none).unwrap();
2476        assert!(planned.steps.is_empty());
2477        assert!(planned.blocked[0].contains("Home Manager"));
2478    }
2479
2480    /// A package step whose sources are harmless commands: `bin` is a
2481    /// temporary directory put first on the PATH, where sources create or
2482    /// delete the executable `hf-sample`.
2483    fn fake_package(bin: &Path, sources: Vec<(&str, &str, &[&str])>, removal: bool) -> Plan {
2484        let sample = bin.join("hf-sample");
2485        let sample = quote(&sample.to_string_lossy());
2486        Plan {
2487            steps: vec![Step::Package {
2488                id: "sample".into(),
2489                name: "Sample".into(),
2490                binary: Some("hf-sample".into()),
2491                sources: sources
2492                    .into_iter()
2493                    .map(|(label, line, requires)| Source {
2494                        label: label.into(),
2495                        commands: vec![vec![
2496                            "sh".into(),
2497                            "-c".into(),
2498                            line.replace("SAMPLE", &sample),
2499                        ]],
2500                        requires: requires.iter().map(|tool| tool.to_string()).collect(),
2501                        probe: None,
2502                    })
2503                    .collect(),
2504                removal,
2505            }],
2506            ..Default::default()
2507        }
2508    }
2509
2510    fn run_in_shell(plan: &Plan, bin: &Path) -> (Option<i32>, String) {
2511        let path = format!(
2512            "{}:{}",
2513            bin.display(),
2514            std::env::var("PATH").unwrap_or_default()
2515        );
2516        let output = Command::new("sh")
2517            .arg("-c")
2518            .arg(shell_program(plan, Path::new("/lib"), Path::new("/bin/hf")))
2519            .env("PATH", path)
2520            .output()
2521            .unwrap();
2522        (
2523            output.status.code(),
2524            String::from_utf8_lossy(&output.stdout).into_owned(),
2525        )
2526    }
2527
2528    const CREATE: &str = "printf '#!/bin/sh\\n' > SAMPLE && chmod +x SAMPLE";
2529
2530    #[test]
2531    fn a_package_falls_back_to_the_next_source_that_works() {
2532        let bin = tempfile::tempdir().unwrap();
2533        let plan = fake_package(
2534            bin.path(),
2535            vec![
2536                ("first", "exit 3", &["sh"]),
2537                ("second", CREATE, &["helixforge-no-such-tool"]),
2538                ("third", CREATE, &["sh"]),
2539                ("fourth", "echo never", &["sh"]),
2540            ],
2541            false,
2542        );
2543        let (code, text) = run_in_shell(&plan, bin.path());
2544        assert_eq!(code, Some(0), "{text}");
2545        assert!(text.contains("HelixForge: first failed (exit 3)"), "{text}");
2546        assert!(
2547            text.contains("HelixForge: skipping second: it needs helixforge-no-such-tool"),
2548            "{text}"
2549        );
2550        assert!(text.contains("HelixForge: trying third"), "{text}");
2551        assert!(!text.contains("never"), "{text}");
2552        assert!(bin.path().join("hf-sample").exists());
2553    }
2554
2555    #[test]
2556    fn a_source_that_installs_nothing_is_not_trusted() {
2557        let bin = tempfile::tempdir().unwrap();
2558        let alone = fake_package(bin.path(), vec![("quiet", "true", &["sh"])], false);
2559        let (code, text) = run_in_shell(&alone, bin.path());
2560        assert_eq!(code, Some(1), "{text}");
2561        assert!(
2562            text.contains("HelixForge: quiet finished, but Sample is not installed"),
2563            "{text}"
2564        );
2565        assert!(
2566            text.contains("HelixForge: Sample was not installed"),
2567            "{text}"
2568        );
2569        let rescued = fake_package(
2570            bin.path(),
2571            vec![("quiet", "true", &["sh"]), ("real", CREATE, &["sh"])],
2572            false,
2573        );
2574        let (code, text) = run_in_shell(&rescued, bin.path());
2575        assert_eq!(code, Some(0), "{text}");
2576        assert!(text.contains("HelixForge: trying real"), "{text}");
2577    }
2578
2579    #[test]
2580    fn an_interrupted_source_stops_the_run_without_falling_back() {
2581        let bin = tempfile::tempdir().unwrap();
2582        let plan = fake_package(
2583            bin.path(),
2584            vec![("first", "exit 130", &["sh"]), ("second", CREATE, &["sh"])],
2585            false,
2586        );
2587        let (code, text) = run_in_shell(&plan, bin.path());
2588        assert_eq!(code, Some(130), "{text}");
2589        assert!(!text.contains("trying second"), "{text}");
2590        assert!(!bin.path().join("hf-sample").exists());
2591    }
2592
2593    #[test]
2594    fn a_removal_fails_when_the_package_is_still_there() {
2595        let bin = tempfile::tempdir().unwrap();
2596        std::fs::write(bin.path().join("hf-sample"), "#!/bin/sh\n").unwrap();
2597        std::fs::set_permissions(
2598            bin.path().join("hf-sample"),
2599            std::os::unix::fs::PermissionsExt::from_mode(0o755),
2600        )
2601        .unwrap();
2602        let kept = fake_package(bin.path(), vec![("keeper", "true", &["sh"])], true);
2603        let (code, text) = run_in_shell(&kept, bin.path());
2604        assert_eq!(code, Some(1), "{text}");
2605        assert!(
2606            text.contains("HelixForge: Sample is still installed"),
2607            "{text}"
2608        );
2609        let removed = fake_package(bin.path(), vec![("remover", "rm SAMPLE", &["sh"])], true);
2610        let (code, text) = run_in_shell(&removed, bin.path());
2611        assert_eq!(code, Some(0), "{text}");
2612        assert!(text.contains("HelixForge: Sample is removed"), "{text}");
2613        let failing = fake_package(bin.path(), vec![("remover", "exit 5", &["sh"])], true);
2614        assert_eq!(run_in_shell(&failing, bin.path()).0, Some(5));
2615    }
2616
2617    #[test]
2618    fn probes_ask_the_package_manager_and_flatpak() {
2619        let pacman = Probe::package(PackageManager::Arch, "ripgrep").unwrap();
2620        assert_eq!(pacman.shell(), "'pacman' '-Q' 'ripgrep' >/dev/null 2>&1");
2621        assert_eq!(pacman.describe(), "pacman lists ripgrep");
2622        let rpm = Probe::package(PackageManager::Suse, "lynx").unwrap();
2623        assert_eq!(rpm.shell(), "'rpm' '-q' 'lynx' >/dev/null 2>&1");
2624        let dpkg = Probe::package(PackageManager::Debian, "lynx").unwrap();
2625        assert_eq!(
2626            dpkg.shell(),
2627            "[ \"$(dpkg-query -W -f='${db:Status-Status}' 'lynx' 2>/dev/null)\" = installed ]"
2628        );
2629        assert_eq!(Probe::package(PackageManager::Solus, "lynx"), None);
2630        let home = tempfile::tempdir().unwrap();
2631        let flatpak = Probe::Flatpak("org.example.Sample".into());
2632        let test = |probe: &Probe| {
2633            Command::new("sh")
2634                .arg("-c")
2635                .arg(probe.shell())
2636                .env("HOME", home.path())
2637                .status()
2638                .unwrap()
2639                .success()
2640        };
2641        assert!(!test(&flatpak));
2642        std::fs::create_dir_all(
2643            home.path()
2644                .join(".local/share/flatpak/app/org.example.Sample"),
2645        )
2646        .unwrap();
2647        assert!(test(&flatpak));
2648    }
2649
2650    #[test]
2651    fn verification_checks_installs_and_removals() {
2652        let source = |probe: Option<Probe>| Source {
2653            label: "pacman".into(),
2654            commands: Vec::new(),
2655            requires: Vec::new(),
2656            probe,
2657        };
2658        let listed = Probe::package(PackageManager::Arch, "meld").unwrap();
2659        let probed =
2660            |name: &str, binary: &str, probe: Option<Probe>, removal: bool| Step::Package {
2661                id: name.to_lowercase(),
2662                name: name.into(),
2663                binary: Some(binary.into()),
2664                sources: vec![source(probe)],
2665                removal,
2666            };
2667        let step = |name: &str, binary: &str, removal: bool| {
2668            probed(name, binary, Some(listed.clone()), removal)
2669        };
2670        let plan = Plan {
2671            steps: vec![
2672                // Without a probe (Solus) the command is the only answer.
2673                probed("Ripgrep", "rg", None, false),
2674                step("Meld", "meld", false),
2675                Step::Command {
2676                    id: "c".into(),
2677                    name: "C".into(),
2678                    line: "true".into(),
2679                },
2680            ],
2681            ..Default::default()
2682        };
2683        let on_path = |binary: &str| binary == "rg";
2684        let checks = verify_with(&plan, &|_| true, &on_path);
2685        assert_eq!(checks.len(), 2);
2686        assert_eq!(checks[0].detail, "rg is on the PATH");
2687        assert_eq!(checks[1].detail, "pacman lists meld");
2688        assert!(checks.iter().all(|check| check.passed));
2689        let checks = verify_with(&plan, &|_| false, &on_path);
2690        assert!(checks[0].passed);
2691        assert!(!checks[1].passed);
2692        assert_eq!(
2693            checks[1].detail,
2694            "not found after the run: pacman (meld) does not list it"
2695        );
2696        // A command from another install is no proof where the package
2697        // manager can be asked.
2698        let elsewhere = verify_with(&plan, &|_| false, &|_| true);
2699        assert!(!elsewhere[1].passed);
2700        let finished = Finished {
2701            outcome: Outcome::Success,
2702            checks,
2703            steps: Vec::new(),
2704        };
2705        assert!(!finished.succeeded());
2706        assert_eq!(
2707            finished.describe(),
2708            "exit 0, not verified: Meld (not found after the run: pacman (meld) does not list it)"
2709        );
2710        let removal = Plan {
2711            steps: vec![step("Remove Meld", "meld", true)],
2712            ..Default::default()
2713        };
2714        let gone = verify_with(&removal, &|_| false, &|_| false);
2715        assert!(gone[0].passed);
2716        assert_eq!(gone[0].detail, "removed");
2717        let other = verify_with(&removal, &|_| false, &|_| true);
2718        assert!(other[0].passed);
2719        assert!(other[0].detail.contains("from another install"));
2720        let still = verify_with(&removal, &|_| true, &|_| false);
2721        assert!(!still[0].passed);
2722        assert_eq!(still[0].detail, "still installed: pacman lists meld");
2723        let passed = Finished {
2724            outcome: Outcome::Success,
2725            checks: gone,
2726            steps: Vec::new(),
2727        };
2728        assert!(passed.verified());
2729        assert_eq!(passed.describe(), "exit 0, verified");
2730        let plain = Finished {
2731            outcome: Outcome::Failed { exit_code: 2 },
2732            checks: Vec::new(),
2733            steps: Vec::new(),
2734        };
2735        assert!(!plain.verified());
2736        assert_eq!(plain.describe(), "exit 2");
2737    }
2738
2739    #[test]
2740    fn a_missing_first_source_does_not_block_a_package_with_another() {
2741        let sources = |first: &str, second: &str| {
2742            vec![
2743                Source {
2744                    label: "first".into(),
2745                    commands: Vec::new(),
2746                    requires: vec![first.into()],
2747                    probe: None,
2748                },
2749                Source {
2750                    label: "second".into(),
2751                    commands: Vec::new(),
2752                    requires: vec![second.into()],
2753                    probe: None,
2754                },
2755            ]
2756        };
2757        let plan = |sources| Plan {
2758            steps: vec![Step::Package {
2759                id: "p".into(),
2760                name: "P".into(),
2761                binary: Some("p".into()),
2762                sources,
2763                removal: false,
2764            }],
2765            requirements: vec!["helixforge-no-such-tool".into()],
2766            ..Default::default()
2767        };
2768        assert!(check_requirements(&plan(sources("helixforge-no-such-tool", "sh"))).is_ok());
2769        assert!(matches!(
2770            check_requirements(&plan(sources(
2771                "helixforge-no-such-tool",
2772                "helixforge-no-such-tool-2"
2773            ))),
2774            Err(EngineError::MissingExecutable(_))
2775        ));
2776    }
2777
2778    #[test]
2779    fn quoting_keeps_metacharacters_literal() {
2780        let value = "spaces ' $(false) ; \" end";
2781        let output = Command::new("sh")
2782            .arg("-c")
2783            .arg(format!("printf %s {}", quote(value)))
2784            .output()
2785            .unwrap();
2786        assert_eq!(String::from_utf8(output.stdout).unwrap(), value);
2787    }
2788}