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