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 needs(entries: &[&Entry], platform: &LinuxInfo, facts: &Facts, into: &mut Vec<Finding>) {
180 let mut enabled = None;
181 let mut found = None;
182 for entry in entries {
183 if platform.manager == Some(PackageManager::Arch) && !entry.needs.repositories.is_empty() {
184 let enabled = enabled.get_or_insert_with(|| (facts.repositories)());
185 if let Some(enabled) = enabled {
186 for repository in &entry.needs.repositories {
187 if !enabled.contains(repository) {
188 into.push(Finding::blocker(format!(
189 "{} needs the [{repository}] repository; enable it in /etc/pacman.conf and run sudo pacman -Sy first",
190 entry.name
191 )));
192 }
193 }
194 }
195 }
196 if !entry.needs.gpu.is_empty() {
197 let found = found.get_or_insert_with(|| (facts.gpus)());
198 if !entry.needs.gpu.iter().any(|vendor| found.contains(vendor)) {
199 into.push(Finding::warning(format!(
200 "{} is for {} graphics, and this machine has {}",
201 entry.name,
202 entry.needs.gpu.join(" or "),
203 if found.is_empty() {
204 "none that HelixForge can see".to_string()
205 } else {
206 found.join(" and ")
207 }
208 )));
209 }
210 }
211 }
212}
213
214fn space(
217 plan: &Plan,
218 entries: &[&Entry],
219 platform: &LinuxInfo,
220 facts: &Facts,
221 into: &mut Vec<Finding>,
222) {
223 if plan.steps.is_empty() {
224 return;
225 }
226 let Some(free) = (facts.free_bytes)(Path::new("/")) else {
227 return;
228 };
229 let mut need: u64 = entries
230 .iter()
231 .filter_map(|entry| entry.needs.space_mb)
232 .map(|megabytes| megabytes << 20)
233 .sum();
234 let mut flatpak = false;
236 let mut packages = Vec::new();
237 for step in &plan.steps {
238 if let Step::Package {
239 sources,
240 removal: false,
241 ..
242 } = step
243 {
244 match sources.first().and_then(|source| source.probe.as_ref()) {
245 Some(Probe::Package { name, .. }) => packages.push(name.clone()),
246 Some(Probe::Flatpak(_)) => flatpak = true,
247 None => {}
248 }
249 }
250 }
251 let sized = match platform.manager {
252 Some(manager) if !packages.is_empty() => (facts.sizes)(manager, &packages),
253 _ => None,
254 };
255 need += sized.unwrap_or(0);
256 let shown = |bytes: u64| format!("{:.1} GiB", bytes as f64 / f64::from(1u32 << 30));
257 if need > 0 && free < need {
258 into.push(Finding::blocker(format!(
259 "the plan needs about {} and the root file system has {} free",
260 shown(need),
261 shown(free)
262 )));
263 } else if free < need + SPARE {
264 into.push(Finding::warning(format!(
265 "the root file system has {} free; after this plan less than {} would be left",
266 shown(free),
267 shown(SPARE)
268 )));
269 } else if flatpak && free < 4 * SPARE {
270 into.push(Finding::warning(format!(
271 "the root file system has {} free, and a Flatpak with its runtime can take several GiB",
272 shown(free)
273 )));
274 }
275}
276
277pub fn free_bytes(path: &Path) -> Option<u64> {
279 let stats = nix::sys::statvfs::statvfs(path).ok()?;
280 Some(stats.blocks_available() as u64 * stats.fragment_size() as u64)
281}
282
283pub fn gpus() -> Vec<String> {
285 gpus_in(Path::new("/sys/bus/pci/devices"))
286}
287
288pub fn gpus_in(devices: &Path) -> Vec<String> {
290 let mut found = Vec::new();
291 let Ok(listing) = std::fs::read_dir(devices) else {
292 return found;
293 };
294 for device in listing.flatten() {
295 let read =
296 |name: &str| std::fs::read_to_string(device.path().join(name)).unwrap_or_default();
297 if !read("class").trim().starts_with("0x03") {
298 continue;
299 }
300 let vendor = match read("vendor").trim() {
301 "0x10de" => "nvidia",
302 "0x1002" => "amd",
303 "0x8086" => "intel",
304 _ => continue,
305 };
306 if !found.iter().any(|known| known == vendor) {
307 found.push(vendor.to_string());
308 }
309 }
310 found.sort();
311 found
312}
313
314pub fn pacman_repositories(conf: &Path) -> Option<Vec<String>> {
316 let text = std::fs::read_to_string(conf).ok()?;
317 Some(
318 text.lines()
319 .filter_map(|line| line.trim().strip_prefix('[')?.strip_suffix(']'))
320 .filter(|name| *name != "options")
321 .map(String::from)
322 .collect(),
323 )
324}
325
326pub fn installed_size(manager: PackageManager, packages: &[String]) -> Option<u64> {
329 let names: Vec<&str> = packages
330 .iter()
331 .map(String::as_str)
332 .filter(|name| valid_package_name(name))
333 .collect();
334 if names.is_empty() {
335 return None;
336 }
337 let (program, mut args): (&str, Vec<&str>) = match manager {
338 PackageManager::Arch => ("pacman", vec!["-Si"]),
339 PackageManager::Debian => ("apt-cache", vec!["show", "--no-all-versions"]),
340 PackageManager::Fedora => (
341 "dnf",
342 vec![
343 "-C",
344 "-q",
345 "repoquery",
346 "--latest-limit=1",
347 "--qf",
348 "%{installsize}\n",
349 ],
350 ),
351 _ => return None,
352 };
353 args.extend(&names);
354 let output = Command::new(program)
355 .args(&args)
356 .env("LC_ALL", "C")
357 .stderr(std::process::Stdio::null())
358 .output()
359 .ok()?;
360 let text = String::from_utf8_lossy(&output.stdout);
361 parse_sizes(manager, &text)
362}
363
364pub fn parse_sizes(manager: PackageManager, text: &str) -> Option<u64> {
366 let sizes: Vec<u64> = match manager {
367 PackageManager::Arch => text
369 .lines()
370 .filter_map(|line| line.strip_prefix("Installed Size"))
371 .filter_map(|rest| {
372 let mut words = rest.trim_start_matches([' ', ':']).split_whitespace();
373 let number: f64 = words.next()?.parse().ok()?;
374 let unit = match words.next()? {
375 "B" => 1.0,
376 "KiB" => 1024.0,
377 "MiB" => 1024.0 * 1024.0,
378 "GiB" => 1024.0 * 1024.0 * 1024.0,
379 _ => return None,
380 };
381 Some((number * unit) as u64)
382 })
383 .collect(),
384 PackageManager::Debian => text
386 .lines()
387 .filter_map(|line| line.strip_prefix("Installed-Size:"))
388 .filter_map(|rest| rest.trim().parse::<u64>().ok())
389 .map(|kib| kib * 1024)
390 .collect(),
391 PackageManager::Fedora => text
393 .lines()
394 .filter_map(|line| line.trim().parse::<u64>().ok())
395 .collect(),
396 _ => Vec::new(),
397 };
398 (!sizes.is_empty()).then(|| sizes.iter().sum())
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::plan_with;
405 use helixforge_core::{Action, Needs, PackageNames};
406
407 fn arch() -> LinuxInfo {
408 LinuxInfo {
409 id: "garuda".into(),
410 pretty_name: "Garuda Linux".into(),
411 id_like: vec!["arch".into()],
412 manager: Some(PackageManager::Arch),
413 }
414 }
415
416 fn package(id: &str, arch: &str, conflicts: &[&str], needs: Needs) -> Entry {
417 Entry {
418 id: id.into(),
419 name: id.into(),
420 description: String::new(),
421 flags: "I".into(),
422 multi_select: true,
423 writes: Vec::new(),
424 action: Action::Package {
425 binary: None,
426 packages: PackageNames {
427 arch: Some(arch.into()),
428 ..Default::default()
429 },
430 aur: None,
431 flatpak: None,
432 conflicts: conflicts.iter().map(|name| name.to_string()).collect(),
433 removable: true,
434 },
435 detect: None,
436 needs,
437 }
438 }
439
440 fn facts_of<'a>(
441 gpus: &'a dyn Fn() -> Vec<String>,
442 repositories: &'a dyn Fn() -> Option<Vec<String>>,
443 sizes: &'a dyn Fn(PackageManager, &[String]) -> Option<u64>,
444 present: &'a dyn Fn(&str) -> bool,
445 free_bytes: &'a dyn Fn(&Path) -> Option<u64>,
446 ) -> Facts<'a> {
447 Facts {
448 machine: Machine {
449 holds: &|_| false,
450 on_path: &|_| false,
451 },
452 present,
453 free_bytes,
454 gpus,
455 repositories,
456 sizes,
457 active_runs: &Vec::new,
458 }
459 }
460
461 const GIB: u64 = 1 << 30;
462
463 fn run(entries: &[&Entry], facts: &Facts) -> Vec<Finding> {
464 let planned =
465 plan_with(entries.iter().copied(), &arch(), &|_| None, facts.machine).unwrap();
466 let lookup = |id: &str| entries.iter().copied().find(|entry| entry.id == id);
467 check(&planned, &arch(), &lookup, facts)
468 }
469
470 #[test]
471 fn entries_that_replace_each_other_cannot_be_queued_together() {
472 let wine = package("wine", "wine", &["wine-staging"], Needs::default());
473 let staging = package("wine-staging", "wine-staging", &["wine"], Needs::default());
474 let meld = package("meld", "meld", &[], Needs::default());
475 let plenty = |_: &Path| Some(100 * GIB);
476 let facts = facts_of(&Vec::new, &|| None, &|_, _| None, &|_| true, &plenty);
477 let found = run(&[&wine, &meld, &staging], &facts);
478 assert_eq!(
479 found,
480 [Finding::blocker(
481 "wine and wine-staging replace each other; queue only one".into()
482 )]
483 );
484 assert!(blocked(&found));
485 assert!(run(&[&wine, &meld], &facts).is_empty());
486 }
487
488 #[test]
489 fn missing_tools_and_repositories_block_and_hardware_warns() {
490 let steam = package(
491 "steam",
492 "steam",
493 &[],
494 Needs {
495 repositories: vec!["multilib".into()],
496 ..Default::default()
497 },
498 );
499 let nvidia = package(
500 "nvidia",
501 "nvidia-dkms",
502 &[],
503 Needs {
504 gpu: vec!["nvidia".into()],
505 ..Default::default()
506 },
507 );
508 let plenty = |_: &Path| Some(100 * GIB);
509 let intel = || vec!["intel".to_string()];
510 let core_only = || Some(vec!["core".to_string(), "extra".to_string()]);
511 let no_pacman = |tool: &str| tool != "pacman";
512 let facts = facts_of(&intel, &core_only, &|_, _| None, &no_pacman, &plenty);
513 let found = run(&[&steam, &nvidia], &facts);
514 let lines: Vec<String> = found.iter().map(Finding::line).collect();
515 assert_eq!(
516 lines,
517 [
518 "Blocked: pacman is needed and is not installed",
519 "Blocked: steam needs the [multilib] repository; enable it in /etc/pacman.conf and run sudo pacman -Sy first",
520 "Warning: nvidia is for nvidia graphics, and this machine has intel",
521 ]
522 );
523 let both = || vec!["intel".to_string(), "nvidia".to_string()];
525 let multilib = || Some(vec!["core".to_string(), "multilib".to_string()]);
526 let facts = facts_of(&both, &multilib, &|_, _| None, &|_| true, &plenty);
527 assert!(run(&[&steam, &nvidia], &facts).is_empty());
528 }
529
530 #[test]
531 fn the_disk_must_have_room_for_the_packages() {
532 let big = package(
533 "resolve",
534 "davinci-resolve",
535 &[],
536 Needs {
537 space_mb: Some(4096),
538 ..Default::default()
539 },
540 );
541 let sized = |_: PackageManager, names: &[String]| Some(names.len() as u64 * GIB);
542 let tight = |_: &Path| Some(3 * GIB);
543 let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &tight);
544 let found = run(&[&big], &facts);
545 assert_eq!(
546 found[0].line(),
547 "Blocked: the plan needs about 5.0 GiB and the root file system has 3.0 GiB free"
548 );
549 let close = |_: &Path| Some(5 * GIB + GIB / 2);
550 let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &close);
551 assert_eq!(
552 found_severity(&run(&[&big], &facts)),
553 Some(Severity::Warning)
554 );
555 let roomy = |_: &Path| Some(50 * GIB);
556 let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &roomy);
557 assert!(run(&[&big], &facts).is_empty());
558 }
559
560 #[test]
561 fn another_run_still_going_blocks() {
562 let meld = package("meld", "meld", &[], Needs::default());
563 let plenty = |_: &Path| Some(100 * GIB);
564 let busy = || vec!["Steam, Lutris".to_string()];
565 let facts = Facts {
566 active_runs: &busy,
567 ..facts_of(&Vec::new, &|| None, &|_, _| None, &|_| true, &plenty)
568 };
569 assert_eq!(
570 run(&[&meld], &facts)[0].line(),
571 "Blocked: another HelixForge run is still going (Steam, Lutris); wait for it to finish"
572 );
573 }
574
575 fn found_severity(found: &[Finding]) -> Option<Severity> {
576 found.first().map(|finding| finding.severity)
577 }
578
579 #[test]
580 fn machine_facts_are_read() {
581 let devices = tempfile::tempdir().unwrap();
582 for (slot, class, vendor) in [
583 ("0000:00:02.0", "0x030000", "0x8086"),
584 ("0000:01:00.0", "0x030200", "0x10de"),
585 ("0000:00:1f.3", "0x040300", "0x8086"),
586 ] {
587 let device = devices.path().join(slot);
588 std::fs::create_dir(&device).unwrap();
589 std::fs::write(device.join("class"), format!("{class}\n")).unwrap();
590 std::fs::write(device.join("vendor"), format!("{vendor}\n")).unwrap();
591 }
592 assert_eq!(gpus_in(devices.path()), ["intel", "nvidia"]);
593 let conf = devices.path().join("pacman.conf");
594 std::fs::write(
595 &conf,
596 "[options]\nColor\n[core]\nInclude = x\n#[multilib]\n#Include = x\n[chaotic-aur]\n",
597 )
598 .unwrap();
599 assert_eq!(pacman_repositories(&conf).unwrap(), ["core", "chaotic-aur"]);
600 assert_eq!(
601 parse_sizes(
602 PackageManager::Arch,
603 "Name : a\nInstalled Size : 4388.95 KiB\nName : b\nInstalled Size : 1.50 MiB\n"
604 ),
605 Some(4494284 + 1572864)
606 );
607 assert_eq!(
608 parse_sizes(PackageManager::Debian, "Package: a\nInstalled-Size: 100\n"),
609 Some(102400)
610 );
611 assert_eq!(
612 parse_sizes(PackageManager::Fedora, "2048\n4096\n"),
613 Some(6144)
614 );
615 assert_eq!(
616 parse_sizes(PackageManager::Arch, "error: package not found\n"),
617 None
618 );
619 assert!(free_bytes(Path::new("/")).is_some_and(|free| free > 0));
620 }
621}