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