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