Skip to main content

helixforge_engine/
inventory.rs

1//! What is installed, where from, at which version, and whether an update is
2//! waiting: one snapshot from a few read-only queries (no root, no network:
3//! updates come from the repository metadata already on the machine), so a
4//! list of entries is answered by lookups.
5
6use crate::{PackageState, Plan, Probe, Source, Step, UpdateTarget};
7use helixforge_core::{Entry, PackageManager, Risk};
8use helixforge_platform::LinuxInfo;
9use std::{
10    collections::{HashMap, HashSet},
11    path::{Path, PathBuf},
12    process::{Command, Stdio},
13};
14
15/// How an entry stands, for showing it.
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub enum Status {
18    /// One of its own packages is installed and current. `shadowed` names
19    /// a Nix install of its command that comes first on the PATH.
20    Installed {
21        source: String,
22        version: Option<String>,
23        shadowed: Option<String>,
24    },
25    /// Installed, and the repository metadata has a newer version.
26    Update {
27        source: String,
28        version: Option<String>,
29        latest: Option<String>,
30        shadowed: Option<String>,
31    },
32    /// Its command comes from Nix: a profile, Home Manager or NixOS.
33    Managed { by: String, version: Option<String> },
34    /// Its command is on the PATH from an install HelixForge cannot place.
35    Elsewhere { path: PathBuf },
36    /// Not installed; `replaces` lists installed packages it conflicts with.
37    Absent { replaces: Vec<String> },
38}
39
40impl Status {
41    /// The short word the interface shows.
42    pub fn label(&self) -> &'static str {
43        match self {
44            Status::Installed { .. } | Status::Elsewhere { .. } => "Installed",
45            Status::Update { .. } => "Update",
46            Status::Managed { .. } => "Managed",
47            Status::Absent { .. } => "Not installed",
48        }
49    }
50
51    /// Where it came from and at which version, as one phrase, printable.
52    pub fn detail(&self) -> String {
53        helixforge_core::printable(&self.raw_detail())
54    }
55
56    fn raw_detail(&self) -> String {
57        let with = |source: &str, version: &Option<String>| match version {
58            Some(version) => format!("{source} {version}"),
59            None => source.to_string(),
60        };
61        let shadow = |text: String, shadowed: &Option<String>| match shadowed {
62            Some(shadowed) => format!("{text}; {shadowed} runs first"),
63            None => text,
64        };
65        match self {
66            Status::Installed {
67                source,
68                version,
69                shadowed,
70            } => shadow(with(source, version), shadowed),
71            Status::Update {
72                source,
73                version,
74                latest,
75                shadowed,
76            } => shadow(
77                match (version, latest) {
78                    (Some(version), Some(latest)) => format!("{source} {version} → {latest}"),
79                    _ => with(source, version),
80                },
81                shadowed,
82            ),
83            Status::Managed { by, version } => with(by, version),
84            Status::Elsewhere { path } => path.display().to_string(),
85            Status::Absent { replaces } if !replaces.is_empty() => {
86                format!("would replace {}", replaces.join(", "))
87            }
88            Status::Absent { .. } => String::new(),
89        }
90    }
91}
92
93/// A snapshot of the machine's packages.
94#[derive(Clone, Debug, Default)]
95pub struct Inventory {
96    manager: Option<PackageManager>,
97    /// Installed packages of the family's manager and their versions; None
98    /// for a family whose list is not read (the probes answer instead).
99    native: Option<HashMap<String, String>>,
100    /// Installed packages with a newer version in the repository metadata.
101    native_updates: HashMap<String, String>,
102    /// Installed Flatpak applications and their versions.
103    flatpaks: HashMap<String, String>,
104    flatpak_updates: HashSet<String>,
105    /// Where Home Manager's and NixOS's commands are, to name who manages a
106    /// command from the Nix store.
107    nix_places: Vec<(String, PathBuf)>,
108    /// Which package owns a file, fixed for tests; None asks the manager.
109    owners: Option<HashMap<PathBuf, String>>,
110}
111
112impl Inventory {
113    /// Read this machine.
114    pub fn gather(platform: &LinuxInfo) -> Self {
115        Self::gather_for(platform.manager)
116    }
117
118    /// Read this machine, whose packages `manager` keeps.
119    pub fn gather_for(manager: Option<PackageManager>) -> Self {
120        let mut inventory = Inventory {
121            manager,
122            nix_places: nix_places(),
123            ..Inventory::default()
124        };
125        match manager {
126            Some(PackageManager::Arch) => {
127                inventory.native = output(&["pacman", "-Q"]).map(|text| parse_pacman(&text));
128                inventory.native_updates = output(&["pacman", "-Qu"])
129                    .map(|text| parse_pacman_updates(&text))
130                    .unwrap_or_default();
131            }
132            Some(PackageManager::Debian) => {
133                inventory.native = output(&[
134                    "dpkg-query",
135                    "-W",
136                    "-f=${db:Status-Status} ${Package} ${Version}\n",
137                ])
138                .map(|text| parse_dpkg(&text));
139                inventory.native_updates = output(&["apt", "list", "--upgradable"])
140                    .map(|text| parse_apt_upgradable(&text))
141                    .unwrap_or_default();
142            }
143            Some(PackageManager::Fedora | PackageManager::Suse) => {
144                inventory.native =
145                    output(&["rpm", "-qa", "--qf", "%{NAME} %{VERSION}-%{RELEASE}\n"])
146                        .map(|text| parse_pacman(&text));
147                if manager == Some(PackageManager::Fedora) {
148                    // -C: the metadata already downloaded, no network.
149                    inventory.native_updates = output(&["dnf", "-C", "-q", "list", "--upgrades"])
150                        .map(|text| parse_dnf_upgrades(&text))
151                        .unwrap_or_default();
152                }
153            }
154            _ => {}
155        }
156        for installation in ["--system", "--user"] {
157            if let Some(text) = output(&[
158                "flatpak",
159                "list",
160                "--app",
161                installation,
162                "--columns=application,version",
163            ]) {
164                inventory.flatpaks.extend(parse_columns(&text));
165            }
166            if let Some(text) = output(&[
167                "flatpak",
168                "remote-ls",
169                "--updates",
170                "--app",
171                "--cached",
172                installation,
173                "--columns=application",
174            ]) {
175                inventory.flatpak_updates.extend(
176                    text.lines()
177                        .map(str::trim)
178                        .filter(|id| !id.is_empty())
179                        .map(String::from),
180                );
181            }
182        }
183        inventory
184    }
185
186    /// An inventory from known facts, for tests.
187    pub fn from_parts(
188        manager: Option<PackageManager>,
189        native: Option<HashMap<String, String>>,
190        native_updates: HashMap<String, String>,
191        flatpaks: HashMap<String, String>,
192        flatpak_updates: HashSet<String>,
193    ) -> Self {
194        Inventory {
195            manager,
196            native,
197            native_updates,
198            flatpaks,
199            flatpak_updates,
200            nix_places: Vec::new(),
201            owners: Some(HashMap::new()),
202        }
203    }
204
205    /// The same, with which package owns each file fixed, for tests.
206    pub fn with_owners(mut self, owners: HashMap<PathBuf, String>) -> Self {
207        self.owners = Some(owners);
208        self
209    }
210
211    /// Whether a probe holds, answered from the snapshot where it can be,
212    /// and by asking the machine where it cannot (families not read).
213    pub fn holds(&self, probe: &Probe) -> bool {
214        match probe {
215            Probe::Package { manager, name } if Some(*manager) == self.manager => {
216                match &self.native {
217                    Some(native) => native.contains_key(name),
218                    None => probe.holds(),
219                }
220            }
221            Probe::Package { .. } => probe.holds(),
222            Probe::Flatpak(id) => self.flatpaks.contains_key(id),
223        }
224    }
225
226    /// A package's installed version, when the snapshot has it.
227    pub fn version(&self, probe: &Probe) -> Option<String> {
228        match probe {
229            Probe::Package { manager, name } if Some(*manager) == self.manager => {
230                self.native.as_ref()?.get(name).cloned()
231            }
232            Probe::Package { .. } => None,
233            Probe::Flatpak(id) => self
234                .flatpaks
235                .get(id)
236                .filter(|version| !version.is_empty())
237                .cloned(),
238        }
239    }
240
241    /// Plan updating entries: each package with a newer version through its
242    /// own package manager or Flatpak, and on Arch one update of the whole
243    /// system for all of them, since pacman does not support updating one
244    /// package alone. Entries with nothing to update become notes.
245    pub fn plan_update<'a>(
246        &self,
247        entries: impl IntoIterator<Item = &'a Entry>,
248        platform: &LinuxInfo,
249        on_path: &dyn Fn(&str) -> Option<PathBuf>,
250    ) -> Plan {
251        let mut plan = Plan::default();
252        let mut system = Vec::new();
253        for entry in entries {
254            let Some(helixforge_core::Identity {
255                packages,
256                aur,
257                flatpak,
258                ..
259            }) = entry.identity()
260            else {
261                plan.notes.push(format!(
262                    "{}: its installer decides what it installs; run it again to update",
263                    entry.name
264                ));
265                continue;
266            };
267            let Some(status) = self.status(entry, platform, on_path) else {
268                continue;
269            };
270            match &status {
271                Status::Update { .. } => {}
272                Status::Installed { .. } => {
273                    plan.notes
274                        .push(format!("{}: up to date ({})", entry.name, status.detail()));
275                    continue;
276                }
277                Status::Managed { by, .. } => {
278                    let how = match by.as_str() {
279                        "Home Manager" => "update its flake inputs and switch",
280                        "NixOS" => "nixos-rebuild switch --upgrade",
281                        _ => "nix profile upgrade",
282                    };
283                    plan.notes.push(format!(
284                        "{}: managed by {by}; update it there ({how})",
285                        entry.name
286                    ));
287                    continue;
288                }
289                Status::Elsewhere { path } => {
290                    plan.notes.push(format!(
291                        "{}: installed outside a package manager ({}); update it the way it was installed",
292                        entry.name,
293                        path.display()
294                    ));
295                    continue;
296                }
297                Status::Absent { .. } => {
298                    plan.notes.push(format!("{}: not installed", entry.name));
299                    continue;
300                }
301            }
302            let probes = crate::own_probes(packages, aur, flatpak, platform.manager);
303            let owned = || {
304                let binary = entry.identity()?.binary?;
305                let path = on_path(binary)?;
306                let target = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
307                let (manager, name) = self.owner(&path).or_else(|| self.owner(&target))?;
308                Some(Probe::Package { manager, name })
309            };
310            let Some(probe) = probes
311                .into_iter()
312                .find(|probe| self.holds(probe))
313                .or_else(owned)
314            else {
315                continue;
316            };
317            let target = UpdateTarget {
318                name: entry.name.clone(),
319                from: self.version(&probe),
320                probe: probe.clone(),
321            };
322            let source = match &probe {
323                Probe::Package {
324                    manager: PackageManager::Arch,
325                    ..
326                } => {
327                    system.push(target);
328                    continue;
329                }
330                Probe::Package { manager, name } => Source {
331                    label: manager.executable().to_string(),
332                    commands: vec![crate::words(update_command(*manager), &[name])],
333                    requires: vec![manager.executable().to_string(), "sudo".to_string()],
334                    probe: Some(probe.clone()),
335                },
336                Probe::Flatpak(id) => {
337                    // The installation that has it: the system's, else the user's.
338                    let system_wide = Path::new("/var/lib/flatpak/app").join(id).is_dir();
339                    Source {
340                        label: "Flathub".to_string(),
341                        commands: vec![if system_wide {
342                            crate::words(vec!["sudo", "flatpak", "update"], &[id])
343                        } else {
344                            crate::words(vec!["flatpak", "update", "--user"], &[id])
345                        }],
346                        requires: if system_wide {
347                            vec!["flatpak".to_string(), "sudo".to_string()]
348                        } else {
349                            vec!["flatpak".to_string()]
350                        },
351                        probe: Some(probe.clone()),
352                    }
353                }
354            };
355            plan.privileged |= source.requires.iter().any(|tool| tool == "sudo");
356            plan.risk = plan.risk.max(Risk::Medium);
357            plan.requirements.extend(source.requires.iter().cloned());
358            plan.steps.push(Step::Update {
359                id: entry.id.clone(),
360                name: format!("Update {}", entry.name),
361                source,
362                targets: vec![target],
363            });
364        }
365        if !system.is_empty() {
366            let names: Vec<&str> = system.iter().map(|target| target.name.as_str()).collect();
367            plan.notes.push(format!(
368                "On Arch, updating {} alone would be a partial upgrade, which Arch does not support; this updates the whole system",
369                names.join(", ")
370            ));
371            plan.privileged = true;
372            plan.risk = Risk::High;
373            plan.requirements
374                .extend(["pacman".to_string(), "sudo".to_string()]);
375            plan.steps.push(Step::Update {
376                id: "system-update".to_string(),
377                name: format!("Update the system (for {})", names.join(", ")),
378                source: Source {
379                    label: "pacman".to_string(),
380                    commands: vec![crate::words(vec!["sudo", "pacman", "-Syu"], &[])],
381                    requires: vec!["pacman".to_string(), "sudo".to_string()],
382                    probe: None,
383                },
384                targets: system,
385            });
386        }
387        plan.requirements.sort();
388        plan.requirements.dedup();
389        plan
390    }
391
392    /// How a package entry stands; None for other entries.
393    pub fn status(
394        &self,
395        entry: &Entry,
396        platform: &LinuxInfo,
397        on_path: &dyn Fn(&str) -> Option<PathBuf>,
398    ) -> Option<Status> {
399        let helixforge_core::Identity {
400            binary,
401            packages,
402            aur,
403            flatpak,
404            ..
405        } = entry.identity()?;
406        let holds = |probe: &Probe| self.holds(probe);
407        let found = |binary: &str| on_path(binary).is_some();
408        let machine = crate::Machine {
409            holds: &holds,
410            on_path: &found,
411        };
412        match crate::package_state(entry, platform, machine)? {
413            PackageState::Absent { replaces } => Some(Status::Absent { replaces }),
414            PackageState::OnPath(_) => {
415                let path = binary.and_then(on_path)?;
416                Some(self.placed(binary.unwrap_or_default(), path))
417            }
418            PackageState::Installed(_) => {
419                // A Nix install of the same command that the PATH finds first.
420                let shadowed =
421                    binary.and_then(|binary| match self.placed(binary, on_path(binary)?) {
422                        Status::Managed { by, version } => Some(match version {
423                            Some(version) => format!("{by}'s {version}"),
424                            None => format!("{by}'s"),
425                        }),
426                        _ => None,
427                    });
428                // Which of its sources: the family's package, the AUR's, the Flatpak.
429                let manager = platform.manager;
430                let native = manager.and_then(|manager| {
431                    packages
432                        .for_manager(manager)
433                        .map(|name| (name, source_name(manager).to_string()))
434                });
435                let from_aur = (manager == Some(PackageManager::Arch))
436                    .then_some(aur)
437                    .flatten()
438                    .map(|name| (name, "AUR".to_string()));
439                for (name, source) in native.into_iter().chain(from_aur) {
440                    let Some(manager) = manager else { break };
441                    let probe = Probe::Package {
442                        manager,
443                        name: name.to_string(),
444                    };
445                    if self.holds(&probe) {
446                        let version = self
447                            .native
448                            .as_ref()
449                            .and_then(|native| native.get(name))
450                            .cloned();
451                        return Some(match self.native_updates.get(name) {
452                            Some(latest) => Status::Update {
453                                source,
454                                version,
455                                latest: Some(latest.clone()),
456                                shadowed,
457                            },
458                            None => Status::Installed {
459                                source,
460                                version,
461                                shadowed,
462                            },
463                        });
464                    }
465                }
466                let id = flatpak?;
467                let version = self
468                    .flatpaks
469                    .get(id)
470                    .filter(|version| !version.is_empty())
471                    .cloned();
472                let source = "Flatpak".to_string();
473                Some(if self.flatpak_updates.contains(id) {
474                    Status::Update {
475                        source,
476                        version,
477                        latest: None,
478                        shadowed: None,
479                    }
480                } else {
481                    Status::Installed {
482                        source,
483                        version,
484                        shadowed: None,
485                    }
486                })
487            }
488        }
489    }
490
491    /// Who provides a command found on the PATH but in no package.
492    fn placed(&self, binary: &str, path: PathBuf) -> Status {
493        let target = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
494        let resolve = |path: &Path| std::fs::canonicalize(path).ok();
495        if let Some(by) = nix_owner(binary, &target, &self.nix_places, &resolve) {
496            return Status::Managed {
497                by,
498                version: store_version(&target),
499            };
500        }
501        // A package other than the entry's own may have installed it.
502        let Some((manager, name)) = self.owner(&path).or_else(|| self.owner(&target)) else {
503            return Status::Elsewhere { path };
504        };
505        let source = format!("{}: {name}", source_name(manager));
506        let version = self
507            .native
508            .as_ref()
509            .and_then(|native| native.get(&name))
510            .cloned();
511        match self.native_updates.get(&name) {
512            Some(latest) => Status::Update {
513                source,
514                version,
515                latest: Some(latest.clone()),
516                shadowed: None,
517            },
518            None => Status::Installed {
519                source,
520                version,
521                shadowed: None,
522            },
523        }
524    }
525
526    /// The package of this machine's manager that owns a file.
527    pub fn owner(&self, path: &Path) -> Option<(PackageManager, String)> {
528        let manager = self.manager?;
529        if let Some(owners) = &self.owners {
530            return owners.get(path).map(|name| (manager, name.clone()));
531        }
532        let path = path.to_str()?;
533        let name = match manager {
534            PackageManager::Arch => output(&["pacman", "-Qoq", path])?,
535            PackageManager::Debian => output(&["dpkg", "-S", path])?
536                .split(':')
537                .next()?
538                .to_string(),
539            PackageManager::Fedora | PackageManager::Suse => {
540                output(&["rpm", "-qf", "--qf", "%{NAME}", path])?
541            }
542            _ => return None,
543        };
544        let name = name.lines().next()?.trim().to_string();
545        // Errors go to standard error; a valid name is all that counts.
546        helixforge_core::valid_package_name(&name).then_some((manager, name))
547    }
548}
549
550/// The command that updates named packages of a family, without the names;
551/// Arch updates the whole system instead.
552fn update_command(manager: PackageManager) -> Vec<&'static str> {
553    match manager {
554        PackageManager::Arch => vec!["sudo", "pacman", "-Syu"],
555        PackageManager::Debian => vec!["sudo", "apt-get", "install", "--only-upgrade"],
556        PackageManager::Fedora => vec!["sudo", "dnf", "upgrade"],
557        PackageManager::Suse => vec!["sudo", "zypper", "update"],
558        PackageManager::Alpine => vec!["sudo", "apk", "upgrade"],
559        PackageManager::Void => vec!["sudo", "xbps-install", "-u"],
560        PackageManager::Solus => vec!["sudo", "eopkg", "upgrade"],
561    }
562}
563
564/// The name people know a family's packages by.
565fn source_name(manager: PackageManager) -> &'static str {
566    match manager {
567        PackageManager::Debian => "apt",
568        manager => manager.executable(),
569    }
570}
571
572/// Run a read-only query; its output when it ran. pacman -Qu exits 1 when
573/// nothing is outdated, so the status is not checked, only that it ran.
574fn output(command: &[&str]) -> Option<String> {
575    let output = Command::new(command[0])
576        .args(&command[1..])
577        .stdin(Stdio::null())
578        .stderr(Stdio::null())
579        .env("LC_ALL", "C")
580        .output()
581        .ok()?;
582    Some(String::from_utf8_lossy(&output.stdout).into_owned())
583}
584
585/// `name version` lines (pacman -Q, and rpm with that format).
586pub fn parse_pacman(text: &str) -> HashMap<String, String> {
587    text.lines()
588        .filter_map(|line| line.split_once(' '))
589        .map(|(name, version)| (name.to_string(), version.trim().to_string()))
590        .collect()
591}
592
593/// `name old -> new` lines from pacman -Qu, skipping ignored packages.
594pub fn parse_pacman_updates(text: &str) -> HashMap<String, String> {
595    text.lines()
596        .filter(|line| !line.contains("[ignored]"))
597        .filter_map(|line| {
598            let mut words = line.split_whitespace();
599            let name = words.next()?;
600            let _old = words.next()?;
601            (words.next()? == "->").then_some(())?;
602            Some((name.to_string(), words.next()?.to_string()))
603        })
604        .collect()
605}
606
607/// `status package version` lines from dpkg-query; only installed ones.
608pub fn parse_dpkg(text: &str) -> HashMap<String, String> {
609    text.lines()
610        .filter_map(|line| {
611            let mut words = line.split_whitespace();
612            (words.next()? == "installed").then_some(())?;
613            Some((words.next()?.to_string(), words.next()?.to_string()))
614        })
615        .collect()
616}
617
618/// `name/suite version arch [upgradable from: old]` lines from apt list.
619pub fn parse_apt_upgradable(text: &str) -> HashMap<String, String> {
620    text.lines()
621        .filter(|line| line.contains("[upgradable from:"))
622        .filter_map(|line| {
623            let mut words = line.split_whitespace();
624            let name = words.next()?.split('/').next()?;
625            Some((name.to_string(), words.next()?.to_string()))
626        })
627        .collect()
628}
629
630/// `name.arch version repository` lines from dnf list --upgrades.
631pub fn parse_dnf_upgrades(text: &str) -> HashMap<String, String> {
632    text.lines()
633        .filter_map(|line| {
634            let mut words = line.split_whitespace();
635            let package = words.next()?;
636            let version = words.next()?;
637            let (name, arch) = package.rsplit_once('.')?;
638            // Headings such as "Available upgrades" have no architecture.
639            (!arch.is_empty() && version.chars().next()?.is_ascii_digit())
640                .then(|| (name.to_string(), version.to_string()))
641        })
642        .collect()
643}
644
645/// Tab-separated `id version` columns from flatpak list.
646pub fn parse_columns(text: &str) -> HashMap<String, String> {
647    text.lines()
648        .filter_map(|line| {
649            let mut columns = line.split('\t');
650            let id = columns.next()?.trim();
651            (!id.is_empty()).then(|| {
652                (
653                    id.to_string(),
654                    columns.next().unwrap_or("").trim().to_string(),
655                )
656            })
657        })
658        .collect()
659}
660
661/// The version in a Nix store path: `/nix/store/<hash>-ripgrep-15.2.0/bin/rg`
662/// gives 15.2.0.
663pub fn store_version(path: &Path) -> Option<String> {
664    let rest = path.to_str()?.strip_prefix("/nix/store/")?;
665    let name = rest.split('/').next()?;
666    let (_hash, name) = name.split_once('-')?;
667    let mut parts = name.split('-');
668    let start = parts.position(|part| part.starts_with(|c: char| c.is_ascii_digit()))?;
669    let version: Vec<&str> = name.split('-').skip(start).collect();
670    Some(version.join("-"))
671}
672
673/// Home Manager's and NixOS's command directories on this machine.
674fn nix_places() -> Vec<(String, PathBuf)> {
675    let mut places = Vec::new();
676    let home = std::env::var_os("HOME").map(PathBuf::from);
677    let state = std::env::var_os("XDG_STATE_HOME")
678        .map(PathBuf::from)
679        .or_else(|| home.as_ref().map(|home| home.join(".local/state")));
680    if let Some(state) = state {
681        places.push((
682            "Home Manager".to_string(),
683            state.join("home-manager/gcroots/current-home/home-path/bin"),
684        ));
685    }
686    if let Ok(user) = std::env::var("USER") {
687        places.push((
688            "Home Manager".to_string(),
689            PathBuf::from(format!("/etc/profiles/per-user/{user}/bin")),
690        ));
691    }
692    places.push((
693        "NixOS".to_string(),
694        PathBuf::from("/run/current-system/sw/bin"),
695    ));
696    places
697}
698
699/// Who manages a command that resolves into the Nix store: whichever of
700/// `places` has the same command resolving to the same file, else Nix (a
701/// profile or nix-env). None outside the store. `resolve` follows links.
702pub fn nix_owner(
703    binary: &str,
704    target: &Path,
705    places: &[(String, PathBuf)],
706    resolve: &dyn Fn(&Path) -> Option<PathBuf>,
707) -> Option<String> {
708    if !target.starts_with("/nix/store") {
709        return None;
710    }
711    for (name, place) in places {
712        if resolve(&place.join(binary)).is_some_and(|other| other == target) {
713            return Some(name.clone());
714        }
715    }
716    Some("Nix".to_string())
717}
718
719/// Where a command is on the PATH.
720pub fn which(binary: &str) -> Option<PathBuf> {
721    if binary.is_empty() || binary.contains('/') {
722        return None;
723    }
724    let paths = std::env::var_os("PATH")?;
725    std::env::split_paths(&paths)
726        .map(|dir| dir.join(binary))
727        .find(|path| helixforge_platform::binary_exists_at(path))
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733    use helixforge_core::{Action, PackageNames};
734
735    fn platform(manager: PackageManager) -> LinuxInfo {
736        LinuxInfo {
737            id: "test".into(),
738            pretty_name: "Test Linux".into(),
739            id_like: vec![],
740            manager: Some(manager),
741        }
742    }
743
744    fn package(
745        id: &str,
746        binary: Option<&str>,
747        arch: Option<&str>,
748        aur: Option<&str>,
749        flatpak: Option<&str>,
750        conflicts: &[&str],
751    ) -> Entry {
752        Entry {
753            id: id.into(),
754            name: id.into(),
755            description: String::new(),
756            flags: "I".into(),
757            multi_select: true,
758            writes: Vec::new(),
759            action: Action::Package {
760                binary: binary.map(Into::into),
761                packages: PackageNames {
762                    arch: arch.map(Into::into),
763                    ..Default::default()
764                },
765                aur: aur.map(Into::into),
766                flatpak: flatpak.map(Into::into),
767                conflicts: conflicts.iter().map(|name| name.to_string()).collect(),
768                removable: true,
769            },
770            detect: None,
771            needs: Default::default(),
772            effects: Default::default(),
773        }
774    }
775
776    fn map(pairs: &[(&str, &str)]) -> HashMap<String, String> {
777        pairs
778            .iter()
779            .map(|(name, version)| (name.to_string(), version.to_string()))
780            .collect()
781    }
782
783    #[test]
784    fn package_lists_are_read() {
785        assert_eq!(
786            parse_pacman("ripgrep 15.2.0-1\nwine 11.19-1\n"),
787            map(&[("ripgrep", "15.2.0-1"), ("wine", "11.19-1")])
788        );
789        assert_eq!(
790            parse_pacman_updates("ripgrep 15.2.0-1 -> 15.3.0-1\nlinux 6.1-1 -> 6.2-1 [ignored]\n"),
791            map(&[("ripgrep", "15.3.0-1")])
792        );
793        assert_eq!(
794            parse_dpkg("installed ripgrep 14.1.0-1\nconfig-files old 1.0\nnot-installed gone 2\n"),
795            map(&[("ripgrep", "14.1.0-1")])
796        );
797        assert_eq!(
798            parse_apt_upgradable(
799                "Listing...\nripgrep/noble-updates 14.1.1-1 amd64 [upgradable from: 14.1.0-1]\n"
800            ),
801            map(&[("ripgrep", "14.1.1-1")])
802        );
803        assert_eq!(
804            parse_dnf_upgrades("Available upgrades\nripgrep.x86_64 14.1.1-2.fc43 updates\n"),
805            map(&[("ripgrep", "14.1.1-2.fc43")])
806        );
807        assert_eq!(
808            parse_columns("com.rtosta.zapzap\t7.5\norg.example.NoVersion\t\n"),
809            map(&[("com.rtosta.zapzap", "7.5"), ("org.example.NoVersion", "")])
810        );
811    }
812
813    #[test]
814    fn nix_store_paths_give_versions_and_owners() {
815        let rg = Path::new("/nix/store/vli6118fdbngwy46j1w741ansmrzfszr-ripgrep-15.2.0/bin/rg");
816        assert_eq!(store_version(rg).as_deref(), Some("15.2.0"));
817        assert_eq!(
818            store_version(Path::new(
819                "/nix/store/abc-python3.12-black-24.2.0/bin/black"
820            ))
821            .as_deref(),
822            Some("24.2.0")
823        );
824        assert_eq!(store_version(Path::new("/usr/bin/rg")), None);
825        let places = vec![
826            ("Home Manager".to_string(), PathBuf::from("/hm/bin")),
827            (
828                "NixOS".to_string(),
829                PathBuf::from("/run/current-system/sw/bin"),
830            ),
831        ];
832        let from_home_manager =
833            |path: &Path| (path == Path::new("/hm/bin/rg")).then(|| rg.to_path_buf());
834        assert_eq!(
835            nix_owner("rg", rg, &places, &from_home_manager).as_deref(),
836            Some("Home Manager")
837        );
838        assert_eq!(
839            nix_owner("rg", rg, &places, &|_| None).as_deref(),
840            Some("Nix")
841        );
842        assert_eq!(
843            nix_owner("rg", Path::new("/usr/bin/rg"), &places, &|_| None),
844            None
845        );
846    }
847
848    #[test]
849    fn statuses_say_installed_update_managed_and_not_installed() {
850        let arch = platform(PackageManager::Arch);
851        let inventory = Inventory::from_parts(
852            Some(PackageManager::Arch),
853            Some(map(&[
854                ("ripgrep", "15.2.0-1"),
855                ("shelly", "3.1.6-1"),
856                ("wine", "11.19-1"),
857                ("meld", "3.24.1-1"),
858            ])),
859            map(&[("ripgrep", "15.3.0-1")]),
860            map(&[("com.rtosta.zapzap", "7.5"), ("org.example.Old", "1.0")]),
861            ["org.example.Old".to_string()].into_iter().collect(),
862        );
863        let nowhere = |_: &str| None;
864        let status = |entry: &Entry| inventory.status(entry, &arch, &nowhere).unwrap();
865        assert_eq!(
866            status(&package(
867                "meld",
868                Some("meld"),
869                Some("meld"),
870                None,
871                None,
872                &[]
873            )),
874            Status::Installed {
875                source: "pacman".into(),
876                version: Some("3.24.1-1".into()),
877                shadowed: None
878            }
879        );
880        let ripgrep = status(&package(
881            "ripgrep",
882            Some("rg"),
883            Some("ripgrep"),
884            None,
885            None,
886            &[],
887        ));
888        assert_eq!(ripgrep.label(), "Update");
889        assert_eq!(ripgrep.detail(), "pacman 15.2.0-1 → 15.3.0-1");
890        assert_eq!(
891            status(&package("shelly", None, None, Some("shelly"), None, &[])).detail(),
892            "AUR 3.1.6-1"
893        );
894        let zapzap = status(&package(
895            "zapzap",
896            Some("zapzap"),
897            None,
898            Some("zapzap"),
899            Some("com.rtosta.zapzap"),
900            &[],
901        ));
902        assert_eq!(
903            (zapzap.label(), zapzap.detail()),
904            ("Installed", "Flatpak 7.5".to_string())
905        );
906        let old = status(&package(
907            "old",
908            None,
909            None,
910            None,
911            Some("org.example.Old"),
912            &[],
913        ));
914        assert_eq!(
915            (old.label(), old.detail()),
916            ("Update", "Flatpak 1.0".to_string())
917        );
918        let staging = status(&package(
919            "wine-staging",
920            None,
921            Some("wine-staging"),
922            None,
923            None,
924            &["wine"],
925        ));
926        assert_eq!(
927            (staging.label(), staging.detail()),
928            ("Not installed", "would replace wine".to_string())
929        );
930        assert_eq!(
931            status(&package(
932                "blender",
933                Some("blender"),
934                Some("blender"),
935                None,
936                None,
937                &[]
938            )),
939            Status::Absent { replaces: vec![] }
940        );
941        // A command from the Nix store, with no package: managed by Nix.
942        let from_nix = |binary: &str| {
943            (binary == "btop").then(|| PathBuf::from("/nix/store/bvxz-btop-1.4.7/bin/btop"))
944        };
945        let btop = inventory
946            .status(
947                &package("btop", Some("btop"), Some("btop"), None, None, &[]),
948                &arch,
949                &from_nix,
950            )
951            .unwrap();
952        assert_eq!(
953            (btop.label(), btop.detail()),
954            ("Managed", "Nix 1.4.7".to_string())
955        );
956        // The package installed, and a Nix copy of its command first on the PATH.
957        let shadowing = |binary: &str| {
958            (binary == "rg").then(|| PathBuf::from("/nix/store/vli6-ripgrep-15.2.0/bin/rg"))
959        };
960        let both = inventory
961            .status(
962                &package("ripgrep", Some("rg"), Some("ripgrep"), None, None, &[]),
963                &arch,
964                &shadowing,
965            )
966            .unwrap();
967        assert_eq!(
968            both.detail(),
969            "pacman 15.2.0-1 → 15.3.0-1; Nix's 15.2.0 runs first"
970        );
971        // From somewhere HelixForge cannot place.
972        let local =
973            |binary: &str| (binary == "tool").then(|| PathBuf::from("/opt/helixforge-test/tool"));
974        let tool = inventory
975            .status(
976                &package("tool", Some("tool"), Some("tool"), None, None, &[]),
977                &arch,
978                &local,
979            )
980            .unwrap();
981        assert_eq!(
982            (tool.label(), tool.detail()),
983            ("Installed", "/opt/helixforge-test/tool".to_string())
984        );
985        let script = Entry {
986            action: Action::Command {
987                line: "true".into(),
988            },
989            ..package("s", None, None, None, None, &[])
990        };
991        assert_eq!(inventory.status(&script, &arch, &nowhere), None);
992    }
993
994    #[test]
995    fn updates_are_planned_per_family_and_arch_updates_the_system() {
996        let arch = platform(PackageManager::Arch);
997        let inventory = Inventory::from_parts(
998            Some(PackageManager::Arch),
999            Some(map(&[
1000                ("ripgrep", "15.2.0-1"),
1001                ("meld", "3.24.1-1"),
1002                ("git", "2.56.0-1"),
1003            ])),
1004            map(&[("ripgrep", "15.3.0-1"), ("meld", "3.24.2-1")]),
1005            map(&[("org.example.Old", "1.0")]),
1006            ["org.example.Old".to_string()].into_iter().collect(),
1007        );
1008        let ripgrep = package("ripgrep", Some("rg"), Some("ripgrep"), None, None, &[]);
1009        let meld = package("meld", Some("meld"), Some("meld"), None, None, &[]);
1010        let git = package("git", Some("git"), Some("git"), None, None, &[]);
1011        let old = package("old", None, None, None, Some("org.example.Old"), &[]);
1012        let gone = package("blender", None, Some("blender"), None, None, &[]);
1013        let script = Entry {
1014            action: Action::Command {
1015                line: "true".into(),
1016            },
1017            ..package("steam", None, None, None, None, &[])
1018        };
1019        let nowhere = |_: &str| None;
1020        let planned = inventory.plan_update(
1021            [&ripgrep, &meld, &git, &old, &gone, &script],
1022            &arch,
1023            &nowhere,
1024        );
1025        assert_eq!(planned.steps.len(), 2);
1026        // The Flatpak on its own, for the installation that has it.
1027        assert_eq!(
1028            planned.steps[0].display(),
1029            "flatpak update --user org.example.Old"
1030        );
1031        // Both Arch packages through one whole-system update.
1032        let Step::Update { targets, .. } = &planned.steps[1] else {
1033            panic!("expected an update step");
1034        };
1035        assert_eq!(planned.steps[1].display(), "sudo pacman -Syu");
1036        assert_eq!(
1037            planned.steps[1].name(),
1038            "Update the system (for ripgrep, meld)"
1039        );
1040        assert_eq!(targets.len(), 2);
1041        assert_eq!(targets[0].from.as_deref(), Some("15.2.0-1"));
1042        assert_eq!(planned.risk, Risk::High);
1043        assert!(planned.privileged);
1044        assert_eq!(planned.requirements, ["flatpak", "pacman", "sudo"]);
1045        assert!(
1046            planned
1047                .notes
1048                .iter()
1049                .any(|note| note.starts_with("git: up to date (pacman 2.56.0-1)"))
1050        );
1051        assert!(
1052            planned
1053                .notes
1054                .iter()
1055                .any(|note| note == "blender: not installed")
1056        );
1057        assert!(
1058            planned
1059                .notes
1060                .iter()
1061                .any(|note| note.contains("partial upgrade"))
1062        );
1063        assert!(
1064            planned
1065                .notes
1066                .iter()
1067                .any(|note| note.starts_with("steam: its installer decides"))
1068        );
1069
1070        let debian = platform(PackageManager::Debian);
1071        let apt = Inventory::from_parts(
1072            Some(PackageManager::Debian),
1073            Some(map(&[("ripgrep", "14.1.0-1")])),
1074            map(&[("ripgrep", "14.1.1-1")]),
1075            HashMap::new(),
1076            HashSet::new(),
1077        );
1078        let mut deb = ripgrep.clone();
1079        if let Action::Package { packages, .. } = &mut deb.action {
1080            packages.debian = Some("ripgrep".into());
1081        }
1082        let planned = apt.plan_update([&deb], &debian, &nowhere);
1083        assert_eq!(
1084            planned.steps[0].display(),
1085            "sudo apt-get install --only-upgrade ripgrep"
1086        );
1087        assert_eq!(planned.risk, Risk::Medium);
1088        // Managed by Nix: nothing to run, and where to update it.
1089        let from_nix = |binary: &str| {
1090            (binary == "btop").then(|| PathBuf::from("/nix/store/x-btop-1.4.7/bin/btop"))
1091        };
1092        let btop = package("btop", Some("btop"), Some("btop"), None, None, &[]);
1093        let planned = inventory.plan_update([&btop], &arch, &from_nix);
1094        assert!(planned.steps.is_empty());
1095        assert_eq!(
1096            planned.notes,
1097            ["btop: managed by Nix; update it there (nix profile upgrade)"]
1098        );
1099    }
1100
1101    #[test]
1102    fn updates_are_verified_by_their_versions() {
1103        let probe = Probe::Package {
1104            manager: PackageManager::Arch,
1105            name: "ripgrep".into(),
1106        };
1107        let plan = Plan {
1108            steps: vec![Step::Update {
1109                id: "system-update".into(),
1110                name: "Update the system".into(),
1111                source: Source {
1112                    label: "pacman".into(),
1113                    commands: vec![vec!["sudo".into(), "pacman".into(), "-Syu".into()]],
1114                    requires: Vec::new(),
1115                    probe: None,
1116                },
1117                targets: vec![UpdateTarget {
1118                    name: "Ripgrep".into(),
1119                    probe: probe.clone(),
1120                    from: Some("15.2.0-1".into()),
1121                }],
1122            }],
1123            ..Plan::default()
1124        };
1125        let check = |now: Option<&str>, holds: bool| {
1126            let now = now.map(String::from);
1127            crate::verify_updates_with(&plan, &|_| now.clone(), &|_| holds).remove(0)
1128        };
1129        let updated = check(Some("15.3.0-1"), true);
1130        assert!(updated.passed);
1131        assert_eq!(updated.detail, "updated 15.2.0-1 → 15.3.0-1");
1132        assert_eq!(updated.name, "Update Ripgrep");
1133        let same = check(Some("15.2.0-1"), true);
1134        assert!(!same.passed);
1135        assert_eq!(
1136            same.detail,
1137            "still 15.2.0-1: no newer version was installed"
1138        );
1139        assert!(
1140            check(None, true).passed,
1141            "a family without versions: installed is enough"
1142        );
1143        assert!(!check(None, false).passed);
1144        // The run itself also stops if the package is gone afterwards.
1145        let program = crate::shell_program(&plan, Path::new("/lib"), Path::new("/hf"));
1146        assert!(program.contains("'sudo' 'pacman' '-Syu'"), "{program}");
1147        assert!(program.contains("'pacman' '-Q' 'ripgrep'"));
1148        assert!(program.contains("is not installed after the update"));
1149    }
1150
1151    #[test]
1152    fn a_command_from_another_package_is_shown_and_updated_through_it() {
1153        let arch = platform(PackageManager::Arch);
1154        let inventory = Inventory::from_parts(
1155            Some(PackageManager::Arch),
1156            Some(map(&[("github-desktop", "3.4.14-2")])),
1157            map(&[("github-desktop", "3.4.15-1")]),
1158            HashMap::new(),
1159            HashSet::new(),
1160        )
1161        .with_owners(
1162            [(
1163                PathBuf::from("/usr/bin/github-desktop"),
1164                "github-desktop".to_string(),
1165            )]
1166            .into(),
1167        );
1168        let mut entry = package(
1169            "github-desktop",
1170            Some("github-desktop"),
1171            None,
1172            Some("github-desktop-bin"),
1173            None,
1174            &[],
1175        );
1176        // A script entry that says what it installs, as the catalog's do.
1177        let identity = entry.identity().map(|identity| helixforge_core::Detect {
1178            binary: identity.binary.map(String::from),
1179            packages: identity.packages.clone(),
1180            aur: identity.aur.map(String::from),
1181            flatpak: None,
1182        });
1183        entry.action = Action::Script {
1184            file: PathBuf::from("githubdesktop.sh"),
1185            interactive: true,
1186        };
1187        entry.detect = identity;
1188        let found = |binary: &str| {
1189            (binary == "github-desktop").then(|| PathBuf::from("/usr/bin/github-desktop"))
1190        };
1191        let status = inventory.status(&entry, &arch, &found).unwrap();
1192        assert_eq!(status.label(), "Update");
1193        assert_eq!(
1194            status.detail(),
1195            "pacman: github-desktop 3.4.14-2 → 3.4.15-1"
1196        );
1197        let planned = inventory.plan_update([&entry], &arch, &found);
1198        let Step::Update { targets, .. } = &planned.steps[0] else {
1199            panic!("expected an update step");
1200        };
1201        assert_eq!(
1202            targets[0].probe,
1203            Probe::Package {
1204                manager: PackageManager::Arch,
1205                name: "github-desktop".into()
1206            }
1207        );
1208        assert_eq!(targets[0].from.as_deref(), Some("3.4.14-2"));
1209        // Not owned by any package: installed elsewhere, nothing to update.
1210        let unowned = inventory.clone().with_owners(HashMap::new());
1211        let planned = unowned.plan_update([&entry], &arch, &found);
1212        assert!(planned.steps.is_empty());
1213        assert!(planned.notes[0].contains("installed outside a package manager"));
1214    }
1215}