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