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