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