1use crate::{Machine, PackageState, Plan, Probe, Step, missing_requirements, package_state};
7use helixforge_core::{Entry, PackageManager, valid_package_name};
8use helixforge_platform::LinuxInfo;
9use std::{path::Path, process::Command};
10
11#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
13pub enum Severity {
14 Warning,
15 Blocker,
16}
17
18#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct Finding {
21 pub severity: Severity,
22 pub message: String,
23}
24
25impl Finding {
26 fn blocker(message: String) -> Self {
27 Finding {
28 severity: Severity::Blocker,
29 message,
30 }
31 }
32
33 fn warning(message: String) -> Self {
34 Finding {
35 severity: Severity::Warning,
36 message,
37 }
38 }
39
40 pub fn line(&self) -> String {
42 match self.severity {
43 Severity::Blocker => format!("Blocked: {}", self.message),
44 Severity::Warning => format!("Warning: {}", self.message),
45 }
46 }
47}
48
49pub fn blocked(findings: &[Finding]) -> bool {
51 findings
52 .iter()
53 .any(|finding| finding.severity == Severity::Blocker)
54}
55
56pub struct Facts<'a> {
58 pub machine: Machine<'a>,
60 pub present: &'a dyn Fn(&str) -> bool,
62 pub free_bytes: &'a dyn Fn(&Path) -> Option<u64>,
64 pub gpus: &'a dyn Fn() -> Vec<String>,
66 pub repositories: &'a dyn Fn() -> Option<Vec<String>>,
68 pub sizes: &'a dyn Fn(PackageManager, &[String]) -> Option<u64>,
71 pub active_runs: &'a dyn Fn() -> Vec<String>,
73}
74
75impl Facts<'static> {
76 pub fn real() -> Self {
78 Facts {
79 machine: Machine::real(),
80 present: &crate::requirement_present,
81 free_bytes: &free_bytes,
82 gpus: &gpus,
83 repositories: &|| pacman_repositories(Path::new("/etc/pacman.conf")),
84 sizes: &installed_size,
85 active_runs: &crate::history::active,
86 }
87 }
88}
89
90const SPARE: u64 = 1 << 30;
92
93pub fn check<'a>(
95 plan: &Plan,
96 platform: &LinuxInfo,
97 lookup: &dyn Fn(&str) -> Option<&'a Entry>,
98 facts: &Facts,
99) -> Vec<Finding> {
100 let mut findings = Vec::new();
101 if !plan.steps.is_empty() {
103 for names in (facts.active_runs)() {
104 findings.push(Finding::blocker(format!(
105 "another HelixForge run is still going ({names}); wait for it to finish"
106 )));
107 }
108 }
109 for missing in missing_requirements(plan, facts.present) {
110 findings.push(Finding::blocker(format!(
111 "{missing} is needed and is not installed"
112 )));
113 }
114 let entries: Vec<&Entry> = plan
115 .steps
116 .iter()
117 .filter(|step| !matches!(step, Step::Update { .. }))
118 .filter(|step| !matches!(step, Step::Package { removal: true, .. }))
119 .filter_map(|step| lookup(step.id()))
120 .collect();
121 conflicts(&entries, platform, facts, &mut findings);
122 needs(&entries, platform, facts, &mut findings);
123 space(plan, &entries, platform, facts, &mut findings);
124 findings.sort_by_key(|finding| std::cmp::Reverse(finding.severity));
125 findings
126}
127
128fn installs(entry: &Entry, platform: &LinuxInfo) -> Vec<String> {
131 let Some(identity) = entry.identity() else {
132 return Vec::new();
133 };
134 let mut names = Vec::new();
135 if let Some(manager) = platform.manager {
136 names.extend(identity.packages.for_manager(manager).map(String::from));
137 if manager == PackageManager::Arch {
138 names.extend(identity.aur.map(String::from));
139 }
140 }
141 names
142}
143
144fn conflicts(entries: &[&Entry], platform: &LinuxInfo, facts: &Facts, into: &mut Vec<Finding>) {
147 for (index, entry) in entries.iter().enumerate() {
148 let Some(identity) = entry.identity() else {
149 continue;
150 };
151 for other in &entries[index + 1..] {
152 let theirs = installs(other, platform);
153 let ours = installs(entry, platform);
154 let clash = identity.conflicts.iter().any(|name| theirs.contains(name))
155 || other
156 .identity()
157 .is_some_and(|their| their.conflicts.iter().any(|name| ours.contains(name)));
158 if clash {
159 into.push(Finding::blocker(format!(
160 "{} and {} replace each other; queue only one",
161 entry.name, other.name
162 )));
163 }
164 }
165 if let Some(PackageState::Absent { replaces }) =
166 package_state(entry, platform, facts.machine)
167 && !replaces.is_empty()
168 {
169 into.push(Finding::warning(format!(
170 "{} replaces {}, which is installed; the package manager asks before removing it",
171 entry.name,
172 replaces.join(", ")
173 )));
174 }
175 }
176}
177
178fn foreign(entry: &Entry, here: &str) -> Option<Finding> {
180 (!entry.needs.runs_on(here)).then(|| {
181 Finding::blocker(format!(
182 "{} is for {} only, and this machine is {here}",
183 entry.name,
184 entry.needs.architectures.join(" and ")
185 ))
186 })
187}
188
189fn needs(entries: &[&Entry], platform: &LinuxInfo, facts: &Facts, into: &mut Vec<Finding>) {
191 let mut enabled = None;
192 let mut found = None;
193 for entry in entries {
194 into.extend(foreign(entry, std::env::consts::ARCH));
195 if platform.manager == Some(PackageManager::Arch) && !entry.needs.repositories.is_empty() {
196 let enabled = enabled.get_or_insert_with(|| (facts.repositories)());
197 if let Some(enabled) = enabled {
198 for repository in &entry.needs.repositories {
199 if !enabled.contains(repository) {
200 into.push(Finding::blocker(format!(
201 "{} needs the [{repository}] repository; enable it in /etc/pacman.conf and run sudo pacman -Sy first",
202 entry.name
203 )));
204 }
205 }
206 }
207 }
208 if !entry.needs.gpu.is_empty() {
209 let found = found.get_or_insert_with(|| (facts.gpus)());
210 if !entry.needs.gpu.iter().any(|vendor| found.contains(vendor)) {
211 into.push(Finding::warning(format!(
212 "{} is for {} graphics, and this machine has {}",
213 entry.name,
214 entry.needs.gpu.join(" or "),
215 if found.is_empty() {
216 "none that HelixForge can see".to_string()
217 } else {
218 found.join(" and ")
219 }
220 )));
221 }
222 }
223 }
224}
225
226fn space(
229 plan: &Plan,
230 entries: &[&Entry],
231 platform: &LinuxInfo,
232 facts: &Facts,
233 into: &mut Vec<Finding>,
234) {
235 if plan.steps.is_empty() {
236 return;
237 }
238 let Some(free) = (facts.free_bytes)(Path::new("/")) else {
239 return;
240 };
241 let mut need: u64 = entries
242 .iter()
243 .filter_map(|entry| entry.needs.space_mb)
244 .map(|megabytes| megabytes << 20)
245 .sum();
246 let mut flatpak = false;
248 let mut packages = Vec::new();
249 for step in &plan.steps {
250 if let Step::Package {
251 sources,
252 removal: false,
253 ..
254 } = step
255 {
256 match sources.first().and_then(|source| source.probe.as_ref()) {
257 Some(Probe::Package { name, .. }) => packages.push(name.clone()),
258 Some(Probe::Flatpak(_)) => flatpak = true,
259 None => {}
260 }
261 }
262 }
263 let sized = match platform.manager {
264 Some(manager) if !packages.is_empty() => (facts.sizes)(manager, &packages),
265 _ => None,
266 };
267 need += sized.unwrap_or(0);
268 let shown = |bytes: u64| format!("{:.1} GiB", bytes as f64 / f64::from(1u32 << 30));
269 if need > 0 && free < need {
270 into.push(Finding::blocker(format!(
271 "the plan needs about {} and the root file system has {} free",
272 shown(need),
273 shown(free)
274 )));
275 } else if free < need + SPARE {
276 into.push(Finding::warning(format!(
277 "the root file system has {} free; after this plan less than {} would be left",
278 shown(free),
279 shown(SPARE)
280 )));
281 } else if flatpak && free < 4 * SPARE {
282 into.push(Finding::warning(format!(
283 "the root file system has {} free, and a Flatpak with its runtime can take several GiB",
284 shown(free)
285 )));
286 }
287}
288
289pub fn free_bytes(path: &Path) -> Option<u64> {
291 let stats = nix::sys::statvfs::statvfs(path).ok()?;
292 Some(stats.blocks_available() as u64 * stats.fragment_size() as u64)
293}
294
295pub fn gpus() -> Vec<String> {
297 gpus_in(Path::new("/sys/bus/pci/devices"))
298}
299
300pub fn gpus_in(devices: &Path) -> Vec<String> {
302 let mut found = Vec::new();
303 let Ok(listing) = std::fs::read_dir(devices) else {
304 return found;
305 };
306 for device in listing.flatten() {
307 let read =
308 |name: &str| std::fs::read_to_string(device.path().join(name)).unwrap_or_default();
309 if !read("class").trim().starts_with("0x03") {
310 continue;
311 }
312 let vendor = match read("vendor").trim() {
313 "0x10de" => "nvidia",
314 "0x1002" => "amd",
315 "0x8086" => "intel",
316 _ => continue,
317 };
318 if !found.iter().any(|known| known == vendor) {
319 found.push(vendor.to_string());
320 }
321 }
322 found.sort();
323 found
324}
325
326pub fn pacman_repositories(conf: &Path) -> Option<Vec<String>> {
328 Some(repositories_in(&std::fs::read_to_string(conf).ok()?))
329}
330
331pub fn repositories_in(text: &str) -> Vec<String> {
333 text.lines()
334 .filter_map(|line| line.trim().strip_prefix('[')?.strip_suffix(']'))
335 .filter(|name| *name != "options")
336 .map(String::from)
337 .collect()
338}
339
340pub fn installed_size(manager: PackageManager, packages: &[String]) -> Option<u64> {
343 let names: Vec<&str> = packages
344 .iter()
345 .map(String::as_str)
346 .filter(|name| valid_package_name(name))
347 .collect();
348 if names.is_empty() {
349 return None;
350 }
351 let (program, mut args): (&str, Vec<&str>) = match manager {
352 PackageManager::Arch => ("pacman", vec!["-Si"]),
353 PackageManager::Debian => ("apt-cache", vec!["show", "--no-all-versions"]),
354 PackageManager::Fedora => (
355 "dnf",
356 vec![
357 "-C",
358 "-q",
359 "repoquery",
360 "--latest-limit=1",
361 "--qf",
362 "%{installsize}\n",
363 ],
364 ),
365 _ => return None,
366 };
367 args.extend(&names);
368 let output = Command::new(program)
369 .args(&args)
370 .env("LC_ALL", "C")
371 .stderr(std::process::Stdio::null())
372 .output()
373 .ok()?;
374 let text = String::from_utf8_lossy(&output.stdout);
375 parse_sizes(manager, &text)
376}
377
378pub fn parse_sizes(manager: PackageManager, text: &str) -> Option<u64> {
380 let sizes: Vec<u64> = match manager {
381 PackageManager::Arch => text
383 .lines()
384 .filter_map(|line| line.strip_prefix("Installed Size"))
385 .filter_map(|rest| {
386 let mut words = rest.trim_start_matches([' ', ':']).split_whitespace();
387 let number: f64 = words.next()?.parse().ok()?;
388 let unit = match words.next()? {
389 "B" => 1.0,
390 "KiB" => 1024.0,
391 "MiB" => 1024.0 * 1024.0,
392 "GiB" => 1024.0 * 1024.0 * 1024.0,
393 _ => return None,
394 };
395 Some((number * unit) as u64)
396 })
397 .collect(),
398 PackageManager::Debian => text
400 .lines()
401 .filter_map(|line| line.strip_prefix("Installed-Size:"))
402 .filter_map(|rest| rest.trim().parse::<u64>().ok())
403 .map(|kib| kib * 1024)
404 .collect(),
405 PackageManager::Fedora => text
407 .lines()
408 .filter_map(|line| line.trim().parse::<u64>().ok())
409 .collect(),
410 _ => Vec::new(),
411 };
412 (!sizes.is_empty()).then(|| sizes.iter().sum())
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use crate::plan_with;
419 use helixforge_core::{Action, Needs, PackageNames};
420
421 fn arch() -> LinuxInfo {
422 LinuxInfo {
423 id: "garuda".into(),
424 pretty_name: "Garuda Linux".into(),
425 id_like: vec!["arch".into()],
426 manager: Some(PackageManager::Arch),
427 }
428 }
429
430 fn package(id: &str, arch: &str, conflicts: &[&str], needs: Needs) -> Entry {
431 Entry {
432 id: id.into(),
433 name: id.into(),
434 description: String::new(),
435 flags: "I".into(),
436 multi_select: true,
437 writes: Vec::new(),
438 action: Action::Package {
439 binary: None,
440 packages: PackageNames {
441 arch: Some(arch.into()),
442 ..Default::default()
443 },
444 aur: None,
445 flatpak: None,
446 conflicts: conflicts.iter().map(|name| name.to_string()).collect(),
447 removable: true,
448 },
449 detect: None,
450 needs,
451 effects: Default::default(),
452 }
453 }
454
455 fn facts_of<'a>(
456 gpus: &'a dyn Fn() -> Vec<String>,
457 repositories: &'a dyn Fn() -> Option<Vec<String>>,
458 sizes: &'a dyn Fn(PackageManager, &[String]) -> Option<u64>,
459 present: &'a dyn Fn(&str) -> bool,
460 free_bytes: &'a dyn Fn(&Path) -> Option<u64>,
461 ) -> Facts<'a> {
462 Facts {
463 machine: Machine {
464 holds: &|_| false,
465 on_path: &|_| false,
466 },
467 present,
468 free_bytes,
469 gpus,
470 repositories,
471 sizes,
472 active_runs: &Vec::new,
473 }
474 }
475
476 const GIB: u64 = 1 << 30;
477
478 fn run(entries: &[&Entry], facts: &Facts) -> Vec<Finding> {
479 let planned =
480 plan_with(entries.iter().copied(), &arch(), &|_| None, facts.machine).unwrap();
481 let lookup = |id: &str| entries.iter().copied().find(|entry| entry.id == id);
482 check(&planned, &arch(), &lookup, facts)
483 }
484
485 #[test]
486 fn entries_that_replace_each_other_cannot_be_queued_together() {
487 let wine = package("wine", "wine", &["wine-staging"], Needs::default());
488 let staging = package("wine-staging", "wine-staging", &["wine"], Needs::default());
489 let meld = package("meld", "meld", &[], Needs::default());
490 let plenty = |_: &Path| Some(100 * GIB);
491 let facts = facts_of(&Vec::new, &|| None, &|_, _| None, &|_| true, &plenty);
492 let found = run(&[&wine, &meld, &staging], &facts);
493 assert_eq!(
494 found,
495 [Finding::blocker(
496 "wine and wine-staging replace each other; queue only one".into()
497 )]
498 );
499 assert!(blocked(&found));
500 assert!(run(&[&wine, &meld], &facts).is_empty());
501 }
502
503 #[test]
504 fn missing_tools_and_repositories_block_and_hardware_warns() {
505 let steam = package(
506 "steam",
507 "steam",
508 &[],
509 Needs {
510 repositories: vec!["multilib".into()],
511 ..Default::default()
512 },
513 );
514 let nvidia = package(
515 "nvidia",
516 "nvidia-dkms",
517 &[],
518 Needs {
519 gpu: vec!["nvidia".into()],
520 ..Default::default()
521 },
522 );
523 let plenty = |_: &Path| Some(100 * GIB);
524 let intel = || vec!["intel".to_string()];
525 let core_only = || Some(vec!["core".to_string(), "extra".to_string()]);
526 let no_pacman = |tool: &str| tool != "pacman";
527 let facts = facts_of(&intel, &core_only, &|_, _| None, &no_pacman, &plenty);
528 let found = run(&[&steam, &nvidia], &facts);
529 let lines: Vec<String> = found.iter().map(Finding::line).collect();
530 assert_eq!(
531 lines,
532 [
533 "Blocked: pacman is needed and is not installed",
534 "Blocked: steam needs the [multilib] repository; enable it in /etc/pacman.conf and run sudo pacman -Sy first",
535 "Warning: nvidia is for nvidia graphics, and this machine has intel",
536 ]
537 );
538 let both = || vec!["intel".to_string(), "nvidia".to_string()];
540 let multilib = || Some(vec!["core".to_string(), "multilib".to_string()]);
541 let facts = facts_of(&both, &multilib, &|_, _| None, &|_| true, &plenty);
542 assert!(run(&[&steam, &nvidia], &facts).is_empty());
543 }
544
545 #[test]
546 fn an_entry_for_other_processors_is_blocked() {
547 let steam = package(
548 "steam",
549 "steam",
550 &[],
551 Needs {
552 architectures: vec!["x86_64".into()],
553 ..Default::default()
554 },
555 );
556 assert_eq!(
557 foreign(&steam, "aarch64").map(|finding| finding.line()),
558 Some("Blocked: steam is for x86_64 only, and this machine is aarch64".to_string())
559 );
560 assert_eq!(foreign(&steam, "x86_64"), None);
561 let anywhere = package("htop", "htop", &[], Needs::default());
562 assert_eq!(foreign(&anywhere, "aarch64"), None);
563 }
564
565 #[test]
566 fn the_disk_must_have_room_for_the_packages() {
567 let big = package(
568 "resolve",
569 "davinci-resolve",
570 &[],
571 Needs {
572 space_mb: Some(4096),
573 ..Default::default()
574 },
575 );
576 let sized = |_: PackageManager, names: &[String]| Some(names.len() as u64 * GIB);
577 let tight = |_: &Path| Some(3 * GIB);
578 let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &tight);
579 let found = run(&[&big], &facts);
580 assert_eq!(
581 found[0].line(),
582 "Blocked: the plan needs about 5.0 GiB and the root file system has 3.0 GiB free"
583 );
584 let close = |_: &Path| Some(5 * GIB + GIB / 2);
585 let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &close);
586 assert_eq!(
587 found_severity(&run(&[&big], &facts)),
588 Some(Severity::Warning)
589 );
590 let roomy = |_: &Path| Some(50 * GIB);
591 let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &roomy);
592 assert!(run(&[&big], &facts).is_empty());
593 }
594
595 #[test]
596 fn another_run_still_going_blocks() {
597 let meld = package("meld", "meld", &[], Needs::default());
598 let plenty = |_: &Path| Some(100 * GIB);
599 let busy = || vec!["Steam, Lutris".to_string()];
600 let facts = Facts {
601 active_runs: &busy,
602 ..facts_of(&Vec::new, &|| None, &|_, _| None, &|_| true, &plenty)
603 };
604 assert_eq!(
605 run(&[&meld], &facts)[0].line(),
606 "Blocked: another HelixForge run is still going (Steam, Lutris); wait for it to finish"
607 );
608 }
609
610 fn found_severity(found: &[Finding]) -> Option<Severity> {
611 found.first().map(|finding| finding.severity)
612 }
613
614 #[test]
615 fn machine_facts_are_read() {
616 let devices = tempfile::tempdir().unwrap();
617 for (slot, class, vendor) in [
618 ("0000:00:02.0", "0x030000", "0x8086"),
619 ("0000:01:00.0", "0x030200", "0x10de"),
620 ("0000:00:1f.3", "0x040300", "0x8086"),
621 ] {
622 let device = devices.path().join(slot);
623 std::fs::create_dir(&device).unwrap();
624 std::fs::write(device.join("class"), format!("{class}\n")).unwrap();
625 std::fs::write(device.join("vendor"), format!("{vendor}\n")).unwrap();
626 }
627 assert_eq!(gpus_in(devices.path()), ["intel", "nvidia"]);
628 let conf = devices.path().join("pacman.conf");
629 std::fs::write(
630 &conf,
631 "[options]\nColor\n[core]\nInclude = x\n#[multilib]\n#Include = x\n[chaotic-aur]\n",
632 )
633 .unwrap();
634 assert_eq!(pacman_repositories(&conf).unwrap(), ["core", "chaotic-aur"]);
635 assert_eq!(
636 parse_sizes(
637 PackageManager::Arch,
638 "Name : a\nInstalled Size : 4388.95 KiB\nName : b\nInstalled Size : 1.50 MiB\n"
639 ),
640 Some(4494284 + 1572864)
641 );
642 assert_eq!(
643 parse_sizes(PackageManager::Debian, "Package: a\nInstalled-Size: 100\n"),
644 Some(102400)
645 );
646 assert_eq!(
647 parse_sizes(PackageManager::Fedora, "2048\n4096\n"),
648 Some(6144)
649 );
650 assert_eq!(
651 parse_sizes(PackageManager::Arch, "error: package not found\n"),
652 None
653 );
654 assert!(free_bytes(Path::new("/")).is_some_and(|free| free > 0));
655 }
656}