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