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