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