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 Some(repositories_in(&std::fs::read_to_string(conf).ok()?))
317}
318
319pub fn repositories_in(text: &str) -> Vec<String> {
321 text.lines()
322 .filter_map(|line| line.trim().strip_prefix('[')?.strip_suffix(']'))
323 .filter(|name| *name != "options")
324 .map(String::from)
325 .collect()
326}
327
328pub fn installed_size(manager: PackageManager, packages: &[String]) -> Option<u64> {
331 let names: Vec<&str> = packages
332 .iter()
333 .map(String::as_str)
334 .filter(|name| valid_package_name(name))
335 .collect();
336 if names.is_empty() {
337 return None;
338 }
339 let (program, mut args): (&str, Vec<&str>) = match manager {
340 PackageManager::Arch => ("pacman", vec!["-Si"]),
341 PackageManager::Debian => ("apt-cache", vec!["show", "--no-all-versions"]),
342 PackageManager::Fedora => (
343 "dnf",
344 vec![
345 "-C",
346 "-q",
347 "repoquery",
348 "--latest-limit=1",
349 "--qf",
350 "%{installsize}\n",
351 ],
352 ),
353 _ => return None,
354 };
355 args.extend(&names);
356 let output = Command::new(program)
357 .args(&args)
358 .env("LC_ALL", "C")
359 .stderr(std::process::Stdio::null())
360 .output()
361 .ok()?;
362 let text = String::from_utf8_lossy(&output.stdout);
363 parse_sizes(manager, &text)
364}
365
366pub fn parse_sizes(manager: PackageManager, text: &str) -> Option<u64> {
368 let sizes: Vec<u64> = match manager {
369 PackageManager::Arch => text
371 .lines()
372 .filter_map(|line| line.strip_prefix("Installed Size"))
373 .filter_map(|rest| {
374 let mut words = rest.trim_start_matches([' ', ':']).split_whitespace();
375 let number: f64 = words.next()?.parse().ok()?;
376 let unit = match words.next()? {
377 "B" => 1.0,
378 "KiB" => 1024.0,
379 "MiB" => 1024.0 * 1024.0,
380 "GiB" => 1024.0 * 1024.0 * 1024.0,
381 _ => return None,
382 };
383 Some((number * unit) as u64)
384 })
385 .collect(),
386 PackageManager::Debian => text
388 .lines()
389 .filter_map(|line| line.strip_prefix("Installed-Size:"))
390 .filter_map(|rest| rest.trim().parse::<u64>().ok())
391 .map(|kib| kib * 1024)
392 .collect(),
393 PackageManager::Fedora => text
395 .lines()
396 .filter_map(|line| line.trim().parse::<u64>().ok())
397 .collect(),
398 _ => Vec::new(),
399 };
400 (!sizes.is_empty()).then(|| sizes.iter().sum())
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use crate::plan_with;
407 use helixforge_core::{Action, Needs, PackageNames};
408
409 fn arch() -> LinuxInfo {
410 LinuxInfo {
411 id: "garuda".into(),
412 pretty_name: "Garuda Linux".into(),
413 id_like: vec!["arch".into()],
414 manager: Some(PackageManager::Arch),
415 }
416 }
417
418 fn package(id: &str, arch: &str, conflicts: &[&str], needs: Needs) -> Entry {
419 Entry {
420 id: id.into(),
421 name: id.into(),
422 description: String::new(),
423 flags: "I".into(),
424 multi_select: true,
425 writes: Vec::new(),
426 action: Action::Package {
427 binary: None,
428 packages: PackageNames {
429 arch: Some(arch.into()),
430 ..Default::default()
431 },
432 aur: None,
433 flatpak: None,
434 conflicts: conflicts.iter().map(|name| name.to_string()).collect(),
435 removable: true,
436 },
437 detect: None,
438 needs,
439 effects: Default::default(),
440 }
441 }
442
443 fn facts_of<'a>(
444 gpus: &'a dyn Fn() -> Vec<String>,
445 repositories: &'a dyn Fn() -> Option<Vec<String>>,
446 sizes: &'a dyn Fn(PackageManager, &[String]) -> Option<u64>,
447 present: &'a dyn Fn(&str) -> bool,
448 free_bytes: &'a dyn Fn(&Path) -> Option<u64>,
449 ) -> Facts<'a> {
450 Facts {
451 machine: Machine {
452 holds: &|_| false,
453 on_path: &|_| false,
454 },
455 present,
456 free_bytes,
457 gpus,
458 repositories,
459 sizes,
460 active_runs: &Vec::new,
461 }
462 }
463
464 const GIB: u64 = 1 << 30;
465
466 fn run(entries: &[&Entry], facts: &Facts) -> Vec<Finding> {
467 let planned =
468 plan_with(entries.iter().copied(), &arch(), &|_| None, facts.machine).unwrap();
469 let lookup = |id: &str| entries.iter().copied().find(|entry| entry.id == id);
470 check(&planned, &arch(), &lookup, facts)
471 }
472
473 #[test]
474 fn entries_that_replace_each_other_cannot_be_queued_together() {
475 let wine = package("wine", "wine", &["wine-staging"], Needs::default());
476 let staging = package("wine-staging", "wine-staging", &["wine"], Needs::default());
477 let meld = package("meld", "meld", &[], Needs::default());
478 let plenty = |_: &Path| Some(100 * GIB);
479 let facts = facts_of(&Vec::new, &|| None, &|_, _| None, &|_| true, &plenty);
480 let found = run(&[&wine, &meld, &staging], &facts);
481 assert_eq!(
482 found,
483 [Finding::blocker(
484 "wine and wine-staging replace each other; queue only one".into()
485 )]
486 );
487 assert!(blocked(&found));
488 assert!(run(&[&wine, &meld], &facts).is_empty());
489 }
490
491 #[test]
492 fn missing_tools_and_repositories_block_and_hardware_warns() {
493 let steam = package(
494 "steam",
495 "steam",
496 &[],
497 Needs {
498 repositories: vec!["multilib".into()],
499 ..Default::default()
500 },
501 );
502 let nvidia = package(
503 "nvidia",
504 "nvidia-dkms",
505 &[],
506 Needs {
507 gpu: vec!["nvidia".into()],
508 ..Default::default()
509 },
510 );
511 let plenty = |_: &Path| Some(100 * GIB);
512 let intel = || vec!["intel".to_string()];
513 let core_only = || Some(vec!["core".to_string(), "extra".to_string()]);
514 let no_pacman = |tool: &str| tool != "pacman";
515 let facts = facts_of(&intel, &core_only, &|_, _| None, &no_pacman, &plenty);
516 let found = run(&[&steam, &nvidia], &facts);
517 let lines: Vec<String> = found.iter().map(Finding::line).collect();
518 assert_eq!(
519 lines,
520 [
521 "Blocked: pacman is needed and is not installed",
522 "Blocked: steam needs the [multilib] repository; enable it in /etc/pacman.conf and run sudo pacman -Sy first",
523 "Warning: nvidia is for nvidia graphics, and this machine has intel",
524 ]
525 );
526 let both = || vec!["intel".to_string(), "nvidia".to_string()];
528 let multilib = || Some(vec!["core".to_string(), "multilib".to_string()]);
529 let facts = facts_of(&both, &multilib, &|_, _| None, &|_| true, &plenty);
530 assert!(run(&[&steam, &nvidia], &facts).is_empty());
531 }
532
533 #[test]
534 fn the_disk_must_have_room_for_the_packages() {
535 let big = package(
536 "resolve",
537 "davinci-resolve",
538 &[],
539 Needs {
540 space_mb: Some(4096),
541 ..Default::default()
542 },
543 );
544 let sized = |_: PackageManager, names: &[String]| Some(names.len() as u64 * GIB);
545 let tight = |_: &Path| Some(3 * GIB);
546 let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &tight);
547 let found = run(&[&big], &facts);
548 assert_eq!(
549 found[0].line(),
550 "Blocked: the plan needs about 5.0 GiB and the root file system has 3.0 GiB free"
551 );
552 let close = |_: &Path| Some(5 * GIB + GIB / 2);
553 let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &close);
554 assert_eq!(
555 found_severity(&run(&[&big], &facts)),
556 Some(Severity::Warning)
557 );
558 let roomy = |_: &Path| Some(50 * GIB);
559 let facts = facts_of(&Vec::new, &|| None, &sized, &|_| true, &roomy);
560 assert!(run(&[&big], &facts).is_empty());
561 }
562
563 #[test]
564 fn another_run_still_going_blocks() {
565 let meld = package("meld", "meld", &[], Needs::default());
566 let plenty = |_: &Path| Some(100 * GIB);
567 let busy = || vec!["Steam, Lutris".to_string()];
568 let facts = Facts {
569 active_runs: &busy,
570 ..facts_of(&Vec::new, &|| None, &|_, _| None, &|_| true, &plenty)
571 };
572 assert_eq!(
573 run(&[&meld], &facts)[0].line(),
574 "Blocked: another HelixForge run is still going (Steam, Lutris); wait for it to finish"
575 );
576 }
577
578 fn found_severity(found: &[Finding]) -> Option<Severity> {
579 found.first().map(|finding| finding.severity)
580 }
581
582 #[test]
583 fn machine_facts_are_read() {
584 let devices = tempfile::tempdir().unwrap();
585 for (slot, class, vendor) in [
586 ("0000:00:02.0", "0x030000", "0x8086"),
587 ("0000:01:00.0", "0x030200", "0x10de"),
588 ("0000:00:1f.3", "0x040300", "0x8086"),
589 ] {
590 let device = devices.path().join(slot);
591 std::fs::create_dir(&device).unwrap();
592 std::fs::write(device.join("class"), format!("{class}\n")).unwrap();
593 std::fs::write(device.join("vendor"), format!("{vendor}\n")).unwrap();
594 }
595 assert_eq!(gpus_in(devices.path()), ["intel", "nvidia"]);
596 let conf = devices.path().join("pacman.conf");
597 std::fs::write(
598 &conf,
599 "[options]\nColor\n[core]\nInclude = x\n#[multilib]\n#Include = x\n[chaotic-aur]\n",
600 )
601 .unwrap();
602 assert_eq!(pacman_repositories(&conf).unwrap(), ["core", "chaotic-aur"]);
603 assert_eq!(
604 parse_sizes(
605 PackageManager::Arch,
606 "Name : a\nInstalled Size : 4388.95 KiB\nName : b\nInstalled Size : 1.50 MiB\n"
607 ),
608 Some(4494284 + 1572864)
609 );
610 assert_eq!(
611 parse_sizes(PackageManager::Debian, "Package: a\nInstalled-Size: 100\n"),
612 Some(102400)
613 );
614 assert_eq!(
615 parse_sizes(PackageManager::Fedora, "2048\n4096\n"),
616 Some(6144)
617 );
618 assert_eq!(
619 parse_sizes(PackageManager::Arch, "error: package not found\n"),
620 None
621 );
622 assert!(free_bytes(Path::new("/")).is_some_and(|free| free > 0));
623 }
624}