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