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