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 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 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 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 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 assert_eq!(
1028 planned.steps[0].display(),
1029 "flatpak update --user org.example.Old"
1030 );
1031 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 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 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 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 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}