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