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/// The shell program that runs the steps in order and stops at the first
1017/// failure. `lib_dir` is exported as `HELIXFORGE_LIB` for scripts; `this` is
1018/// the HelixForge executable that native steps re-invoke.
1019pub fn shell_program(plan: &Plan, lib_dir: &Path, this: &Path) -> String {
1020    shell_program_with(plan, lib_dir, this, None)
1021}
1022
1023/// `shell_program` that also writes each step's outcome to `status`, one
1024/// `index state exit` line per step (done, failed, interrupted, skipped).
1025pub fn shell_program_with(
1026    plan: &Plan,
1027    lib_dir: &Path,
1028    this: &Path,
1029    status: Option<&Path>,
1030) -> String {
1031    let mut text = format!(
1032        "{LIB_VARIABLE}={}\nexport {LIB_VARIABLE}\nhf_steps={}\nhf_last=0\n\
1033         hf_status() {{ [ -z \"$hf_steps\" ] || printf '%s %s %s\\n' \"$1\" \"$2\" \"$3\" >> \"$hf_steps\"; }}\n",
1034        quote(&lib_dir.to_string_lossy()),
1035        quote(
1036            &status
1037                .map(|path| path.to_string_lossy().into_owned())
1038                .unwrap_or_default()
1039        )
1040    );
1041    for (index, step) in plan.steps.iter().enumerate() {
1042        let body = match step {
1043            Step::Package {
1044                name,
1045                binary,
1046                sources,
1047                removal,
1048                ..
1049            } => package_program(name, binary.as_deref(), sources, *removal),
1050            Step::Script {
1051                file,
1052                program,
1053                args,
1054                ..
1055            } => {
1056                let directory = file.parent().unwrap_or(Path::new("."));
1057                let mut words = vec![program.clone()];
1058                words.extend(args.iter().cloned());
1059                words.push(file.to_string_lossy().into_owned());
1060                format!(
1061                    "cd {} || exit 1\n{}",
1062                    quote(&directory.to_string_lossy()),
1063                    quoted(&words)
1064                )
1065            }
1066            Step::Command { line, .. } => format!("sh -e -c {}", quote(line)),
1067            Step::Nix { action, .. } => quoted(&nix_command(action)),
1068            Step::Native { action, .. } => quoted(&[
1069                this.to_string_lossy().into_owned(),
1070                "native".to_string(),
1071                action.id().to_string(),
1072            ]),
1073            Step::Shell { program, .. } => program.clone(),
1074            Step::Update {
1075                source, targets, ..
1076            } => {
1077                let mut body = source
1078                    .commands
1079                    .iter()
1080                    .map(|command| quoted(command))
1081                    .collect::<Vec<_>>()
1082                    .join(" &&\n");
1083                body.push_str("\nhf_rc=$?\n[ \"$hf_rc\" -eq 0 ] || exit \"$hf_rc\"\n");
1084                for target in targets {
1085                    body.push_str(&format!(
1086                        "if ! {}; then\n  printf 'HelixForge: %s is not installed after the update\\n' {}\n  exit 1\nfi\n",
1087                        target.probe.shell(),
1088                        quote(&target.name)
1089                    ));
1090                }
1091                body
1092            }
1093        };
1094        let run = format!(
1095            "printf '\\n==> %s\\n' {name}\n(\n{body}\n)\nrc=$?\n\
1096             if [ \"$rc\" -eq 0 ]; then\n  hf_status {index} done 0\n\
1097             else\n  hf_failed_{index}=1\n  hf_last=$rc\n\
1098             \x20 if [ \"$rc\" -ge 128 ]; then hf_status {index} interrupted \"$rc\"; exit \"$rc\"; fi\n\
1099             \x20 hf_status {index} failed \"$rc\"\n{stop}fi\n",
1100            name = quote(step.name()),
1101            stop = if plan.keep_going {
1102                ""
1103            } else {
1104                "  exit \"$rc\"\n"
1105            },
1106        );
1107        // A step whose dependency did not finish is skipped, and counts as
1108        // not finished for the steps after it.
1109        let depends = plan.depends.get(index).map(Vec::as_slice).unwrap_or(&[]);
1110        if depends.is_empty() {
1111            text.push_str(&run);
1112        } else {
1113            let failed: String = depends
1114                .iter()
1115                .map(|other| format!("${{hf_failed_{other}}}"))
1116                .collect();
1117            let names: Vec<&str> = depends
1118                .iter()
1119                .filter_map(|&other| plan.steps.get(other))
1120                .map(Step::name)
1121                .collect();
1122            text.push_str(&format!(
1123                "if [ -n \"{failed}\" ]; then\n\
1124                 \x20 printf '\\n==> %s skipped: %s did not finish\\n' {} {}\n\
1125                 \x20 hf_failed_{index}=1\n  hf_status {index} skipped 0\nelse\n{run}fi\n",
1126                quote(step.name()),
1127                quote(&names.join(", "))
1128            ));
1129        }
1130    }
1131    text.push_str("exit \"$hf_last\"\n");
1132    text
1133}
1134
1135/// The body of a package step. An install tries each source in turn until
1136/// one succeeds and leaves the package installed; an interrupted command
1137/// stops the run instead. A removal fails if the package is still there.
1138fn package_program(name: &str, binary: Option<&str>, sources: &[Source], removal: bool) -> String {
1139    let commands = |source: &Source, indent: &str| {
1140        source
1141            .commands
1142            .iter()
1143            .map(|command| format!("{indent}{}", quoted(command)))
1144            .collect::<Vec<_>>()
1145            .join(" &&\n")
1146    };
1147    // The package manager's answer; the command only where it cannot give one.
1148    let on_path = binary.map(|binary| format!("command -v {} >/dev/null 2>&1", quote(binary)));
1149    if removal {
1150        let Some(source) = sources.first() else {
1151            return "exit 1".to_string();
1152        };
1153        let present = source
1154            .probe
1155            .as_ref()
1156            .map(Probe::shell)
1157            .or(on_path)
1158            .unwrap_or_else(|| "false".to_string());
1159        return format!(
1160            "{}\nhf_rc=$?\n[ \"$hf_rc\" -eq 0 ] || exit \"$hf_rc\"\n\
1161             if {present}; then\n  printf 'HelixForge: %s is still installed\\n' {}\n  exit 1\nfi\n\
1162             printf 'HelixForge: %s is removed\\n' {}",
1163            commands(source, ""),
1164            quote(name),
1165            quote(name)
1166        );
1167    }
1168    let mut text = String::from("hf_rc=1\n");
1169    for (index, source) in sources.iter().enumerate() {
1170        let available = if source.requires.is_empty() {
1171            "true".to_string()
1172        } else {
1173            source
1174                .requires
1175                .iter()
1176                .map(|tool| format!("command -v {} >/dev/null 2>&1", quote(tool)))
1177                .collect::<Vec<_>>()
1178                .join(" && ")
1179        };
1180        let installed = match (&source.probe, &on_path) {
1181            (Some(probe), _) => probe.shell(),
1182            (None, Some(on_path)) => on_path.clone(),
1183            (None, None) => "true".to_string(),
1184        };
1185        let label = quote(&source.label);
1186        let trying = if index == 0 {
1187            String::new()
1188        } else {
1189            format!("    printf 'HelixForge: trying %s\\n' {label}\n")
1190        };
1191        text.push_str(&format!(
1192            "if [ \"$hf_rc\" -ne 0 ]; then\n  if {available}; then\n{trying}\
1193             {}\n    hf_rc=$?\n    [ \"$hf_rc\" -lt 128 ] || exit \"$hf_rc\"\n\
1194             \x20   if [ \"$hf_rc\" -ne 0 ]; then\n\
1195             \x20     printf 'HelixForge: %s failed (exit %s)\\n' {label} \"$hf_rc\"\n\
1196             \x20   elif ! {installed}; then\n\
1197             \x20     printf 'HelixForge: %s finished, but %s is not installed\\n' {label} {}\n\
1198             \x20     hf_rc=1\n    fi\n  else\n\
1199             \x20   printf 'HelixForge: skipping %s: it needs %s\\n' {label} {}\n  fi\nfi\n",
1200            commands(source, "    "),
1201            quote(name),
1202            quote(&source.requires.join(", "))
1203        ));
1204    }
1205    text.push_str(&format!(
1206        "[ \"$hf_rc\" -eq 0 ] || printf 'HelixForge: %s was not installed\\n' {}\nexit \"$hf_rc\"",
1207        quote(name)
1208    ));
1209    text
1210}
1211
1212fn quoted(words: &[String]) -> String {
1213    words
1214        .iter()
1215        .map(|word| quote(word))
1216        .collect::<Vec<_>>()
1217        .join(" ")
1218}
1219
1220/// Check that what the plan needs is installed before anything starts.
1221/// A package step whose first source cannot run still starts when another
1222/// of its sources can.
1223pub fn check_requirements(plan: &Plan) -> Result<(), EngineError> {
1224    match missing_requirements(plan, &requirement_present)
1225        .into_iter()
1226        .next()
1227    {
1228        Some(missing) => Err(EngineError::MissingExecutable(missing)),
1229        None => Ok(()),
1230    }
1231}
1232
1233/// Whether a requirement, a command or an absolute path, is on this machine.
1234pub fn requirement_present(requirement: &str) -> bool {
1235    let path = Path::new(requirement);
1236    if path.is_absolute() {
1237        path.is_file()
1238    } else {
1239        binary_exists(requirement)
1240    }
1241}
1242
1243/// The plan's requirements that are missing and that no other source of the
1244/// steps needing them can do without.
1245pub fn missing_requirements(plan: &Plan, present: &dyn Fn(&str) -> bool) -> Vec<String> {
1246    let mut missing = Vec::new();
1247    for requirement in &plan.requirements {
1248        // A tool an earlier step installs is there by the time it is needed.
1249        if present(requirement) || plan.provided.contains(requirement) {
1250            continue;
1251        }
1252        let needing: Vec<&Step> = plan
1253            .steps
1254            .iter()
1255            .filter(|step| step.requirements().contains(requirement))
1256            .collect();
1257        let excused = !needing.is_empty()
1258            && needing.iter().all(|step| match step {
1259                Step::Package { sources, .. } => sources
1260                    .iter()
1261                    .any(|source| source.requires.iter().all(|tool| present(tool))),
1262                _ => false,
1263            });
1264        if !excused {
1265            missing.push(requirement.clone());
1266        }
1267    }
1268    missing
1269}
1270
1271/// Whether one package step left its package as intended.
1272#[derive(Clone, Debug, Eq, PartialEq)]
1273pub struct Check {
1274    /// The step's name, such as "Ripgrep" or "Remove Ripgrep".
1275    pub name: String,
1276    pub passed: bool,
1277    /// What showed it, or what is wrong.
1278    pub detail: String,
1279}
1280
1281/// Check every package and update step of a finished plan against the system.
1282pub fn verify(plan: &Plan) -> Vec<Check> {
1283    let mut checks = verify_with(plan, &Probe::holds, &binary_exists);
1284    let manager = plan.steps.iter().find_map(|step| match step {
1285        Step::Update { targets, .. } => targets.iter().find_map(|target| match &target.probe {
1286            Probe::Package { manager, .. } => Some(*manager),
1287            Probe::Flatpak(_) => None,
1288        }),
1289        _ => None,
1290    });
1291    if plan
1292        .steps
1293        .iter()
1294        .any(|step| matches!(step, Step::Update { .. }))
1295    {
1296        let after = inventory::Inventory::gather_for(manager);
1297        checks.extend(verify_updates_with(
1298            plan,
1299            &|probe| after.version(probe),
1300            &|probe| after.holds(probe),
1301        ));
1302    }
1303    checks
1304}
1305
1306/// The checks of a plan's update steps: each package's version now against
1307/// its version before. `version` and `holds` answer for the machine.
1308pub fn verify_updates_with(
1309    plan: &Plan,
1310    version: &dyn Fn(&Probe) -> Option<String>,
1311    holds: &dyn Fn(&Probe) -> bool,
1312) -> Vec<Check> {
1313    let mut checks = Vec::new();
1314    for step in &plan.steps {
1315        let Step::Update { targets, .. } = step else {
1316            continue;
1317        };
1318        for target in targets {
1319            let (passed, detail) = match (version(&target.probe), &target.from) {
1320                (Some(now), Some(from)) if &now != from => {
1321                    (true, format!("updated {from} → {now}"))
1322                }
1323                (Some(now), Some(_)) => (
1324                    false,
1325                    format!("still {now}: no newer version was installed"),
1326                ),
1327                (Some(now), None) => (true, format!("now {now}")),
1328                (None, _) if holds(&target.probe) => {
1329                    (true, "installed; no version to compare".to_string())
1330                }
1331                (None, _) => (false, "not installed after the update".to_string()),
1332            };
1333            checks.push(Check {
1334                name: format!("Update {}", target.name),
1335                passed,
1336                detail,
1337            });
1338        }
1339    }
1340    checks
1341}
1342
1343/// `verify` with the questions it asks supplied, for tests.
1344pub fn verify_with(
1345    plan: &Plan,
1346    holds: &dyn Fn(&Probe) -> bool,
1347    on_path: &dyn Fn(&str) -> bool,
1348) -> Vec<Check> {
1349    let mut checks = Vec::new();
1350    for step in &plan.steps {
1351        let Step::Package {
1352            name,
1353            binary,
1354            sources,
1355            removal,
1356            ..
1357        } = step
1358        else {
1359            continue;
1360        };
1361        let on_path = |binary: &Option<String>| binary.as_deref().is_some_and(on_path);
1362        let shown = binary.as_deref().unwrap_or("its command");
1363        let check = if *removal {
1364            let probe = sources.first().and_then(|source| source.probe.as_ref());
1365            let still = match probe {
1366                Some(probe) => holds(probe),
1367                None => on_path(binary),
1368            };
1369            Check {
1370                name: name.clone(),
1371                passed: !still,
1372                detail: match (still, probe) {
1373                    (true, Some(probe)) => format!("still installed: {}", probe.describe()),
1374                    (true, None) => format!("still installed: {shown} is on the PATH"),
1375                    (false, _) if on_path(binary) => {
1376                        format!("removed; {shown} is still on the PATH from another install")
1377                    }
1378                    (false, _) => "removed".to_string(),
1379                },
1380            }
1381        } else {
1382            // Its own packages answer; the command only where none can.
1383            let probes: Vec<&Probe> = sources
1384                .iter()
1385                .filter_map(|source| source.probe.as_ref())
1386                .collect();
1387            match probes.iter().find(|probe| holds(probe)) {
1388                Some(probe) => Check {
1389                    name: name.clone(),
1390                    passed: true,
1391                    detail: probe.describe(),
1392                },
1393                None if probes.is_empty() && on_path(binary) => Check {
1394                    name: name.clone(),
1395                    passed: true,
1396                    detail: format!("{shown} is on the PATH"),
1397                },
1398                None if probes.is_empty() && binary.is_none() => Check {
1399                    name: name.clone(),
1400                    passed: true,
1401                    detail: "finished; nothing here can confirm it".to_string(),
1402                },
1403                None if probes.is_empty() => Check {
1404                    name: name.clone(),
1405                    passed: false,
1406                    detail: format!("not found after the run: {shown} is not on the PATH"),
1407                },
1408                None => Check {
1409                    name: name.clone(),
1410                    passed: false,
1411                    detail: format!(
1412                        "not found after the run: {} {} not list it",
1413                        probes
1414                            .iter()
1415                            .map(|probe| probe.subject())
1416                            .collect::<Vec<_>>()
1417                            .join(" and "),
1418                        if probes.len() == 1 { "does" } else { "do" }
1419                    ),
1420                },
1421            }
1422        };
1423        checks.push(check);
1424    }
1425    checks
1426}
1427
1428/// The outcome of a run, as the history records it.
1429#[derive(Clone, Debug, Eq, PartialEq)]
1430pub enum Outcome {
1431    Success,
1432    Failed {
1433        exit_code: u32,
1434    },
1435    /// Stopped by a signal or by the user; nothing is assumed about what finished.
1436    Interrupted,
1437    CouldNotStart(String),
1438}
1439
1440impl Outcome {
1441    pub fn from_status(status: &ExitStatus) -> Self {
1442        match status.code() {
1443            Some(0) => Outcome::Success,
1444            Some(code) if code >= 128 => Outcome::Interrupted,
1445            Some(code) => Outcome::Failed {
1446                exit_code: code as u32,
1447            },
1448            None => Outcome::Interrupted,
1449        }
1450    }
1451
1452    pub fn succeeded(&self) -> bool {
1453        matches!(self, Outcome::Success)
1454    }
1455
1456    pub fn describe(&self) -> String {
1457        match self {
1458            Outcome::Success => "exit 0".to_string(),
1459            Outcome::Failed { exit_code } => format!("exit {exit_code}"),
1460            Outcome::Interrupted => "interrupted".to_string(),
1461            Outcome::CouldNotStart(reason) => format!("could not start: {reason}"),
1462        }
1463    }
1464}
1465
1466/// How one step of a run ended.
1467#[derive(Clone, Debug, Eq, PartialEq)]
1468pub enum StepState {
1469    Done,
1470    Failed(u32),
1471    Interrupted,
1472    /// A step it depends on did not finish.
1473    Skipped,
1474    /// The run stopped before it.
1475    NotRun,
1476}
1477
1478impl StepState {
1479    pub fn describe(&self) -> String {
1480        match self {
1481            StepState::Done => "done".to_string(),
1482            StepState::Failed(code) => format!("failed (exit {code})"),
1483            StepState::Interrupted => "interrupted".to_string(),
1484            StepState::Skipped => "skipped: a step it needs did not finish".to_string(),
1485            StepState::NotRun => "not run: the run stopped first".to_string(),
1486        }
1487    }
1488}
1489
1490/// One step of a finished run.
1491#[derive(Clone, Debug, Eq, PartialEq)]
1492pub struct StepResult {
1493    pub id: String,
1494    pub name: String,
1495    pub state: StepState,
1496}
1497
1498/// Read the step outcomes a run's program wrote to `status`; a step with no
1499/// line did not run, unless the whole program succeeded without a file.
1500pub fn step_results(plan: &Plan, outcome: &Outcome, status: Option<&Path>) -> Vec<StepResult> {
1501    let text = status.and_then(|path| std::fs::read_to_string(path).ok());
1502    let mut states: Vec<Option<StepState>> = vec![None; plan.steps.len()];
1503    for line in text.as_deref().unwrap_or_default().lines() {
1504        let mut words = line.split_whitespace();
1505        let (Some(index), Some(state), Some(code)) = (words.next(), words.next(), words.next())
1506        else {
1507            continue;
1508        };
1509        let (Ok(index), Ok(code)) = (index.parse::<usize>(), code.parse::<u32>()) else {
1510            continue;
1511        };
1512        let state = match state {
1513            "done" => StepState::Done,
1514            "failed" => StepState::Failed(code),
1515            "interrupted" => StepState::Interrupted,
1516            "skipped" => StepState::Skipped,
1517            _ => continue,
1518        };
1519        if let Some(slot) = states.get_mut(index) {
1520            *slot = Some(state);
1521        }
1522    }
1523    let unknown = if text.is_none() && outcome.succeeded() {
1524        StepState::Done
1525    } else {
1526        StepState::NotRun
1527    };
1528    plan.steps
1529        .iter()
1530        .zip(states)
1531        .map(|(step, state)| StepResult {
1532            id: step.id().to_string(),
1533            name: step.name().to_string(),
1534            state: state.unwrap_or_else(|| unknown.clone()),
1535        })
1536        .collect()
1537}
1538
1539/// A finished run: how the program ended, how each step ended, and what the
1540/// checks of the package steps that finished found.
1541#[derive(Clone, Debug, Eq, PartialEq)]
1542pub struct Finished {
1543    pub outcome: Outcome,
1544    pub checks: Vec<Check>,
1545    pub steps: Vec<StepResult>,
1546}
1547
1548impl Finished {
1549    /// Check a run's package steps if the program succeeded.
1550    pub fn new(plan: &Plan, outcome: Outcome) -> Self {
1551        Self::from_run(plan, outcome, None)
1552    }
1553
1554    /// A run whose program wrote its step outcomes to `status`: the steps
1555    /// that finished are checked even when another failed.
1556    pub fn from_run(plan: &Plan, outcome: Outcome, status: Option<&Path>) -> Self {
1557        let steps = step_results(plan, &outcome, status);
1558        let finished = Plan {
1559            steps: plan
1560                .steps
1561                .iter()
1562                .zip(&steps)
1563                .filter(|(_, result)| result.state == StepState::Done)
1564                .map(|(step, _)| step.clone())
1565                .collect(),
1566            ..Plan::default()
1567        };
1568        let checks =
1569            if outcome == Outcome::Interrupted || matches!(outcome, Outcome::CouldNotStart(_)) {
1570                Vec::new()
1571            } else {
1572                verify(&finished)
1573            };
1574        Self {
1575            outcome,
1576            checks,
1577            steps,
1578        }
1579    }
1580
1581    /// The steps that did not finish, to try again.
1582    pub fn unfinished(&self) -> Vec<&StepResult> {
1583        self.steps
1584            .iter()
1585            .filter(|step| step.state != StepState::Done)
1586            .collect()
1587    }
1588
1589    /// The program succeeded and every check passed.
1590    pub fn succeeded(&self) -> bool {
1591        self.outcome.succeeded() && self.checks.iter().all(|check| check.passed)
1592    }
1593
1594    pub fn verified(&self) -> bool {
1595        !self.checks.is_empty() && self.succeeded()
1596    }
1597
1598    /// The result as the history records it: "exit 0, verified",
1599    /// "exit 0, not verified: Meld (…)", or the outcome alone.
1600    pub fn describe(&self) -> String {
1601        let mut text = self.outcome.describe();
1602        let unfinished = self.unfinished();
1603        if !unfinished.is_empty() && self.steps.len() > 1 {
1604            text.push_str(&format!(
1605                ", {} of {} steps done",
1606                self.steps.len() - unfinished.len(),
1607                self.steps.len()
1608            ));
1609        }
1610        if self.checks.is_empty() {
1611            return text;
1612        }
1613        let failed: Vec<String> = self
1614            .checks
1615            .iter()
1616            .filter(|check| !check.passed)
1617            .map(|check| format!("{} ({})", check.name, check.detail))
1618            .collect();
1619        if failed.is_empty() {
1620            text.push_str(", verified");
1621        } else {
1622            text.push_str(&format!(", not verified: {}", failed.join("; ")));
1623        }
1624        text
1625    }
1626}
1627
1628/// A fresh, empty file for a run's step outcomes, only this user can write.
1629pub fn status_file() -> Option<PathBuf> {
1630    use std::os::unix::fs::OpenOptionsExt;
1631    let directory = std::env::temp_dir();
1632    for attempt in 0..16u32 {
1633        let nanos = std::time::SystemTime::now()
1634            .duration_since(std::time::UNIX_EPOCH)
1635            .map(|elapsed| elapsed.subsec_nanos())
1636            .unwrap_or_default();
1637        let path = directory.join(format!(
1638            "helixforge-steps-{}-{nanos}-{attempt}",
1639            std::process::id()
1640        ));
1641        if std::fs::OpenOptions::new()
1642            .write(true)
1643            .create_new(true)
1644            .mode(0o600)
1645            .open(&path)
1646            .is_ok()
1647        {
1648            return Some(path);
1649        }
1650    }
1651    None
1652}
1653
1654/// Something that can run a shell program: the caller's terminal, a
1655/// pseudo-terminal, or a test double.
1656pub trait Runner {
1657    fn run(&mut self, program: &str, lib_dir: &Path) -> Outcome;
1658}
1659
1660/// Runs in the caller's terminal, inheriting stdin and stdout.
1661pub struct InheritedRunner;
1662
1663impl Runner for InheritedRunner {
1664    fn run(&mut self, program: &str, lib_dir: &Path) -> Outcome {
1665        match Command::new("sh")
1666            .arg("-c")
1667            .arg(program)
1668            .env(LIB_VARIABLE, lib_dir)
1669            .status()
1670        {
1671            Ok(status) => Outcome::from_status(&status),
1672            Err(error) => Outcome::CouldNotStart(error.to_string()),
1673        }
1674    }
1675}
1676
1677/// Records the program instead of running it, for tests.
1678#[derive(Default)]
1679pub struct MockRunner {
1680    pub programs: Vec<String>,
1681    pub outcome: Option<Outcome>,
1682}
1683
1684impl Runner for MockRunner {
1685    fn run(&mut self, program: &str, _lib_dir: &Path) -> Outcome {
1686        self.programs.push(program.to_string());
1687        self.outcome.clone().unwrap_or(Outcome::Success)
1688    }
1689}
1690
1691/// Run a plan through a runner and record the outcome in the default history.
1692pub fn execute(
1693    plan: &Plan,
1694    lib_dir: &Path,
1695    this: &Path,
1696    runner: &mut dyn Runner,
1697) -> Result<Finished, EngineError> {
1698    let history = history::state_dir();
1699    execute_in(plan, lib_dir, this, runner, history.as_deref())
1700}
1701
1702/// Run a plan through a runner and record the outcome in `history_dir`.
1703pub fn execute_in(
1704    plan: &Plan,
1705    lib_dir: &Path,
1706    this: &Path,
1707    runner: &mut dyn Runner,
1708    history_dir: Option<&Path>,
1709) -> Result<Finished, EngineError> {
1710    check_requirements(plan)?;
1711    info!(steps = ?plan.entries(), "run started");
1712    let not_recorded = |directory: &Path, error: std::io::Error| {
1713        warn!(%error, "the run was not recorded");
1714        eprintln!(
1715            "HelixForge could not record this run in {}: {error}",
1716            directory.display()
1717        );
1718    };
1719    // The marker records the run even if this process dies before it ends.
1720    let pending = history_dir.and_then(|directory| {
1721        history::begin_in(directory, &plan.entries())
1722            .inspect_err(|error| warn!(%error, "the run start was not recorded"))
1723            .ok()
1724    });
1725    let status = status_file();
1726    let outcome = runner.run(
1727        &shell_program_with(plan, lib_dir, this, status.as_deref()),
1728        lib_dir,
1729    );
1730    let finished = Finished::from_run(plan, outcome, status.as_deref());
1731    if let Some(status) = &status {
1732        let _ = std::fs::remove_file(status);
1733    }
1734    info!(outcome = finished.describe(), "run finished");
1735    if let Some(directory) = history_dir {
1736        let written = match pending {
1737            Some(pending) => pending.finish(finished.succeeded(), &finished.describe(), None),
1738            None => history::record_in(
1739                directory,
1740                &plan.entries(),
1741                finished.succeeded(),
1742                &finished.describe(),
1743                None,
1744            ),
1745        };
1746        if let Err(error) = written {
1747            not_recorded(directory, error);
1748        }
1749    }
1750    Ok(finished)
1751}
1752
1753/// Run a native action and return its text.
1754pub fn run_native(action: NativeAction, platform: &LinuxInfo) -> String {
1755    match action {
1756        NativeAction::SystemReport => {
1757            let mut lines = vec![
1758                format!(
1759                    "Distribution:    {} ({})",
1760                    platform.pretty_name, platform.id
1761                ),
1762                format!(
1763                    "Package manager: {}",
1764                    platform
1765                        .manager
1766                        .map(|manager| manager.label())
1767                        .unwrap_or("none supported for package entries")
1768                ),
1769                format!(
1770                    "Kernel:          {}",
1771                    std::fs::read_to_string("/proc/sys/kernel/osrelease")
1772                        .unwrap_or_default()
1773                        .trim()
1774                ),
1775                format!("Architecture:    {}", std::env::consts::ARCH),
1776            ];
1777            for tool in ["sudo", "nix", "home-manager", "flatpak"] {
1778                lines.push(format!(
1779                    "{:<16} {}",
1780                    format!("{tool}:"),
1781                    if binary_exists(tool) {
1782                        "present"
1783                    } else {
1784                        "missing"
1785                    }
1786                ));
1787            }
1788            lines.join("\n") + "\n"
1789        }
1790    }
1791}
1792
1793#[cfg(test)]
1794mod tests {
1795    use super::*;
1796    use helixforge_core::PackageNames;
1797
1798    fn platform(manager: Option<PackageManager>) -> LinuxInfo {
1799        LinuxInfo {
1800            id: "test".into(),
1801            pretty_name: "Test Linux".into(),
1802            id_like: vec![],
1803            manager,
1804        }
1805    }
1806
1807    fn entry(id: &str, flags: &str, action: Action) -> Entry {
1808        Entry {
1809            id: id.into(),
1810            name: id.to_uppercase(),
1811            description: String::new(),
1812            flags: flags.into(),
1813            multi_select: true,
1814            writes: Vec::new(),
1815            action,
1816            detect: None,
1817            needs: Default::default(),
1818            effects: Default::default(),
1819        }
1820    }
1821
1822    fn package_entry() -> Entry {
1823        entry(
1824            "sample",
1825            "I",
1826            Action::Package {
1827                binary: Some("helixforge-nonexistent-binary-12345".into()),
1828                packages: PackageNames {
1829                    arch: Some("sample".into()),
1830                    debian: Some("sample-deb".into()),
1831                    ..Default::default()
1832                },
1833                aur: None,
1834                flatpak: None,
1835                conflicts: Vec::new(),
1836                removable: true,
1837            },
1838        )
1839    }
1840
1841    fn none<'a>(_: &str) -> Option<&'a Entry> {
1842        None
1843    }
1844
1845    #[test]
1846    fn package_plans_use_the_native_manager_and_say_what_they_need() {
1847        let sample = package_entry();
1848        let arch = plan([&sample], &platform(Some(PackageManager::Arch)), &none).unwrap();
1849        assert_eq!(arch.steps[0].display(), "sudo pacman -S --needed sample");
1850        assert!(arch.privileged);
1851        assert_eq!(arch.risk, Risk::Medium);
1852        assert_eq!(arch.requirements, ["pacman", "sudo"]);
1853        let debian = plan([&sample], &platform(Some(PackageManager::Debian)), &none).unwrap();
1854        assert_eq!(debian.steps[0].display(), "sudo apt-get install sample-deb");
1855        assert!(matches!(
1856            plan([&sample], &platform(Some(PackageManager::Fedora)), &none),
1857            Err(EngineError::UnsupportedEntry(_))
1858        ));
1859        assert!(matches!(
1860            plan([&sample], &platform(None), &none),
1861            Err(EngineError::UnsupportedPlatform)
1862        ));
1863    }
1864
1865    #[test]
1866    fn aur_and_flatpak_routes_and_the_other_families() {
1867        let mut entry = package_entry();
1868        entry.action = Action::Package {
1869            binary: Some("helixforge-nonexistent-binary-12345".into()),
1870            packages: PackageNames {
1871                suse: Some("sample-suse".into()),
1872                ..Default::default()
1873            },
1874            aur: Some("sample-bin".into()),
1875            flatpak: Some("org.example.Sample".into()),
1876            conflicts: Vec::new(),
1877            removable: false,
1878        };
1879        let suse = plan([&entry], &platform(Some(PackageManager::Suse)), &none).unwrap();
1880        assert_eq!(
1881            suse.steps[0].display(),
1882            "sudo zypper install sample-suse  (else Flathub)"
1883        );
1884        assert_eq!(suse.requirements, ["sudo", "zypper"]);
1885        assert_eq!(
1886            suse.steps[0].fallbacks(),
1887            [format!(
1888                "if that fails, Flathub: sudo flatpak remote-add --if-not-exists flathub {FLATHUB} && sudo flatpak install flathub org.example.Sample"
1889            )]
1890        );
1891        let arch = plan([&entry], &platform(Some(PackageManager::Arch)), &none).unwrap();
1892        let shown = arch.steps[0].display();
1893        assert!(
1894            shown == "paru -S --needed sample-bin  (else Flathub)"
1895                || shown == "yay -S --needed sample-bin  (else Flathub)",
1896            "{shown}"
1897        );
1898        let fedora = plan([&entry], &platform(Some(PackageManager::Fedora)), &none).unwrap();
1899        assert_eq!(
1900            fedora.steps[0].display(),
1901            format!(
1902                "sudo flatpak remote-add --if-not-exists flathub {FLATHUB} && sudo flatpak install flathub org.example.Sample"
1903            )
1904        );
1905        assert_eq!(fedora.requirements, ["flatpak", "sudo"]);
1906        assert!(fedora.steps[0].fallbacks().is_empty());
1907        let unknown = plan([&entry], &platform(None), &none).unwrap();
1908        assert!(unknown.steps[0].display().contains("flatpak install"));
1909        assert_eq!(aur_helper(&|name| name == "yay"), Some("yay"));
1910        assert_eq!(aur_helper(&|_| true), Some("paru"));
1911        assert_eq!(aur_helper(&|_| false), None);
1912    }
1913
1914    #[test]
1915    fn installed_state_comes_from_packages_and_conflicts_before_commands() {
1916        let arch = platform(Some(PackageManager::Arch));
1917        let package = |name: &str, binary: Option<&str>, conflicts: &[&str]| {
1918            entry(
1919                name,
1920                "I",
1921                Action::Package {
1922                    binary: binary.map(Into::into),
1923                    packages: PackageNames {
1924                        arch: Some(name.into()),
1925                        ..Default::default()
1926                    },
1927                    aur: None,
1928                    flatpak: Some(format!("org.example.{name}")),
1929                    conflicts: conflicts.iter().map(|name| name.to_string()).collect(),
1930                    removable: true,
1931                },
1932            )
1933        };
1934        let staging = package("wine-staging", None, &["wine"]);
1935        let wine = package("wine", Some("wine"), &["wine-staging"]);
1936        // Plain WINE installed: wine on the PATH, pacman lists wine.
1937        let plain = Machine {
1938            holds: &|probe| probe == &Probe::package(PackageManager::Arch, "wine").unwrap(),
1939            on_path: &|binary| binary == "wine",
1940        };
1941        assert_eq!(
1942            package_state(&staging, &arch, plain),
1943            Some(PackageState::Absent {
1944                replaces: vec!["wine".into()]
1945            })
1946        );
1947        let planned = plan_with([&staging], &arch, &none, plain).unwrap();
1948        assert_eq!(
1949            planned.steps.len(),
1950            1,
1951            "staging is planned, not taken as installed"
1952        );
1953        assert!(planned.notes.is_empty());
1954        // The preflight says what it replaces.
1955        let facts = preflight::Facts {
1956            machine: plain,
1957            present: &|_| true,
1958            free_bytes: &|_| Some(u64::MAX),
1959            gpus: &Vec::new,
1960            repositories: &|| None,
1961            sizes: &|_, _| None,
1962            active_runs: &Vec::new,
1963        };
1964        let lookup = |id: &str| (id == "wine-staging").then_some(&staging);
1965        assert_eq!(
1966            preflight::check(&planned, &arch, &lookup, &facts),
1967            [preflight::Finding {
1968                severity: preflight::Severity::Warning,
1969                message: "WINE-STAGING replaces wine, which is installed; the package manager asks before removing it".into(),
1970            }]
1971        );
1972        assert_eq!(
1973            package_state(&wine, &arch, plain),
1974            Some(PackageState::Installed("pacman lists wine".into()))
1975        );
1976        // Staging installed: its wine command does not make plain WINE installed.
1977        let staged = Machine {
1978            holds: &|probe| probe == &Probe::package(PackageManager::Arch, "wine-staging").unwrap(),
1979            on_path: &|binary| binary == "wine",
1980        };
1981        assert_eq!(
1982            package_state(&wine, &arch, staged),
1983            Some(PackageState::Absent {
1984                replaces: vec!["wine-staging".into()]
1985            })
1986        );
1987        // From Nix or a download: no package listed, the command on the PATH.
1988        let elsewhere = Machine {
1989            holds: &|_| false,
1990            on_path: &|binary| binary == "wine",
1991        };
1992        assert_eq!(
1993            package_state(&wine, &arch, elsewhere),
1994            Some(PackageState::OnPath("wine is on the PATH".into()))
1995        );
1996        let noted = plan_with([&wine], &arch, &none, elsewhere).unwrap();
1997        assert!(noted.steps.is_empty());
1998        assert_eq!(
1999            noted.notes,
2000            ["WINE: already installed (wine is on the PATH)"]
2001        );
2002        // Without a command, nothing on the PATH counts.
2003        assert_eq!(
2004            package_state(&staging, &arch, elsewhere),
2005            Some(PackageState::Absent { replaces: vec![] })
2006        );
2007        // Its Flatpak counts on any family.
2008        let flatpak = Machine {
2009            holds: &|probe| matches!(probe, Probe::Flatpak(_)),
2010            on_path: &|_| false,
2011        };
2012        assert_eq!(
2013            package_state(&staging, &platform(Some(PackageManager::Fedora)), flatpak),
2014            Some(PackageState::Installed(
2015                "org.example.wine-staging is installed from Flathub".into()
2016            ))
2017        );
2018        let script = entry(
2019            "script",
2020            "",
2021            Action::Command {
2022                line: "true".into(),
2023            },
2024        );
2025        assert_eq!(package_state(&script, &arch, plain), None);
2026    }
2027
2028    #[test]
2029    fn removal_follows_how_the_package_is_installed() {
2030        let mut present = package_entry();
2031        present.action = Action::Package {
2032            binary: Some("sh".into()),
2033            packages: PackageNames {
2034                debian: Some("dash".into()),
2035                ..Default::default()
2036            },
2037            aur: Some("sh-bin".into()),
2038            flatpak: None,
2039            conflicts: Vec::new(),
2040            removable: true,
2041        };
2042        // Every package listed, and sh on the PATH.
2043        let listed = Machine {
2044            holds: &|_| true,
2045            on_path: &|binary| binary == "sh",
2046        };
2047        let remove = |entry: &Entry, manager, machine| {
2048            plan_removal_with([entry], &platform(Some(manager)), machine).unwrap()
2049        };
2050        let debian = remove(&present, PackageManager::Debian, listed);
2051        assert_eq!(debian.steps[0].display(), "sudo apt-get remove dash");
2052        assert_eq!(debian.steps[0].name(), "Remove SAMPLE");
2053        assert!(debian.privileged);
2054        assert_eq!(debian.risk, Risk::Medium);
2055        let arch = remove(&present, PackageManager::Arch, listed);
2056        assert_eq!(arch.steps[0].display(), "sudo pacman -R sh-bin");
2057        // Nothing listed: the command alone is someone else's install.
2058        let unlisted = Machine {
2059            holds: &|_| false,
2060            on_path: &|binary| binary == "sh",
2061        };
2062        let other = remove(&present, PackageManager::Arch, unlisted);
2063        assert!(other.steps.is_empty());
2064        assert!(other.notes[0].contains("not by a package"));
2065        let fedora = remove(&present, PackageManager::Fedora, listed);
2066        assert!(fedora.steps.is_empty(), "Fedora has no package for it");
2067        let nothing = Machine {
2068            holds: &|_| false,
2069            on_path: &|_| false,
2070        };
2071        let absent = remove(&package_entry(), PackageManager::Arch, nothing);
2072        assert!(absent.steps.is_empty());
2073        assert!(absent.notes[0].contains("not installed"));
2074        // The Flatpak is found and removed as such.
2075        let mut flatpak = package_entry();
2076        if let Action::Package { flatpak: id, .. } = &mut flatpak.action {
2077            *id = Some("org.example.Sample".into());
2078        }
2079        let only_flatpak = Machine {
2080            holds: &|probe| matches!(probe, Probe::Flatpak(_)),
2081            on_path: &|_| false,
2082        };
2083        let removed = remove(&flatpak, PackageManager::Arch, only_flatpak);
2084        assert_eq!(
2085            removed.steps[0].display(),
2086            "sudo flatpak uninstall org.example.Sample"
2087        );
2088        let mut fixed = package_entry();
2089        if let Action::Package { removable, .. } = &mut fixed.action {
2090            *removable = false;
2091        }
2092        assert!(matches!(
2093            plan_removal([&fixed], &platform(Some(PackageManager::Arch))),
2094            Err(EngineError::NotRemovable(_))
2095        ));
2096        let script = entry(
2097            "script",
2098            "",
2099            Action::Command {
2100                line: "true".into(),
2101            },
2102        );
2103        assert!(matches!(
2104            plan_removal([&script], &platform(Some(PackageManager::Arch))),
2105            Err(EngineError::NotRemovable(_))
2106        ));
2107        let root = tempfile::tempdir().unwrap();
2108        std::fs::create_dir_all(root.path().join("app/org.example.Sample")).unwrap();
2109        assert!(flatpak_installed_in(
2110            &[root.path().to_path_buf()],
2111            "org.example.Sample"
2112        ));
2113        assert!(!flatpak_installed_in(
2114            &[root.path().to_path_buf()],
2115            "org.example.Other"
2116        ));
2117    }
2118
2119    #[test]
2120    fn installed_packages_become_notes_and_profiles_expand() {
2121        let installed = entry(
2122            "shell",
2123            "I",
2124            Action::Package {
2125                binary: Some("sh".into()),
2126                packages: PackageNames {
2127                    arch: Some("sh".into()),
2128                    ..Default::default()
2129                },
2130                aur: None,
2131                flatpak: None,
2132                conflicts: Vec::new(),
2133                removable: true,
2134            },
2135        );
2136        let report = entry(
2137            "report",
2138            "",
2139            Action::Native {
2140                action: NativeAction::SystemReport,
2141            },
2142        );
2143        let profile = entry(
2144            "basics",
2145            "",
2146            Action::Profile {
2147                entries: vec!["shell".into(), "report".into()],
2148            },
2149        );
2150        let members = [installed.clone(), report.clone()];
2151        let lookup = |id: &str| members.iter().find(|entry| entry.id == id);
2152        let planned = plan([&profile], &platform(Some(PackageManager::Arch)), &lookup).unwrap();
2153        assert_eq!(planned.steps.len(), 1);
2154        assert_eq!(planned.steps[0].id(), "report");
2155        assert_eq!(planned.notes.len(), 1);
2156        assert!(!planned.privileged);
2157        assert_eq!(planned.risk, Risk::Low);
2158        let broken = entry(
2159            "broken",
2160            "",
2161            Action::Profile {
2162                entries: vec!["ghost".into()],
2163            },
2164        );
2165        assert!(matches!(
2166            super::plan([&broken], &platform(Some(PackageManager::Arch)), &lookup),
2167            Err(EngineError::UnknownMember(_, _))
2168        ));
2169    }
2170
2171    #[test]
2172    fn the_shell_program_runs_steps_in_order_and_stops_on_failure() {
2173        let directory = tempfile::tempdir().unwrap();
2174        let script = directory.path().join("it's.sh");
2175        std::fs::write(
2176            &script,
2177            "#!/bin/sh\nprintf 'lib=%s\\n' \"$HELIXFORGE_LIB\"\npwd\n",
2178        )
2179        .unwrap();
2180        let plan = Plan {
2181            steps: vec![
2182                Step::Script {
2183                    id: "s".into(),
2184                    name: "S".into(),
2185                    file: script,
2186                    program: "/bin/sh".into(),
2187                    args: vec!["-e".into()],
2188                    interactive: true,
2189                },
2190                Step::Command {
2191                    id: "fail".into(),
2192                    name: "Fail".into(),
2193                    line: "echo before; exit 7".into(),
2194                },
2195                Step::Command {
2196                    id: "never".into(),
2197                    name: "Never".into(),
2198                    line: "echo never".into(),
2199                },
2200            ],
2201            ..Default::default()
2202        };
2203        let program = shell_program(
2204            &plan,
2205            Path::new("/tmp/lib dir"),
2206            Path::new("/usr/bin/helixforge"),
2207        );
2208        let output = Command::new("sh").arg("-c").arg(&program).output().unwrap();
2209        let text = String::from_utf8_lossy(&output.stdout);
2210        assert_eq!(output.status.code(), Some(7));
2211        assert!(text.contains("==> S"));
2212        assert!(text.contains("lib=/tmp/lib dir"));
2213        assert!(text.contains(&directory.path().to_string_lossy().to_string()));
2214        assert!(text.contains("before"));
2215        assert!(!text.contains("never"));
2216    }
2217
2218    fn command(id: &str, line: &str) -> Step {
2219        Step::Command {
2220            id: id.into(),
2221            name: id.to_uppercase(),
2222            line: line.into(),
2223        }
2224    }
2225
2226    /// Run a plan's program with a status file; its exit code, output and results.
2227    fn run_tracked(plan: &Plan) -> (Option<i32>, String, Vec<StepResult>) {
2228        let status = status_file().unwrap();
2229        let program =
2230            shell_program_with(plan, Path::new("/lib"), Path::new("/bin/hf"), Some(&status));
2231        let output = Command::new("sh").arg("-c").arg(&program).output().unwrap();
2232        let outcome = Outcome::from_status(&output.status);
2233        let results = step_results(plan, &outcome, Some(&status));
2234        std::fs::remove_file(&status).unwrap();
2235        (
2236            output.status.code(),
2237            String::from_utf8_lossy(&output.stdout).into_owned(),
2238            results,
2239        )
2240    }
2241
2242    #[test]
2243    fn steps_run_after_what_they_need_and_their_tools_count_as_coming() {
2244        let app = entry(
2245            "flatpak-app",
2246            "I",
2247            Action::Package {
2248                binary: None,
2249                packages: PackageNames::default(),
2250                aur: None,
2251                flatpak: Some("org.example.App".into()),
2252                conflicts: Vec::new(),
2253                removable: true,
2254            },
2255        );
2256        let mut setup = entry(
2257            "flatpak-flathub",
2258            "I",
2259            Action::Command {
2260                line: "true".into(),
2261            },
2262        );
2263        setup.detect = Some(helixforge_core::Detect {
2264            binary: Some("flatpak".into()),
2265            ..Default::default()
2266        });
2267        let mut last = entry(
2268            "last",
2269            "",
2270            Action::Command {
2271                line: "true".into(),
2272            },
2273        );
2274        last.needs.after = vec!["report".into()];
2275        let report = entry(
2276            "report",
2277            "",
2278            Action::Command {
2279                line: "true".into(),
2280            },
2281        );
2282        let all = [&app, &setup, &last, &report];
2283        let lookup = |id: &str| all.iter().copied().find(|entry| entry.id == id);
2284        let nothing = Machine {
2285            holds: &|_| false,
2286            on_path: &|_| false,
2287        };
2288        let planned = plan_with(
2289            all,
2290            &platform(Some(PackageManager::Fedora)),
2291            &lookup,
2292            nothing,
2293        )
2294        .unwrap();
2295        let order: Vec<&str> = planned.steps.iter().map(Step::id).collect();
2296        assert_eq!(order, ["flatpak-flathub", "flatpak-app", "report", "last"]);
2297        assert_eq!(planned.depends, [vec![], vec![0], vec![], vec![2]]);
2298        assert_eq!(planned.provided, ["flatpak"]);
2299        // flatpak is missing now, but the first step installs it.
2300        assert!(
2301            missing_requirements(&planned, &|_| false)
2302                .iter()
2303                .all(|missing| missing != "flatpak")
2304        );
2305    }
2306
2307    #[test]
2308    fn a_failure_stops_the_run_or_skips_only_what_depends_on_it() {
2309        let mut plan = Plan {
2310            steps: vec![
2311                command("a", "echo a-ran; exit 3"),
2312                command("b", "echo b-ran"),
2313                command("c", "echo c-ran"),
2314            ],
2315            depends: vec![vec![], vec![], vec![0]],
2316            ..Default::default()
2317        };
2318        // Stop: B and C never start.
2319        let (code, out, results) = run_tracked(&plan);
2320        assert_eq!(code, Some(3));
2321        assert!(out.contains("a-ran") && !out.contains("b-ran"));
2322        let states: Vec<&StepState> = results.iter().map(|step| &step.state).collect();
2323        assert_eq!(
2324            states,
2325            [
2326                &StepState::Failed(3),
2327                &StepState::NotRun,
2328                &StepState::NotRun
2329            ]
2330        );
2331        // Keep going: B runs, C depends on A and is skipped; the run still fails.
2332        plan.keep_going = true;
2333        let (code, out, results) = run_tracked(&plan);
2334        assert_eq!(code, Some(3));
2335        assert!(out.contains("b-ran") && !out.contains("c-ran"));
2336        assert!(out.contains("==> C skipped: A did not finish"), "{out}");
2337        let states: Vec<&StepState> = results.iter().map(|step| &step.state).collect();
2338        assert_eq!(
2339            states,
2340            [&StepState::Failed(3), &StepState::Done, &StepState::Skipped]
2341        );
2342        let finished = Finished::from_run(&plan, Outcome::Failed { exit_code: 3 }, None);
2343        assert!(finished.describe().starts_with("exit 3"));
2344        let finished = Finished {
2345            outcome: Outcome::Failed { exit_code: 3 },
2346            checks: Vec::new(),
2347            steps: results,
2348        };
2349        assert_eq!(finished.describe(), "exit 3, 1 of 3 steps done");
2350        let unfinished: Vec<&str> = finished
2351            .unfinished()
2352            .iter()
2353            .map(|step| step.id.as_str())
2354            .collect();
2355        assert_eq!(unfinished, ["a", "c"]);
2356        // An interruption always stops.
2357        plan.steps[0] = command("a", "exit 130");
2358        let (code, _, results) = run_tracked(&plan);
2359        assert_eq!(code, Some(130));
2360        assert_eq!(results[0].state, StepState::Interrupted);
2361        assert_eq!(results[1].state, StepState::NotRun);
2362        // Everything done.
2363        plan.steps[0] = command("a", "true");
2364        let (code, _, results) = run_tracked(&plan);
2365        assert_eq!(code, Some(0));
2366        assert!(results.iter().all(|step| step.state == StepState::Done));
2367    }
2368
2369    #[test]
2370    fn native_steps_reinvoke_helixforge() {
2371        let plan = Plan {
2372            steps: vec![Step::Native {
2373                id: "report".into(),
2374                name: "Report".into(),
2375                action: NativeAction::SystemReport,
2376            }],
2377            ..Default::default()
2378        };
2379        let program = shell_program(&plan, Path::new("/lib"), Path::new("/opt/helix forge"));
2380        assert!(program.contains("'/opt/helix forge' 'native' 'system-report'"));
2381    }
2382
2383    #[test]
2384    fn outcomes_tell_failure_from_interruption() {
2385        assert_eq!(
2386            Outcome::from_status(&Command::new("true").status().unwrap()),
2387            Outcome::Success
2388        );
2389        assert_eq!(
2390            Outcome::from_status(&Command::new("sh").args(["-c", "exit 3"]).status().unwrap()),
2391            Outcome::Failed { exit_code: 3 }
2392        );
2393        assert_eq!(
2394            Outcome::from_status(
2395                &Command::new("sh")
2396                    .args(["-c", "kill -TERM $$"])
2397                    .status()
2398                    .unwrap()
2399            ),
2400            Outcome::Interrupted
2401        );
2402        assert_eq!(
2403            Outcome::from_status(
2404                &Command::new("sh")
2405                    .args(["-c", "exit 130"])
2406                    .status()
2407                    .unwrap()
2408            ),
2409            Outcome::Interrupted
2410        );
2411    }
2412
2413    #[test]
2414    fn execution_goes_through_the_runner_and_is_recorded() {
2415        let state = tempfile::tempdir().unwrap();
2416        let plan = Plan {
2417            steps: vec![Step::Command {
2418                id: "hello".into(),
2419                name: "Hello".into(),
2420                line: "echo hello".into(),
2421            }],
2422            ..Default::default()
2423        };
2424        let mut runner = MockRunner {
2425            outcome: Some(Outcome::Failed { exit_code: 4 }),
2426            ..Default::default()
2427        };
2428        let outcome = execute_in(
2429            &plan,
2430            Path::new("/lib"),
2431            Path::new("/bin/helixforge"),
2432            &mut runner,
2433            Some(state.path()),
2434        )
2435        .unwrap();
2436        assert_eq!(outcome.outcome, Outcome::Failed { exit_code: 4 });
2437        assert!(outcome.checks.is_empty());
2438        assert_eq!(runner.programs.len(), 1);
2439        assert!(runner.programs[0].contains("sh -e -c 'echo hello'"));
2440        let records = history::read_in(state.path(), 1).unwrap();
2441        assert_eq!(records[0].entries, ["hello"]);
2442        assert!(!records[0].success);
2443        assert_eq!(records[0].result, "exit 4");
2444    }
2445
2446    #[test]
2447    fn missing_requirements_stop_a_run_before_it_starts() {
2448        let plan = Plan {
2449            requirements: vec!["helixforge-no-such-tool".into()],
2450            ..Default::default()
2451        };
2452        let mut runner = MockRunner::default();
2453        assert!(matches!(
2454            execute(
2455                &plan,
2456                Path::new("/lib"),
2457                Path::new("/bin/helixforge"),
2458                &mut runner
2459            ),
2460            Err(EngineError::MissingExecutable(_))
2461        ));
2462        assert!(runner.programs.is_empty());
2463    }
2464
2465    #[test]
2466    fn home_manager_managed_paths_block_an_entry() {
2467        let home = tempfile::tempdir().unwrap();
2468        std::fs::create_dir_all(home.path().join(".config")).unwrap();
2469        std::os::unix::fs::symlink("/nix/store/abc-nvim", home.path().join(".config/nvim"))
2470            .unwrap();
2471        std::fs::create_dir_all(home.path().join(".config/ghostty")).unwrap();
2472        assert_eq!(
2473            managed_path_in(home.path(), &["~/.config/nvim/init.lua".to_string()]),
2474            Some(home.path().join(".config/nvim"))
2475        );
2476        assert_eq!(
2477            managed_path_in(home.path(), &["~/.config/ghostty/config".to_string()]),
2478            None
2479        );
2480        // Planning reads HOME; an unmanaged absolute path outside it is fine.
2481        let mut free = entry(
2482            "free",
2483            "FM",
2484            Action::Command {
2485                line: "true".into(),
2486            },
2487        );
2488        free.writes = vec![
2489            home.path()
2490                .join(".config/ghostty")
2491                .to_string_lossy()
2492                .into_owned(),
2493        ];
2494        let planned = plan([&free], &platform(None), &none).unwrap();
2495        assert_eq!(planned.steps.len(), 1);
2496        assert!(planned.blocked.is_empty());
2497        let mut managed = entry(
2498            "nvim",
2499            "FM",
2500            Action::Command {
2501                line: "true".into(),
2502            },
2503        );
2504        managed.writes = vec![
2505            home.path()
2506                .join(".config/nvim")
2507                .to_string_lossy()
2508                .into_owned(),
2509        ];
2510        let planned = plan([&managed], &platform(None), &none).unwrap();
2511        assert!(planned.steps.is_empty());
2512        assert!(planned.blocked[0].contains("Home Manager"));
2513    }
2514
2515    /// A package step whose sources are harmless commands: `bin` is a
2516    /// temporary directory put first on the PATH, where sources create or
2517    /// delete the executable `hf-sample`.
2518    fn fake_package(bin: &Path, sources: Vec<(&str, &str, &[&str])>, removal: bool) -> Plan {
2519        let sample = bin.join("hf-sample");
2520        let sample = quote(&sample.to_string_lossy());
2521        Plan {
2522            steps: vec![Step::Package {
2523                id: "sample".into(),
2524                name: "Sample".into(),
2525                binary: Some("hf-sample".into()),
2526                sources: sources
2527                    .into_iter()
2528                    .map(|(label, line, requires)| Source {
2529                        label: label.into(),
2530                        commands: vec![vec![
2531                            "sh".into(),
2532                            "-c".into(),
2533                            line.replace("SAMPLE", &sample),
2534                        ]],
2535                        requires: requires.iter().map(|tool| tool.to_string()).collect(),
2536                        probe: None,
2537                    })
2538                    .collect(),
2539                removal,
2540            }],
2541            ..Default::default()
2542        }
2543    }
2544
2545    fn run_in_shell(plan: &Plan, bin: &Path) -> (Option<i32>, String) {
2546        let path = format!(
2547            "{}:{}",
2548            bin.display(),
2549            std::env::var("PATH").unwrap_or_default()
2550        );
2551        let output = Command::new("sh")
2552            .arg("-c")
2553            .arg(shell_program(plan, Path::new("/lib"), Path::new("/bin/hf")))
2554            .env("PATH", path)
2555            .output()
2556            .unwrap();
2557        (
2558            output.status.code(),
2559            String::from_utf8_lossy(&output.stdout).into_owned(),
2560        )
2561    }
2562
2563    const CREATE: &str = "printf '#!/bin/sh\\n' > SAMPLE && chmod +x SAMPLE";
2564
2565    #[test]
2566    fn a_package_falls_back_to_the_next_source_that_works() {
2567        let bin = tempfile::tempdir().unwrap();
2568        let plan = fake_package(
2569            bin.path(),
2570            vec![
2571                ("first", "exit 3", &["sh"]),
2572                ("second", CREATE, &["helixforge-no-such-tool"]),
2573                ("third", CREATE, &["sh"]),
2574                ("fourth", "echo never", &["sh"]),
2575            ],
2576            false,
2577        );
2578        let (code, text) = run_in_shell(&plan, bin.path());
2579        assert_eq!(code, Some(0), "{text}");
2580        assert!(text.contains("HelixForge: first failed (exit 3)"), "{text}");
2581        assert!(
2582            text.contains("HelixForge: skipping second: it needs helixforge-no-such-tool"),
2583            "{text}"
2584        );
2585        assert!(text.contains("HelixForge: trying third"), "{text}");
2586        assert!(!text.contains("never"), "{text}");
2587        assert!(bin.path().join("hf-sample").exists());
2588    }
2589
2590    #[test]
2591    fn a_source_that_installs_nothing_is_not_trusted() {
2592        let bin = tempfile::tempdir().unwrap();
2593        let alone = fake_package(bin.path(), vec![("quiet", "true", &["sh"])], false);
2594        let (code, text) = run_in_shell(&alone, bin.path());
2595        assert_eq!(code, Some(1), "{text}");
2596        assert!(
2597            text.contains("HelixForge: quiet finished, but Sample is not installed"),
2598            "{text}"
2599        );
2600        assert!(
2601            text.contains("HelixForge: Sample was not installed"),
2602            "{text}"
2603        );
2604        let rescued = fake_package(
2605            bin.path(),
2606            vec![("quiet", "true", &["sh"]), ("real", CREATE, &["sh"])],
2607            false,
2608        );
2609        let (code, text) = run_in_shell(&rescued, bin.path());
2610        assert_eq!(code, Some(0), "{text}");
2611        assert!(text.contains("HelixForge: trying real"), "{text}");
2612    }
2613
2614    #[test]
2615    fn an_interrupted_source_stops_the_run_without_falling_back() {
2616        let bin = tempfile::tempdir().unwrap();
2617        let plan = fake_package(
2618            bin.path(),
2619            vec![("first", "exit 130", &["sh"]), ("second", CREATE, &["sh"])],
2620            false,
2621        );
2622        let (code, text) = run_in_shell(&plan, bin.path());
2623        assert_eq!(code, Some(130), "{text}");
2624        assert!(!text.contains("trying second"), "{text}");
2625        assert!(!bin.path().join("hf-sample").exists());
2626    }
2627
2628    #[test]
2629    fn a_removal_fails_when_the_package_is_still_there() {
2630        let bin = tempfile::tempdir().unwrap();
2631        std::fs::write(bin.path().join("hf-sample"), "#!/bin/sh\n").unwrap();
2632        std::fs::set_permissions(
2633            bin.path().join("hf-sample"),
2634            std::os::unix::fs::PermissionsExt::from_mode(0o755),
2635        )
2636        .unwrap();
2637        let kept = fake_package(bin.path(), vec![("keeper", "true", &["sh"])], true);
2638        let (code, text) = run_in_shell(&kept, bin.path());
2639        assert_eq!(code, Some(1), "{text}");
2640        assert!(
2641            text.contains("HelixForge: Sample is still installed"),
2642            "{text}"
2643        );
2644        let removed = fake_package(bin.path(), vec![("remover", "rm SAMPLE", &["sh"])], true);
2645        let (code, text) = run_in_shell(&removed, bin.path());
2646        assert_eq!(code, Some(0), "{text}");
2647        assert!(text.contains("HelixForge: Sample is removed"), "{text}");
2648        let failing = fake_package(bin.path(), vec![("remover", "exit 5", &["sh"])], true);
2649        assert_eq!(run_in_shell(&failing, bin.path()).0, Some(5));
2650    }
2651
2652    #[test]
2653    fn probes_ask_the_package_manager_and_flatpak() {
2654        let pacman = Probe::package(PackageManager::Arch, "ripgrep").unwrap();
2655        assert_eq!(pacman.shell(), "'pacman' '-Q' 'ripgrep' >/dev/null 2>&1");
2656        assert_eq!(pacman.describe(), "pacman lists ripgrep");
2657        let rpm = Probe::package(PackageManager::Suse, "lynx").unwrap();
2658        assert_eq!(rpm.shell(), "'rpm' '-q' 'lynx' >/dev/null 2>&1");
2659        let dpkg = Probe::package(PackageManager::Debian, "lynx").unwrap();
2660        assert_eq!(
2661            dpkg.shell(),
2662            "[ \"$(dpkg-query -W -f='${db:Status-Status}' 'lynx' 2>/dev/null)\" = installed ]"
2663        );
2664        assert_eq!(Probe::package(PackageManager::Solus, "lynx"), None);
2665        let home = tempfile::tempdir().unwrap();
2666        let flatpak = Probe::Flatpak("org.example.Sample".into());
2667        let test = |probe: &Probe| {
2668            Command::new("sh")
2669                .arg("-c")
2670                .arg(probe.shell())
2671                .env("HOME", home.path())
2672                .status()
2673                .unwrap()
2674                .success()
2675        };
2676        assert!(!test(&flatpak));
2677        std::fs::create_dir_all(
2678            home.path()
2679                .join(".local/share/flatpak/app/org.example.Sample"),
2680        )
2681        .unwrap();
2682        assert!(test(&flatpak));
2683    }
2684
2685    #[test]
2686    fn verification_checks_installs_and_removals() {
2687        let source = |probe: Option<Probe>| Source {
2688            label: "pacman".into(),
2689            commands: Vec::new(),
2690            requires: Vec::new(),
2691            probe,
2692        };
2693        let listed = Probe::package(PackageManager::Arch, "meld").unwrap();
2694        let probed =
2695            |name: &str, binary: &str, probe: Option<Probe>, removal: bool| Step::Package {
2696                id: name.to_lowercase(),
2697                name: name.into(),
2698                binary: Some(binary.into()),
2699                sources: vec![source(probe)],
2700                removal,
2701            };
2702        let step = |name: &str, binary: &str, removal: bool| {
2703            probed(name, binary, Some(listed.clone()), removal)
2704        };
2705        let plan = Plan {
2706            steps: vec![
2707                // Without a probe (Solus) the command is the only answer.
2708                probed("Ripgrep", "rg", None, false),
2709                step("Meld", "meld", false),
2710                Step::Command {
2711                    id: "c".into(),
2712                    name: "C".into(),
2713                    line: "true".into(),
2714                },
2715            ],
2716            ..Default::default()
2717        };
2718        let on_path = |binary: &str| binary == "rg";
2719        let checks = verify_with(&plan, &|_| true, &on_path);
2720        assert_eq!(checks.len(), 2);
2721        assert_eq!(checks[0].detail, "rg is on the PATH");
2722        assert_eq!(checks[1].detail, "pacman lists meld");
2723        assert!(checks.iter().all(|check| check.passed));
2724        let checks = verify_with(&plan, &|_| false, &on_path);
2725        assert!(checks[0].passed);
2726        assert!(!checks[1].passed);
2727        assert_eq!(
2728            checks[1].detail,
2729            "not found after the run: pacman (meld) does not list it"
2730        );
2731        // A command from another install is no proof where the package
2732        // manager can be asked.
2733        let elsewhere = verify_with(&plan, &|_| false, &|_| true);
2734        assert!(!elsewhere[1].passed);
2735        let finished = Finished {
2736            outcome: Outcome::Success,
2737            checks,
2738            steps: Vec::new(),
2739        };
2740        assert!(!finished.succeeded());
2741        assert_eq!(
2742            finished.describe(),
2743            "exit 0, not verified: Meld (not found after the run: pacman (meld) does not list it)"
2744        );
2745        let removal = Plan {
2746            steps: vec![step("Remove Meld", "meld", true)],
2747            ..Default::default()
2748        };
2749        let gone = verify_with(&removal, &|_| false, &|_| false);
2750        assert!(gone[0].passed);
2751        assert_eq!(gone[0].detail, "removed");
2752        let other = verify_with(&removal, &|_| false, &|_| true);
2753        assert!(other[0].passed);
2754        assert!(other[0].detail.contains("from another install"));
2755        let still = verify_with(&removal, &|_| true, &|_| false);
2756        assert!(!still[0].passed);
2757        assert_eq!(still[0].detail, "still installed: pacman lists meld");
2758        let passed = Finished {
2759            outcome: Outcome::Success,
2760            checks: gone,
2761            steps: Vec::new(),
2762        };
2763        assert!(passed.verified());
2764        assert_eq!(passed.describe(), "exit 0, verified");
2765        let plain = Finished {
2766            outcome: Outcome::Failed { exit_code: 2 },
2767            checks: Vec::new(),
2768            steps: Vec::new(),
2769        };
2770        assert!(!plain.verified());
2771        assert_eq!(plain.describe(), "exit 2");
2772    }
2773
2774    #[test]
2775    fn a_missing_first_source_does_not_block_a_package_with_another() {
2776        let sources = |first: &str, second: &str| {
2777            vec![
2778                Source {
2779                    label: "first".into(),
2780                    commands: Vec::new(),
2781                    requires: vec![first.into()],
2782                    probe: None,
2783                },
2784                Source {
2785                    label: "second".into(),
2786                    commands: Vec::new(),
2787                    requires: vec![second.into()],
2788                    probe: None,
2789                },
2790            ]
2791        };
2792        let plan = |sources| Plan {
2793            steps: vec![Step::Package {
2794                id: "p".into(),
2795                name: "P".into(),
2796                binary: Some("p".into()),
2797                sources,
2798                removal: false,
2799            }],
2800            requirements: vec!["helixforge-no-such-tool".into()],
2801            ..Default::default()
2802        };
2803        assert!(check_requirements(&plan(sources("helixforge-no-such-tool", "sh"))).is_ok());
2804        assert!(matches!(
2805            check_requirements(&plan(sources(
2806                "helixforge-no-such-tool",
2807                "helixforge-no-such-tool-2"
2808            ))),
2809            Err(EngineError::MissingExecutable(_))
2810        ));
2811    }
2812
2813    #[test]
2814    fn quoting_keeps_metacharacters_literal() {
2815        let value = "spaces ' $(false) ; \" end";
2816        let output = Command::new("sh")
2817            .arg("-c")
2818            .arg(format!("printf %s {}", quote(value)))
2819            .output()
2820            .unwrap();
2821        assert_eq!(String::from_utf8(output.stdout).unwrap(), value);
2822    }
2823}