1use 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#[derive(Clone, Debug, Eq, PartialEq)]
17pub enum Status {
18 Installed {
21 source: String,
22 version: Option<String>,
23 shadowed: Option<String>,
24 },
25 Update {
27 source: String,
28 version: Option<String>,
29 latest: Option<String>,
30 shadowed: Option<String>,
31 },
32 Managed { by: String, version: Option<String> },
34 Elsewhere { path: PathBuf },
36 Absent { replaces: Vec<String> },
38}
39
40impl Status {
41 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 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#[derive(Clone, Debug, Default)]
95pub struct Inventory {
96 manager: Option<PackageManager>,
97 native: Option<HashMap<String, String>>,
100 native_updates: HashMap<String, String>,
102 flatpaks: HashMap<String, String>,
104 flatpak_updates: HashSet<String>,
105 nix_places: Vec<(String, PathBuf)>,
108 owners: Option<HashMap<PathBuf, String>>,
110}
111
112impl Inventory {
113 pub fn gather(platform: &LinuxInfo) -> Self {
115 Self::gather_for(platform.manager)
116 }
117
118 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 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 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 pub fn with_owners(mut self, owners: HashMap<PathBuf, String>) -> Self {
207 self.owners = Some(owners);
208 self
209 }
210
211 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 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 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 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 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 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 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 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 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 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 helixforge_core::valid_package_name(&name).then_some((manager, name))
547 }
548}
549
550fn 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
564fn source_name(manager: PackageManager) -> &'static str {
566 match manager {
567 PackageManager::Debian => "apt",
568 manager => manager.executable(),
569 }
570}
571
572fn 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
585pub 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
593pub 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
607pub 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
618pub 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
630pub 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 (!arch.is_empty() && version.chars().next()?.is_ascii_digit())
640 .then(|| (name.to_string(), version.to_string()))
641 })
642 .collect()
643}
644
645pub 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
661pub 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
673fn 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
699pub 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
719pub 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 }
773 }
774
775 fn map(pairs: &[(&str, &str)]) -> HashMap<String, String> {
776 pairs
777 .iter()
778 .map(|(name, version)| (name.to_string(), version.to_string()))
779 .collect()
780 }
781
782 #[test]
783 fn package_lists_are_read() {
784 assert_eq!(
785 parse_pacman("ripgrep 15.2.0-1\nwine 11.19-1\n"),
786 map(&[("ripgrep", "15.2.0-1"), ("wine", "11.19-1")])
787 );
788 assert_eq!(
789 parse_pacman_updates("ripgrep 15.2.0-1 -> 15.3.0-1\nlinux 6.1-1 -> 6.2-1 [ignored]\n"),
790 map(&[("ripgrep", "15.3.0-1")])
791 );
792 assert_eq!(
793 parse_dpkg("installed ripgrep 14.1.0-1\nconfig-files old 1.0\nnot-installed gone 2\n"),
794 map(&[("ripgrep", "14.1.0-1")])
795 );
796 assert_eq!(
797 parse_apt_upgradable(
798 "Listing...\nripgrep/noble-updates 14.1.1-1 amd64 [upgradable from: 14.1.0-1]\n"
799 ),
800 map(&[("ripgrep", "14.1.1-1")])
801 );
802 assert_eq!(
803 parse_dnf_upgrades("Available upgrades\nripgrep.x86_64 14.1.1-2.fc43 updates\n"),
804 map(&[("ripgrep", "14.1.1-2.fc43")])
805 );
806 assert_eq!(
807 parse_columns("com.rtosta.zapzap\t7.5\norg.example.NoVersion\t\n"),
808 map(&[("com.rtosta.zapzap", "7.5"), ("org.example.NoVersion", "")])
809 );
810 }
811
812 #[test]
813 fn nix_store_paths_give_versions_and_owners() {
814 let rg = Path::new("/nix/store/vli6118fdbngwy46j1w741ansmrzfszr-ripgrep-15.2.0/bin/rg");
815 assert_eq!(store_version(rg).as_deref(), Some("15.2.0"));
816 assert_eq!(
817 store_version(Path::new(
818 "/nix/store/abc-python3.12-black-24.2.0/bin/black"
819 ))
820 .as_deref(),
821 Some("24.2.0")
822 );
823 assert_eq!(store_version(Path::new("/usr/bin/rg")), None);
824 let places = vec![
825 ("Home Manager".to_string(), PathBuf::from("/hm/bin")),
826 (
827 "NixOS".to_string(),
828 PathBuf::from("/run/current-system/sw/bin"),
829 ),
830 ];
831 let from_home_manager =
832 |path: &Path| (path == Path::new("/hm/bin/rg")).then(|| rg.to_path_buf());
833 assert_eq!(
834 nix_owner("rg", rg, &places, &from_home_manager).as_deref(),
835 Some("Home Manager")
836 );
837 assert_eq!(
838 nix_owner("rg", rg, &places, &|_| None).as_deref(),
839 Some("Nix")
840 );
841 assert_eq!(
842 nix_owner("rg", Path::new("/usr/bin/rg"), &places, &|_| None),
843 None
844 );
845 }
846
847 #[test]
848 fn statuses_say_installed_update_managed_and_not_installed() {
849 let arch = platform(PackageManager::Arch);
850 let inventory = Inventory::from_parts(
851 Some(PackageManager::Arch),
852 Some(map(&[
853 ("ripgrep", "15.2.0-1"),
854 ("shelly", "3.1.6-1"),
855 ("wine", "11.19-1"),
856 ("meld", "3.24.1-1"),
857 ])),
858 map(&[("ripgrep", "15.3.0-1")]),
859 map(&[("com.rtosta.zapzap", "7.5"), ("org.example.Old", "1.0")]),
860 ["org.example.Old".to_string()].into_iter().collect(),
861 );
862 let nowhere = |_: &str| None;
863 let status = |entry: &Entry| inventory.status(entry, &arch, &nowhere).unwrap();
864 assert_eq!(
865 status(&package(
866 "meld",
867 Some("meld"),
868 Some("meld"),
869 None,
870 None,
871 &[]
872 )),
873 Status::Installed {
874 source: "pacman".into(),
875 version: Some("3.24.1-1".into()),
876 shadowed: None
877 }
878 );
879 let ripgrep = status(&package(
880 "ripgrep",
881 Some("rg"),
882 Some("ripgrep"),
883 None,
884 None,
885 &[],
886 ));
887 assert_eq!(ripgrep.label(), "Update");
888 assert_eq!(ripgrep.detail(), "pacman 15.2.0-1 → 15.3.0-1");
889 assert_eq!(
890 status(&package("shelly", None, None, Some("shelly"), None, &[])).detail(),
891 "AUR 3.1.6-1"
892 );
893 let zapzap = status(&package(
894 "zapzap",
895 Some("zapzap"),
896 None,
897 Some("zapzap"),
898 Some("com.rtosta.zapzap"),
899 &[],
900 ));
901 assert_eq!(
902 (zapzap.label(), zapzap.detail()),
903 ("Installed", "Flatpak 7.5".to_string())
904 );
905 let old = status(&package(
906 "old",
907 None,
908 None,
909 None,
910 Some("org.example.Old"),
911 &[],
912 ));
913 assert_eq!(
914 (old.label(), old.detail()),
915 ("Update", "Flatpak 1.0".to_string())
916 );
917 let staging = status(&package(
918 "wine-staging",
919 None,
920 Some("wine-staging"),
921 None,
922 None,
923 &["wine"],
924 ));
925 assert_eq!(
926 (staging.label(), staging.detail()),
927 ("Not installed", "would replace wine".to_string())
928 );
929 assert_eq!(
930 status(&package(
931 "blender",
932 Some("blender"),
933 Some("blender"),
934 None,
935 None,
936 &[]
937 )),
938 Status::Absent { replaces: vec![] }
939 );
940 let from_nix = |binary: &str| {
942 (binary == "btop").then(|| PathBuf::from("/nix/store/bvxz-btop-1.4.7/bin/btop"))
943 };
944 let btop = inventory
945 .status(
946 &package("btop", Some("btop"), Some("btop"), None, None, &[]),
947 &arch,
948 &from_nix,
949 )
950 .unwrap();
951 assert_eq!(
952 (btop.label(), btop.detail()),
953 ("Managed", "Nix 1.4.7".to_string())
954 );
955 let shadowing = |binary: &str| {
957 (binary == "rg").then(|| PathBuf::from("/nix/store/vli6-ripgrep-15.2.0/bin/rg"))
958 };
959 let both = inventory
960 .status(
961 &package("ripgrep", Some("rg"), Some("ripgrep"), None, None, &[]),
962 &arch,
963 &shadowing,
964 )
965 .unwrap();
966 assert_eq!(
967 both.detail(),
968 "pacman 15.2.0-1 → 15.3.0-1; Nix's 15.2.0 runs first"
969 );
970 let local =
972 |binary: &str| (binary == "tool").then(|| PathBuf::from("/opt/helixforge-test/tool"));
973 let tool = inventory
974 .status(
975 &package("tool", Some("tool"), Some("tool"), None, None, &[]),
976 &arch,
977 &local,
978 )
979 .unwrap();
980 assert_eq!(
981 (tool.label(), tool.detail()),
982 ("Installed", "/opt/helixforge-test/tool".to_string())
983 );
984 let script = Entry {
985 action: Action::Command {
986 line: "true".into(),
987 },
988 ..package("s", None, None, None, None, &[])
989 };
990 assert_eq!(inventory.status(&script, &arch, &nowhere), None);
991 }
992
993 #[test]
994 fn updates_are_planned_per_family_and_arch_updates_the_system() {
995 let arch = platform(PackageManager::Arch);
996 let inventory = Inventory::from_parts(
997 Some(PackageManager::Arch),
998 Some(map(&[
999 ("ripgrep", "15.2.0-1"),
1000 ("meld", "3.24.1-1"),
1001 ("git", "2.56.0-1"),
1002 ])),
1003 map(&[("ripgrep", "15.3.0-1"), ("meld", "3.24.2-1")]),
1004 map(&[("org.example.Old", "1.0")]),
1005 ["org.example.Old".to_string()].into_iter().collect(),
1006 );
1007 let ripgrep = package("ripgrep", Some("rg"), Some("ripgrep"), None, None, &[]);
1008 let meld = package("meld", Some("meld"), Some("meld"), None, None, &[]);
1009 let git = package("git", Some("git"), Some("git"), None, None, &[]);
1010 let old = package("old", None, None, None, Some("org.example.Old"), &[]);
1011 let gone = package("blender", None, Some("blender"), None, None, &[]);
1012 let script = Entry {
1013 action: Action::Command {
1014 line: "true".into(),
1015 },
1016 ..package("steam", None, None, None, None, &[])
1017 };
1018 let nowhere = |_: &str| None;
1019 let planned = inventory.plan_update(
1020 [&ripgrep, &meld, &git, &old, &gone, &script],
1021 &arch,
1022 &nowhere,
1023 );
1024 assert_eq!(planned.steps.len(), 2);
1025 assert_eq!(
1027 planned.steps[0].display(),
1028 "flatpak update --user org.example.Old"
1029 );
1030 let Step::Update { targets, .. } = &planned.steps[1] else {
1032 panic!("expected an update step");
1033 };
1034 assert_eq!(planned.steps[1].display(), "sudo pacman -Syu");
1035 assert_eq!(
1036 planned.steps[1].name(),
1037 "Update the system (for ripgrep, meld)"
1038 );
1039 assert_eq!(targets.len(), 2);
1040 assert_eq!(targets[0].from.as_deref(), Some("15.2.0-1"));
1041 assert_eq!(planned.risk, Risk::High);
1042 assert!(planned.privileged);
1043 assert_eq!(planned.requirements, ["flatpak", "pacman", "sudo"]);
1044 assert!(
1045 planned
1046 .notes
1047 .iter()
1048 .any(|note| note.starts_with("git: up to date (pacman 2.56.0-1)"))
1049 );
1050 assert!(
1051 planned
1052 .notes
1053 .iter()
1054 .any(|note| note == "blender: not installed")
1055 );
1056 assert!(
1057 planned
1058 .notes
1059 .iter()
1060 .any(|note| note.contains("partial upgrade"))
1061 );
1062 assert!(
1063 planned
1064 .notes
1065 .iter()
1066 .any(|note| note.starts_with("steam: its installer decides"))
1067 );
1068
1069 let debian = platform(PackageManager::Debian);
1070 let apt = Inventory::from_parts(
1071 Some(PackageManager::Debian),
1072 Some(map(&[("ripgrep", "14.1.0-1")])),
1073 map(&[("ripgrep", "14.1.1-1")]),
1074 HashMap::new(),
1075 HashSet::new(),
1076 );
1077 let mut deb = ripgrep.clone();
1078 if let Action::Package { packages, .. } = &mut deb.action {
1079 packages.debian = Some("ripgrep".into());
1080 }
1081 let planned = apt.plan_update([&deb], &debian, &nowhere);
1082 assert_eq!(
1083 planned.steps[0].display(),
1084 "sudo apt-get install --only-upgrade ripgrep"
1085 );
1086 assert_eq!(planned.risk, Risk::Medium);
1087 let from_nix = |binary: &str| {
1089 (binary == "btop").then(|| PathBuf::from("/nix/store/x-btop-1.4.7/bin/btop"))
1090 };
1091 let btop = package("btop", Some("btop"), Some("btop"), None, None, &[]);
1092 let planned = inventory.plan_update([&btop], &arch, &from_nix);
1093 assert!(planned.steps.is_empty());
1094 assert_eq!(
1095 planned.notes,
1096 ["btop: managed by Nix; update it there (nix profile upgrade)"]
1097 );
1098 }
1099
1100 #[test]
1101 fn updates_are_verified_by_their_versions() {
1102 let probe = Probe::Package {
1103 manager: PackageManager::Arch,
1104 name: "ripgrep".into(),
1105 };
1106 let plan = Plan {
1107 steps: vec![Step::Update {
1108 id: "system-update".into(),
1109 name: "Update the system".into(),
1110 source: Source {
1111 label: "pacman".into(),
1112 commands: vec![vec!["sudo".into(), "pacman".into(), "-Syu".into()]],
1113 requires: Vec::new(),
1114 probe: None,
1115 },
1116 targets: vec![UpdateTarget {
1117 name: "Ripgrep".into(),
1118 probe: probe.clone(),
1119 from: Some("15.2.0-1".into()),
1120 }],
1121 }],
1122 ..Plan::default()
1123 };
1124 let check = |now: Option<&str>, holds: bool| {
1125 let now = now.map(String::from);
1126 crate::verify_updates_with(&plan, &|_| now.clone(), &|_| holds).remove(0)
1127 };
1128 let updated = check(Some("15.3.0-1"), true);
1129 assert!(updated.passed);
1130 assert_eq!(updated.detail, "updated 15.2.0-1 → 15.3.0-1");
1131 assert_eq!(updated.name, "Update Ripgrep");
1132 let same = check(Some("15.2.0-1"), true);
1133 assert!(!same.passed);
1134 assert_eq!(
1135 same.detail,
1136 "still 15.2.0-1: no newer version was installed"
1137 );
1138 assert!(
1139 check(None, true).passed,
1140 "a family without versions: installed is enough"
1141 );
1142 assert!(!check(None, false).passed);
1143 let program = crate::shell_program(&plan, Path::new("/lib"), Path::new("/hf"));
1145 assert!(program.contains("'sudo' 'pacman' '-Syu'"), "{program}");
1146 assert!(program.contains("'pacman' '-Q' 'ripgrep'"));
1147 assert!(program.contains("is not installed after the update"));
1148 }
1149
1150 #[test]
1151 fn a_command_from_another_package_is_shown_and_updated_through_it() {
1152 let arch = platform(PackageManager::Arch);
1153 let inventory = Inventory::from_parts(
1154 Some(PackageManager::Arch),
1155 Some(map(&[("github-desktop", "3.4.14-2")])),
1156 map(&[("github-desktop", "3.4.15-1")]),
1157 HashMap::new(),
1158 HashSet::new(),
1159 )
1160 .with_owners(
1161 [(
1162 PathBuf::from("/usr/bin/github-desktop"),
1163 "github-desktop".to_string(),
1164 )]
1165 .into(),
1166 );
1167 let mut entry = package(
1168 "github-desktop",
1169 Some("github-desktop"),
1170 None,
1171 Some("github-desktop-bin"),
1172 None,
1173 &[],
1174 );
1175 let identity = entry.identity().map(|identity| helixforge_core::Detect {
1177 binary: identity.binary.map(String::from),
1178 packages: identity.packages.clone(),
1179 aur: identity.aur.map(String::from),
1180 flatpak: None,
1181 });
1182 entry.action = Action::Script {
1183 file: PathBuf::from("githubdesktop.sh"),
1184 interactive: true,
1185 };
1186 entry.detect = identity;
1187 let found = |binary: &str| {
1188 (binary == "github-desktop").then(|| PathBuf::from("/usr/bin/github-desktop"))
1189 };
1190 let status = inventory.status(&entry, &arch, &found).unwrap();
1191 assert_eq!(status.label(), "Update");
1192 assert_eq!(
1193 status.detail(),
1194 "pacman: github-desktop 3.4.14-2 → 3.4.15-1"
1195 );
1196 let planned = inventory.plan_update([&entry], &arch, &found);
1197 let Step::Update { targets, .. } = &planned.steps[0] else {
1198 panic!("expected an update step");
1199 };
1200 assert_eq!(
1201 targets[0].probe,
1202 Probe::Package {
1203 manager: PackageManager::Arch,
1204 name: "github-desktop".into()
1205 }
1206 );
1207 assert_eq!(targets[0].from.as_deref(), Some("3.4.14-2"));
1208 let unowned = inventory.clone().with_owners(HashMap::new());
1210 let planned = unowned.plan_update([&entry], &arch, &found);
1211 assert!(planned.steps.is_empty());
1212 assert!(planned.notes[0].contains("installed outside a package manager"));
1213 }
1214}