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