1use std::path::{Path, PathBuf};
7
8use helixforge_core::{PackageManager, printable};
9use helixforge_platform::LinuxInfo;
10
11use crate::doctor::System;
12use crate::{Plan, Risk, Source, Step};
13
14#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct Pending {
17 pub name: String,
18 pub from: Option<String>,
19 pub to: Option<String>,
20}
21
22#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct Channel {
25 pub id: String,
28 pub name: String,
29 pub pending: Option<Vec<Pending>>,
32 pub note: String,
34 pub commands: Vec<Vec<String>>,
36 pub requires: Vec<String>,
37 pub caveat: Option<String>,
39}
40
41impl Channel {
42 pub fn count(&self) -> Option<usize> {
44 self.pending.as_ref().map(Vec::len)
45 }
46
47 pub fn summary(&self) -> String {
49 match self.count() {
50 Some(0) => "up to date".to_string(),
51 Some(1) => "1 update".to_string(),
52 Some(count) => format!("{count} updates"),
53 None => "not counted".to_string(),
54 }
55 }
56
57 pub fn command_line(&self) -> String {
59 printable(
60 &self
61 .commands
62 .iter()
63 .map(|command| command.join(" "))
64 .collect::<Vec<_>>()
65 .join(" && "),
66 )
67 }
68}
69
70pub struct Places {
72 pub config: PathBuf,
74 pub home: PathBuf,
75 pub now: u64,
77 pub this: Option<ThisCopy>,
79}
80
81#[derive(Clone, Debug, Eq, PartialEq)]
84pub struct ThisCopy {
85 pub version: String,
86 pub exe: PathBuf,
87 pub install: Install,
88 pub cache: PathBuf,
90}
91
92#[derive(Clone, Debug, Eq, PartialEq)]
94pub enum Install {
95 Package,
97 Cargo,
99 Source(PathBuf),
101 Launcher,
104 Download,
106 Other,
108}
109
110pub struct Homes {
112 pub cargo: PathBuf,
113 pub data: PathBuf,
114 pub temp: PathBuf,
115}
116
117pub fn install_of(exe: &Path, homes: &Homes, checkout: &dyn Fn(&Path) -> bool) -> Install {
120 if exe.starts_with("/usr/bin") || exe.starts_with("/usr/sbin") {
121 Install::Package
122 } else if exe.starts_with(homes.cargo.join("bin")) {
123 Install::Cargo
124 } else if exe.starts_with(homes.data.join("helixforge")) {
125 Install::Launcher
126 } else if let Some(root) = exe
127 .ancestors()
128 .find(|dir| dir.file_name().is_some_and(|name| name == "target"))
129 .and_then(Path::parent)
130 .filter(|root| checkout(root))
131 {
132 Install::Source(root.to_path_buf())
133 } else if exe.starts_with(&homes.temp) {
134 Install::Download
135 } else {
136 Install::Other
137 }
138}
139
140impl Places {
141 pub fn here() -> Self {
142 let home = std::env::var_os("HOME")
143 .map(PathBuf::from)
144 .unwrap_or_else(|| PathBuf::from("/"));
145 let config = std::env::var_os("XDG_CONFIG_HOME")
146 .map(PathBuf::from)
147 .filter(|path| path.is_absolute())
148 .unwrap_or_else(|| home.join(".config"));
149 let now = std::time::SystemTime::now()
150 .duration_since(std::time::UNIX_EPOCH)
151 .map(|elapsed| elapsed.as_secs())
152 .unwrap_or(0);
153 Places {
154 config,
155 home,
156 now,
157 this: None,
158 }
159 }
160
161 pub fn with_this(mut self, version: &str, exe: PathBuf) -> Self {
164 let variable = |name: &str| {
165 std::env::var_os(name)
166 .map(PathBuf::from)
167 .filter(|path| path.is_absolute())
168 };
169 let homes = Homes {
170 cargo: variable("CARGO_HOME").unwrap_or_else(|| self.home.join(".cargo")),
171 data: variable("XDG_DATA_HOME").unwrap_or_else(|| self.home.join(".local/share")),
172 temp: std::env::temp_dir(),
173 };
174 let checkout =
175 |root: &Path| root.join("Cargo.toml").is_file() && root.join(".git").exists();
176 let cache = variable("XDG_CACHE_HOME").unwrap_or_else(|| self.home.join(".cache"));
177 self.this = Some(ThisCopy {
178 version: version.to_string(),
179 install: install_of(&exe, &homes, &checkout),
180 exe,
181 cache: cache.join("helixforge/release"),
182 });
183 self
184 }
185}
186
187pub const RELEASES: &str = "https://github.com/Commanderx-code/Helix-Forge";
189const LATEST: &str = "https://api.github.com/repos/Commanderx-code/Helix-Forge/releases/latest";
190
191fn release(version: &str) -> Option<(u64, u64, u64)> {
193 let mut parts = version.trim_start_matches('v').split('.');
194 let part = |part: Option<&str>| part?.parse::<u64>().ok();
195 let found = (
196 part(parts.next())?,
197 part(parts.next())?,
198 part(parts.next())?,
199 );
200 parts.next().is_none().then_some(found)
201}
202
203pub fn package_asset(manager: PackageManager, version: &str) -> Option<String> {
205 package_asset_for(manager, version, std::env::consts::ARCH)
206}
207
208pub fn package_asset_for(
211 manager: PackageManager,
212 version: &str,
213 architecture: &str,
214) -> Option<String> {
215 release(version)?;
216 let debian = match architecture {
217 "x86_64" => "amd64",
218 "aarch64" => "arm64",
219 _ => return None,
220 };
221 match manager {
222 PackageManager::Arch => Some(format!("helixforge-{version}-1-{architecture}.pkg.tar.zst")),
223 PackageManager::Debian => Some(format!("helixforge_{version}-1_{debian}.deb")),
224 PackageManager::Fedora | PackageManager::Suse => {
225 Some(format!("helixforge-{version}-1.{architecture}.rpm"))
226 }
227 _ => None,
228 }
229}
230
231pub fn program_asset(architecture: &str) -> Option<String> {
233 helixforge_core::ARCHITECTURES
234 .contains(&architecture)
235 .then(|| format!("helixforge-linux-{architecture}"))
236}
237
238fn this_channel(
240 system: &dyn System,
241 platform: &LinuxInfo,
242 this: &ThisCopy,
243 online: bool,
244) -> Channel {
245 let mut channel = Channel {
246 id: "helixforge".into(),
247 name: "HelixForge".into(),
248 pending: None,
249 note: String::new(),
250 commands: Vec::new(),
251 requires: Vec::new(),
252 caveat: None,
253 };
254 let latest = || -> Option<String> {
256 let text = read(
257 system,
258 &[
259 "curl",
260 "-fsSL",
261 "--max-time",
262 "15",
263 "--proto",
264 "=https",
265 LATEST,
266 ],
267 )?;
268 let json: serde_json::Value = serde_json::from_str(&text).ok()?;
269 let tag = json["tag_name"].as_str()?;
270 release(tag)?;
271 Some(tag.trim_start_matches('v').to_string())
272 };
273 let behind = |latest: &str| {
274 let pending = match (release(&this.version), release(latest)) {
275 (Some(current), Some(newest)) if newest > current => vec![Pending {
276 name: "helixforge".into(),
277 from: Some(this.version.clone()),
278 to: Some(latest.to_string()),
279 }],
280 _ => Vec::new(),
281 };
282 Some(pending)
283 };
284 let exe = this.exe.to_string_lossy().into_owned();
285 match &this.install {
286 Install::Launcher | Install::Download => {
287 channel.pending = Some(Vec::new());
288 channel.note = format!(
289 "{}; it fetches the latest release each time it starts",
290 this.version
291 );
292 }
293 Install::Other => {
294 channel.note = format!(
295 "{} at {}, installed in a way HelixForge cannot update; update it the way you installed it",
296 this.version,
297 this.exe.display()
298 );
299 }
300 Install::Package | Install::Cargo if !online => {
301 channel.note = format!("{}; r checks GitHub for a newer release", this.version);
302 }
303 Install::Package | Install::Cargo => match latest() {
304 None => {
305 channel.note = format!(
306 "{}; GitHub could not be asked for the latest release",
307 this.version
308 );
309 }
310 Some(newest) => {
311 channel.pending = behind(&newest);
312 channel.note = format!("{}, the latest release is {newest}", this.version);
313 let waiting = channel.count().unwrap_or(0) > 0;
314 if this.install == Install::Cargo {
315 if waiting && system.on_path("cargo") {
316 channel.commands =
317 vec![words(&["cargo", "install", "--locked", "helixforge"])];
318 channel.requires = strings(&["cargo"]);
319 }
320 } else if waiting
321 && let Some(manager) = platform.manager
322 && let Some(asset) = package_asset(manager, &newest)
323 {
324 let cache = this.cache.to_string_lossy().into_owned();
325 let file = this.cache.join(&asset).to_string_lossy().into_owned();
326 let install: &[&str] = match manager {
327 PackageManager::Arch => &["sudo", "pacman", "-U"],
328 PackageManager::Debian => &["sudo", "apt-get", "install"],
329 PackageManager::Fedora => &["sudo", "dnf", "install"],
330 _ => &["sudo", "zypper", "install", "--allow-unsigned-rpm"],
331 };
332 let mut install = words(install);
333 install.push(file);
334 channel.commands = vec![
335 vec![exe, "fetch-release".into(), newest.clone(), asset, cache],
336 install,
337 ];
338 channel.requires = strings(&["curl", "sha256sum", "sudo"]);
339 channel.caveat = Some(format!(
340 "downloads the {newest} package from {RELEASES}/releases and checks it against the release's SHA256SUMS before installing it"
341 ));
342 }
343 }
344 },
345 Install::Source(root) => {
346 let git = |args: &[&str]| {
347 let directory = root.to_string_lossy();
348 let mut command = vec!["git", "-C", directory.as_ref()];
349 command.extend(args);
350 read(system, &command)
351 };
352 let head = git(&["rev-parse", "HEAD"]).map(|text| text.trim().to_string());
353 let short = |hash: &str| hash.chars().take(12).collect::<String>();
354 channel.note = format!(
355 "a checkout at {}, {}",
356 root.display(),
357 head.as_deref()
358 .map(short)
359 .unwrap_or_else(|| "unknown".into())
360 );
361 if online {
362 let remote = git(&["ls-remote", "origin", "refs/heads/main"])
363 .and_then(|text| text.split_whitespace().next().map(String::from));
364 if let (Some(head), Some(remote)) = (&head, remote) {
365 channel.pending = Some(if *head == remote {
366 Vec::new()
367 } else {
368 vec![Pending {
369 name: "main".into(),
370 from: Some(short(head)),
371 to: Some(short(&remote)),
372 }]
373 });
374 }
375 } else {
376 channel.note.push_str("; r compares it with GitHub");
377 }
378 let directory = root.to_string_lossy().into_owned();
379 let manifest = root.join("Cargo.toml").to_string_lossy().into_owned();
380 channel.commands = vec![
381 words(&["git", "-C", &directory, "diff", "--quiet", "HEAD"]),
383 words(&["git", "-C", &directory, "pull", "--ff-only"]),
384 words(&[
385 "cargo",
386 "build",
387 "--release",
388 "--locked",
389 "--manifest-path",
390 &manifest,
391 "-p",
392 "helixforge",
393 ]),
394 ];
395 channel.requires = strings(&["git", "cargo"]);
396 channel.caveat = Some(
397 "fast-forwards the checkout and builds it; a checkout with local changes or other commits is left alone".into(),
398 );
399 }
400 }
401 channel
402}
403
404fn words(command: &[&str]) -> Vec<String> {
405 command.iter().map(|word| word.to_string()).collect()
406}
407
408fn strings(tools: &[&str]) -> Vec<String> {
409 words(tools)
410}
411
412fn read(system: &dyn System, command: &[&str]) -> Option<String> {
416 system.run(command).map(|(_, text)| text)
417}
418
419pub fn overview(
422 system: &dyn System,
423 platform: &LinuxInfo,
424 places: &Places,
425 online: bool,
426) -> Vec<Channel> {
427 let mut channels = Vec::new();
428 match platform.manager {
429 Some(PackageManager::Arch) if system.on_path("pacman") => {
430 let (pending, note) = if online && system.on_path("checkupdates") {
431 (
432 read(system, &["checkupdates"]).map(|text| arrows(&text)),
433 "checked online with checkupdates",
434 )
435 } else {
436 (
437 read(system, &["pacman", "-Qu"]).map(|text| arrows(&text)),
438 "as of pacman's last sync",
439 )
440 };
441 channels.push(Channel {
442 id: "pacman".into(),
443 name: "Arch packages".into(),
444 pending,
445 note: note.into(),
446 commands: vec![words(&["sudo", "pacman", "-Syu"])],
447 requires: strings(&["pacman", "sudo"]),
448 caveat: Some(
449 "updates the whole system: Arch does not support partial upgrades".into(),
450 ),
451 });
452 if let Some(helper) = ["paru", "yay"]
453 .into_iter()
454 .find(|tool| system.on_path(tool))
455 {
456 let pending = online
457 .then(|| read(system, &[helper, "-Qua"]).map(|text| arrows(&text)))
458 .flatten();
459 channels.push(Channel {
460 id: "aur".into(),
461 name: format!("AUR ({helper})"),
462 note: if online {
463 format!("checked online with {helper} -Qua")
464 } else {
465 "the AUR is only asked in an online check".into()
466 },
467 pending,
468 commands: vec![words(&[helper, "-Sua"])],
469 requires: strings(&[helper]),
470 caveat: Some(
471 "builds AUR packages from their PKGBUILDs; read them when asked".into(),
472 ),
473 });
474 }
475 }
476 Some(PackageManager::Debian) if system.on_path("apt") => {
477 channels.push(Channel {
478 id: "apt".into(),
479 name: "Debian packages".into(),
480 pending: read(system, &["apt", "list", "--upgradable"]).map(|text| apt(&text)),
481 note: "as of the last apt update, which the update runs first".into(),
482 commands: vec![
483 words(&["sudo", "apt-get", "update"]),
484 words(&["sudo", "apt-get", "upgrade"]),
485 ],
486 requires: strings(&["apt-get", "sudo"]),
487 caveat: None,
488 });
489 }
490 Some(PackageManager::Fedora) if system.on_path("dnf") => {
491 let command: &[&str] = if online {
492 &["dnf", "-q", "list", "--upgrades"]
493 } else {
494 &["dnf", "-C", "-q", "list", "--upgrades"]
495 };
496 channels.push(Channel {
497 id: "dnf".into(),
498 name: "Fedora packages".into(),
499 pending: read(system, command).map(|text| dnf(&text)),
500 note: if online {
501 "checked online with dnf".into()
502 } else {
503 "from dnf's cached metadata".into()
504 },
505 commands: vec![words(&["sudo", "dnf", "upgrade"])],
506 requires: strings(&["dnf", "sudo"]),
507 caveat: None,
508 });
509 }
510 Some(PackageManager::Suse) if system.on_path("zypper") => {
511 let rolling = platform.id.contains("tumbleweed");
513 channels.push(Channel {
514 id: "zypper".into(),
515 name: "openSUSE packages".into(),
516 pending: read(system, &["zypper", "-q", "--no-refresh", "list-updates"])
517 .map(|text| zypper(&text)),
518 note: "from zypper's cached repositories".into(),
519 commands: vec![words(if rolling {
520 &["sudo", "zypper", "dist-upgrade"]
521 } else {
522 &["sudo", "zypper", "update"]
523 })],
524 requires: strings(&["zypper", "sudo"]),
525 caveat: None,
526 });
527 }
528 _ => {}
529 }
530 if system.on_path("flatpak") {
531 for (installation, id, name) in [
532 ("--system", "flatpak-system", "Flatpak (system)"),
533 ("--user", "flatpak-user", "Flatpak (user)"),
534 ] {
535 let installed = read(
536 system,
537 &["flatpak", "list", installation, "--columns=application"],
538 )
539 .unwrap_or_default();
540 if installed.trim().is_empty() {
541 continue;
542 }
543 let mut command = vec![
544 "flatpak",
545 "remote-ls",
546 "--updates",
547 installation,
548 "--columns=application,version",
549 ];
550 if !online {
551 command.push("--cached");
552 }
553 let system_wide = installation == "--system";
554 channels.push(Channel {
555 id: id.into(),
556 name: name.into(),
557 pending: read(system, &command).map(|text| columns(&text)),
558 note: if online {
559 "checked online with flatpak remote-ls".into()
560 } else {
561 "from Flatpak's cached remote metadata".into()
562 },
563 commands: vec![if system_wide {
564 words(&["sudo", "flatpak", "update", "--system"])
565 } else {
566 words(&["flatpak", "update", "--user"])
567 }],
568 requires: if system_wide {
569 strings(&["flatpak", "sudo"])
570 } else {
571 strings(&["flatpak"])
572 },
573 caveat: None,
574 });
575 }
576 }
577 if let Some(channel) = home_manager(system, places) {
578 channels.push(channel);
579 }
580 if system.exists(Path::new("/etc/NIXOS")) {
581 let flake = Path::new("/etc/nixos/flake.nix");
582 let (commands, caveat) = if system.exists(flake) {
583 (
584 vec![
585 words(&["sudo", "nix", "flake", "update", "--flake", "/etc/nixos"]),
586 words(&["sudo", "nixos-rebuild", "switch", "--flake", "/etc/nixos"]),
587 ],
588 Some("updates /etc/nixos/flake.lock".to_string()),
589 )
590 } else {
591 (
592 vec![words(&["sudo", "nixos-rebuild", "switch", "--upgrade"])],
593 None,
594 )
595 };
596 channels.push(Channel {
597 id: "nixos".into(),
598 name: "NixOS".into(),
599 pending: None,
600 note: lock_age(system, Path::new("/etc/nixos/flake.lock"), places.now)
601 .unwrap_or_else(|| "NixOS counts nothing before it rebuilds".into()),
602 commands,
603 requires: strings(&["nixos-rebuild", "sudo"]),
604 caveat,
605 });
606 }
607 let manifest = places.home.join(".nix-profile/manifest.json");
608 if system.on_path("nix")
609 && let Some(text) = system.read(&manifest)
610 && let Ok(json) = serde_json::from_str::<serde_json::Value>(&text)
611 {
612 let count = match &json["elements"] {
613 serde_json::Value::Object(map) => map.len(),
614 serde_json::Value::Array(list) => list.len(),
615 _ => 0,
616 };
617 if count > 0 {
618 channels.push(Channel {
619 id: "nix-profile".into(),
620 name: "Nix profile".into(),
621 pending: None,
622 note: format!(
623 "{count} package{} installed with nix profile",
624 if count == 1 { "" } else { "s" }
625 ),
626 commands: vec![words(&["nix", "profile", "upgrade", "--all"])],
627 requires: strings(&["nix"]),
628 caveat: None,
629 });
630 }
631 }
632 if let Some(this) = &places.this {
633 channels.push(this_channel(system, platform, this, online));
634 }
635 channels.into_iter().map(cleaned).collect()
638}
639
640fn cleaned(mut channel: Channel) -> Channel {
641 channel.name = printable(&channel.name);
642 channel.note = printable(&channel.note);
643 channel.caveat = channel.caveat.as_deref().map(printable);
644 for pending in channel.pending.iter_mut().flatten() {
645 pending.name = printable(&pending.name);
646 pending.from = pending.from.as_deref().map(printable);
647 pending.to = pending.to.as_deref().map(printable);
648 }
649 channel
650}
651
652fn home_manager(system: &dyn System, places: &Places) -> Option<Channel> {
654 if !system.on_path("home-manager") {
655 return None;
656 }
657 let directory = places.config.join("home-manager");
658 if !system.exists(&directory.join("flake.nix")) {
659 return None;
660 }
661 let shown = system
663 .canonical(&directory)
664 .unwrap_or_else(|| directory.clone());
665 let flake = directory.to_string_lossy().into_owned();
666 Some(Channel {
667 id: "home-manager".into(),
668 name: "Home Manager".into(),
669 pending: None,
670 note: lock_age(system, &directory.join("flake.lock"), places.now)
671 .unwrap_or_else(|| "its flake inputs are not locked yet".into()),
672 commands: vec![
673 words(&["nix", "flake", "update", "--flake", &flake]),
674 words(&["home-manager", "switch", "--flake", &flake]),
675 ],
676 requires: strings(&["nix", "home-manager"]),
677 caveat: Some(format!(
678 "updates {}; commit it there if you keep it in Git",
679 shown.join("flake.lock").display()
680 )),
681 })
682}
683
684fn lock_age(system: &dyn System, lock: &Path, now: u64) -> Option<String> {
686 let json: serde_json::Value = serde_json::from_str(&system.read(lock)?).ok()?;
687 let nodes = json["nodes"].as_object()?;
688 let when = |node: &serde_json::Value| node["locked"]["lastModified"].as_u64();
689 let (name, time) = match nodes.get("nixpkgs").and_then(when) {
690 Some(time) => ("nixpkgs", time),
691 None => nodes
692 .iter()
693 .filter_map(|(name, node)| Some((name.as_str(), when(node)?)))
694 .min_by_key(|(_, time)| *time)?,
695 };
696 let days = now.saturating_sub(time) / 86_400;
697 Some(format!(
698 "{name} locked at {}, {}",
699 date(time),
700 match days {
701 0 => "today".to_string(),
702 1 => "1 day ago".to_string(),
703 days => format!("{days} days ago"),
704 }
705 ))
706}
707
708pub fn date(seconds: u64) -> String {
710 let days = (seconds / 86_400) as i64 + 719_468;
712 let era = days.div_euclid(146_097);
713 let day_of_era = days.rem_euclid(146_097);
714 let year_of_era =
715 (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
716 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
717 let month_index = (5 * day_of_year + 2) / 153;
718 let day = day_of_year - (153 * month_index + 2) / 5 + 1;
719 let month = if month_index < 10 {
720 month_index + 3
721 } else {
722 month_index - 9
723 };
724 let year = year_of_era + era * 400 + i64::from(month <= 2);
725 format!("{year:04}-{month:02}-{day:02}")
726}
727
728pub fn arrows(text: &str) -> Vec<Pending> {
730 text.lines()
731 .filter(|line| !line.contains("[ignored]"))
732 .filter_map(|line| {
733 let mut words = line.split_whitespace();
734 let name = words.next()?;
735 let from = words.next()?;
736 (words.next()? == "->").then_some(())?;
737 Some(Pending {
738 name: name.to_string(),
739 from: Some(from.to_string()),
740 to: Some(words.next()?.to_string()),
741 })
742 })
743 .collect()
744}
745
746pub fn apt(text: &str) -> Vec<Pending> {
748 text.lines()
749 .filter_map(|line| {
750 let (head, from) = line.split_once("[upgradable from:")?;
751 let mut words = head.split_whitespace();
752 let name = words.next()?.split('/').next()?;
753 Some(Pending {
754 name: name.to_string(),
755 from: Some(from.trim().trim_end_matches(']').trim().to_string()),
756 to: words.next().map(String::from),
757 })
758 })
759 .collect()
760}
761
762pub fn dnf(text: &str) -> Vec<Pending> {
764 let mut found: Vec<Pending> = crate::inventory::parse_dnf_upgrades(text)
765 .into_iter()
766 .map(|(name, to)| Pending {
767 name,
768 from: None,
769 to: Some(to),
770 })
771 .collect();
772 found.sort_by(|a, b| a.name.cmp(&b.name));
773 found
774}
775
776pub fn zypper(text: &str) -> Vec<Pending> {
778 text.lines()
779 .filter_map(|line| {
780 let cells: Vec<&str> = line.split('|').map(str::trim).collect();
781 (cells.len() >= 5 && cells[0] == "v").then(|| Pending {
782 name: cells[2].to_string(),
783 from: Some(cells[3].to_string()),
784 to: Some(cells[4].to_string()),
785 })
786 })
787 .collect()
788}
789
790pub fn columns(text: &str) -> Vec<Pending> {
792 text.lines()
793 .filter_map(|line| {
794 let mut cells = line.split('\t').map(str::trim);
795 let name = cells.next().filter(|name| !name.is_empty())?;
796 Some(Pending {
797 name: name.to_string(),
798 from: None,
799 to: cells
800 .next()
801 .filter(|version| !version.is_empty())
802 .map(String::from),
803 })
804 })
805 .collect()
806}
807
808pub fn plan(channels: &[&Channel]) -> Plan {
811 let mut plan = Plan::default();
812 for channel in channels {
813 if channel.commands.is_empty() {
814 plan.notes.push(format!(
815 "{}: nothing to run ({})",
816 channel.name, channel.note
817 ));
818 continue;
819 }
820 plan.privileged |= channel.requires.iter().any(|tool| tool == "sudo");
821 plan.risk = plan.risk.max(match channel.id.as_str() {
822 "pacman" | "apt" | "dnf" | "zypper" | "nixos" => Risk::High,
823 _ => Risk::Medium,
824 });
825 plan.requirements.extend(channel.requires.iter().cloned());
826 if let Some(caveat) = &channel.caveat {
827 plan.notes.push(format!("{}: {caveat}", channel.name));
828 }
829 plan.steps.push(Step::Update {
830 id: format!("update-{}", channel.id),
831 name: format!("Update {}", channel.name),
832 source: Source {
833 label: channel.name.clone(),
834 commands: channel.commands.clone(),
835 requires: channel.requires.clone(),
836 probe: None,
837 },
838 targets: Vec::new(),
839 });
840 }
841 let native = plan.steps.iter().position(|step| {
843 matches!(
844 step.id(),
845 "update-pacman" | "update-apt" | "update-dnf" | "update-zypper"
846 )
847 });
848 plan.depends = plan
849 .steps
850 .iter()
851 .map(|step| match native {
852 Some(index) if step.id() == "update-aur" => vec![index],
853 _ => Vec::new(),
854 })
855 .collect();
856 plan.requirements.sort();
857 plan.requirements.dedup();
858 plan
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864 use crate::doctor::Fake;
865
866 fn arch() -> LinuxInfo {
867 LinuxInfo::from_os_release("ID=garuda\nID_LIKE=arch\n").unwrap()
868 }
869
870 fn places() -> Places {
871 Places {
872 config: PathBuf::from("/home/me/.config"),
873 home: PathBuf::from("/home/me"),
874 now: 1_792_022_400,
876 this: None,
877 }
878 }
879
880 fn machine() -> Fake {
881 let mut fake = Fake {
882 tools: [
883 "pacman",
884 "checkupdates",
885 "paru",
886 "flatpak",
887 "home-manager",
888 "nix",
889 ]
890 .map(String::from)
891 .to_vec(),
892 ..Fake::default()
893 };
894 fake.commands.insert(
895 "pacman -Qu".into(),
896 (
897 true,
898 "ripgrep 15.2.0-1 -> 15.3.0-1\nlinux 6.1-1 -> 6.2-1 [ignored]\n".into(),
899 ),
900 );
901 fake.commands.insert(
902 "checkupdates".into(),
903 (
904 true,
905 "ripgrep 15.2.0-1 -> 15.3.0-1\nmesa 1-1 -> 2-1\n".into(),
906 ),
907 );
908 fake.commands
910 .insert("paru -Qua".into(), (false, String::new()));
911 fake.commands.insert(
912 "flatpak list --system --columns=application".into(),
913 (true, "org.zapzap.ZapZap\n".into()),
914 );
915 fake.commands.insert(
916 "flatpak list --user --columns=application".into(),
917 (true, String::new()),
918 );
919 fake.commands.insert(
920 "flatpak remote-ls --updates --system --columns=application,version --cached".into(),
921 (true, "org.zapzap.ZapZap\t7.6\n".into()),
922 );
923 fake.files.insert(
924 PathBuf::from("/home/me/.config/home-manager/flake.nix"),
925 "{}".into(),
926 );
927 fake.files.insert(
928 PathBuf::from("/home/me/.config/home-manager/flake.lock"),
929 r#"{"nodes":{"nixpkgs":{"locked":{"lastModified":1791331200}},"systems":{"locked":{"lastModified":1681000000}},"root":{}}}"#.into(),
930 );
931 fake.canonical.insert(
932 PathBuf::from("/home/me/.config/home-manager"),
933 PathBuf::from("/home/me/dotfiles/home-manager"),
934 );
935 fake
936 }
937
938 #[test]
939 fn offline_the_overview_reads_what_the_tools_know() {
940 let channels = overview(&machine(), &arch(), &places(), false);
941 let ids: Vec<&str> = channels.iter().map(|channel| channel.id.as_str()).collect();
942 assert_eq!(ids, ["pacman", "aur", "flatpak-system", "home-manager"]);
943 assert_eq!(
944 channels[0].summary(),
945 "1 update",
946 "ignored packages are left out"
947 );
948 assert_eq!(
949 channels[0].pending.as_ref().unwrap()[0],
950 Pending {
951 name: "ripgrep".into(),
952 from: Some("15.2.0-1".into()),
953 to: Some("15.3.0-1".into()),
954 }
955 );
956 assert_eq!(channels[1].pending, None, "the AUR only online");
957 assert_eq!(channels[2].summary(), "1 update");
958 let home = &channels[3];
959 assert_eq!(home.note, "nixpkgs locked at 2026-10-07, 8 days ago");
960 assert_eq!(
961 home.command_line(),
962 "nix flake update --flake /home/me/.config/home-manager && home-manager switch --flake /home/me/.config/home-manager"
963 );
964 assert!(
965 home.caveat
966 .as_deref()
967 .unwrap()
968 .contains("/home/me/dotfiles/home-manager/flake.lock")
969 );
970 }
971
972 #[test]
973 fn online_the_overview_asks_read_only_commands() {
974 let channels = overview(&machine(), &arch(), &places(), true);
975 assert_eq!(channels[0].summary(), "2 updates");
976 assert!(channels[0].note.contains("checkupdates"));
977 assert_eq!(channels[1].summary(), "up to date");
978 assert_eq!(channels[2].pending, None);
980 }
981
982 #[test]
983 fn control_characters_from_the_tools_are_not_shown() {
984 let mut fake = machine();
985 fake.commands.insert(
986 "pacman -Qu".into(),
987 (true, "evil\x1b]0;x\x07 1 -> 2\x1b[2J\n".into()),
988 );
989 fake.files.insert(
990 PathBuf::from("/home/me/.config/home-manager/flake.lock"),
991 r#"{"nodes":{"bad\u001b[31m":{"locked":{"lastModified":1791331200}},"root":{}}}"#
992 .into(),
993 );
994 let channels = overview(&fake, &arch(), &places(), false);
995 let shown = format!("{channels:?}");
996 assert!(
997 !shown.contains('\x1b') && !shown.contains('\x07'),
998 "{shown}"
999 );
1000 let pending = &channels[0].pending.as_ref().unwrap()[0];
1001 assert_eq!(pending.name, "evil?]0;x?");
1002 assert_eq!(pending.to.as_deref(), Some("2?[2J"));
1003 assert!(channels[3].note.starts_with("bad?[31m locked at"));
1004 let mut odd = channels[3].clone();
1006 odd.commands = vec![vec!["nix".into(), "/home/\x1b[2Jme".into()]];
1007 let plan = plan(&[&odd]);
1008 assert_eq!(plan.steps[0].display(), "nix /home/?[2Jme");
1009 }
1010
1011 fn this(install: Install) -> ThisCopy {
1012 ThisCopy {
1013 version: "1.0.3".into(),
1014 exe: PathBuf::from("/usr/bin/helixforge"),
1015 install,
1016 cache: PathBuf::from("/home/me/.cache/helixforge/release"),
1017 }
1018 }
1019
1020 fn latest(fake: &mut Fake, tag: &str) {
1021 fake.commands.insert(
1022 format!("curl -fsSL --max-time 15 --proto =https {LATEST}"),
1023 (true, format!(r#"{{"tag_name": "{tag}", "name": "x"}}"#)),
1024 );
1025 }
1026
1027 fn only_this(fake: &Fake, install: Install, online: bool) -> Channel {
1028 let mut places = places();
1029 places.this = Some(this(install));
1030 overview(fake, &arch(), &places, online)
1031 .into_iter()
1032 .find(|channel| channel.id == "helixforge")
1033 .unwrap()
1034 }
1035
1036 #[test]
1037 fn where_it_runs_from_says_how_it_was_installed() {
1038 let homes = Homes {
1039 cargo: PathBuf::from("/home/me/.cargo"),
1040 data: PathBuf::from("/home/me/.local/share"),
1041 temp: PathBuf::from("/tmp"),
1042 };
1043 let checkout = |root: &Path| root == Path::new("/home/me/Helix-Forge");
1044 let of = |path: &str| install_of(Path::new(path), &homes, &checkout);
1045 assert_eq!(of("/usr/bin/helixforge"), Install::Package);
1046 assert_eq!(of("/home/me/.cargo/bin/helixforge"), Install::Cargo);
1047 assert_eq!(
1048 of("/home/me/.local/share/helixforge/helixforge-linux-x86_64"),
1049 Install::Launcher
1050 );
1051 assert_eq!(
1052 of("/home/me/Helix-Forge/target/release/helixforge"),
1053 Install::Source(PathBuf::from("/home/me/Helix-Forge"))
1054 );
1055 assert_eq!(
1056 of("/tmp/tmp.abc/helixforge-linux-x86_64"),
1057 Install::Download
1058 );
1059 assert_eq!(
1060 of("/tmp/other/target/release/helixforge"),
1061 Install::Download
1062 );
1063 assert_eq!(of("/opt/hf/helixforge"), Install::Other);
1064 }
1065
1066 #[test]
1067 fn a_package_install_downloads_checks_and_installs_the_newer_package() {
1068 let mut fake = machine();
1069 latest(&mut fake, "v1.0.4");
1070 let offline = only_this(&fake, Install::Package, false);
1071 assert_eq!(offline.pending, None);
1072 assert!(offline.note.contains("r checks GitHub"), "{}", offline.note);
1073 let channel = only_this(&fake, Install::Package, true);
1074 assert_eq!(channel.summary(), "1 update");
1075 assert_eq!(channel.note, "1.0.3, the latest release is 1.0.4");
1076 let file = format!("helixforge-1.0.4-1-{}.pkg.tar.zst", std::env::consts::ARCH);
1078 assert_eq!(
1079 channel.command_line(),
1080 format!(
1081 "/usr/bin/helixforge fetch-release 1.0.4 {file} /home/me/.cache/helixforge/release && sudo pacman -U /home/me/.cache/helixforge/release/{file}"
1082 )
1083 );
1084 latest(&mut fake, "v1.0.3");
1086 let current = only_this(&fake, Install::Package, true);
1087 assert_eq!(current.summary(), "up to date");
1088 assert!(current.commands.is_empty());
1089 let plan = plan(&[¤t]);
1090 assert!(plan.steps.is_empty());
1091 assert!(plan.notes[0].contains("nothing to run"), "{:?}", plan.notes);
1092 latest(&mut fake, "v1.0.4; rm -rf /");
1094 assert_eq!(only_this(&fake, Install::Package, true).pending, None);
1095 }
1096
1097 #[test]
1098 fn package_files_follow_each_family_and_architecture() {
1099 let file = |manager, architecture| package_asset_for(manager, "1.2.3", architecture);
1100 assert_eq!(
1101 file(PackageManager::Debian, "x86_64").as_deref(),
1102 Some("helixforge_1.2.3-1_amd64.deb")
1103 );
1104 assert_eq!(
1105 file(PackageManager::Suse, "x86_64").as_deref(),
1106 Some("helixforge-1.2.3-1.x86_64.rpm")
1107 );
1108 assert_eq!(
1109 file(PackageManager::Arch, "aarch64").as_deref(),
1110 Some("helixforge-1.2.3-1-aarch64.pkg.tar.zst")
1111 );
1112 assert_eq!(
1113 file(PackageManager::Debian, "aarch64").as_deref(),
1114 Some("helixforge_1.2.3-1_arm64.deb")
1115 );
1116 assert_eq!(
1117 file(PackageManager::Fedora, "aarch64").as_deref(),
1118 Some("helixforge-1.2.3-1.aarch64.rpm")
1119 );
1120 assert_eq!(file(PackageManager::Debian, "riscv64"), None);
1121 assert_eq!(file(PackageManager::Alpine, "x86_64"), None);
1122 assert_eq!(package_asset(PackageManager::Arch, "1.2.3/../x"), None);
1123 assert_eq!(
1125 package_asset(PackageManager::Fedora, "1.2.3"),
1126 file(PackageManager::Fedora, std::env::consts::ARCH)
1127 );
1128 assert_eq!(
1129 program_asset("aarch64").as_deref(),
1130 Some("helixforge-linux-aarch64")
1131 );
1132 assert_eq!(program_asset("../x"), None);
1133 }
1134
1135 #[test]
1136 fn cargo_launcher_source_and_unknown_copies_each_say_how_they_update() {
1137 let mut fake = machine();
1138 fake.tools.push("cargo".into());
1139 latest(&mut fake, "v2.0.0");
1140 let cargo = only_this(&fake, Install::Cargo, true);
1141 assert_eq!(cargo.command_line(), "cargo install --locked helixforge");
1142
1143 let launcher = only_this(&fake, Install::Launcher, true);
1144 assert_eq!(launcher.summary(), "up to date");
1145 assert!(launcher.commands.is_empty());
1146 assert!(launcher.note.contains("each time it starts"));
1147
1148 let other = only_this(&fake, Install::Other, true);
1149 assert_eq!(other.pending, None);
1150 assert!(other.note.contains("cannot update"), "{}", other.note);
1151
1152 let root = PathBuf::from("/home/me/Helix-Forge");
1153 fake.commands.insert(
1154 "git -C /home/me/Helix-Forge rev-parse HEAD".into(),
1155 (true, "aaaaaaaaaaaaaaaaaaaa\n".into()),
1156 );
1157 fake.commands.insert(
1158 "git -C /home/me/Helix-Forge ls-remote origin refs/heads/main".into(),
1159 (true, "bbbbbbbbbbbbbbbbbbbb\trefs/heads/main\n".into()),
1160 );
1161 let source = only_this(&fake, Install::Source(root), true);
1162 assert_eq!(source.summary(), "1 update");
1163 assert_eq!(
1164 source.command_line(),
1165 "git -C /home/me/Helix-Forge diff --quiet HEAD && git -C /home/me/Helix-Forge pull --ff-only && cargo build --release --locked --manifest-path /home/me/Helix-Forge/Cargo.toml -p helixforge"
1166 );
1167 }
1168
1169 #[test]
1170 fn a_machine_without_the_tools_has_no_channels() {
1171 let fake = Fake::default();
1172 assert!(overview(&fake, &arch(), &places(), false).is_empty());
1173 }
1174
1175 #[test]
1176 fn the_parsers_read_each_tool() {
1177 let apt = apt("Listing...\nbash/stable 5.2.15-2+b3 amd64 [upgradable from: 5.2.15-2+b2]\n");
1178 assert_eq!(apt[0].name, "bash");
1179 assert_eq!(apt[0].from.as_deref(), Some("5.2.15-2+b2"));
1180 assert_eq!(apt[0].to.as_deref(), Some("5.2.15-2+b3"));
1181 let zypper = zypper(
1182 "S | Repository | Name | Current Version | Available Version | Arch\n--+---\nv | Main | vim | 9.0-1 | 9.1-1 | x86_64\n",
1183 );
1184 assert_eq!(zypper.len(), 1);
1185 assert_eq!(zypper[0].to.as_deref(), Some("9.1-1"));
1186 let dnf = dnf("Available Upgrades\ngit.x86_64 2.47.0-1.fc41 updates\n");
1187 assert_eq!(dnf[0].name, "git");
1188 assert_eq!(date(0), "1970-01-01");
1189 assert_eq!(date(1_792_022_400), "2026-10-15");
1190 }
1191
1192 #[test]
1193 fn the_plan_runs_each_tool_in_order_and_the_aur_after_pacman() {
1194 let channels = overview(&machine(), &arch(), &places(), false);
1195 let chosen: Vec<&Channel> = channels.iter().collect();
1196 let plan = plan(&chosen);
1197 let ids: Vec<&str> = plan.steps.iter().map(|step| step.id()).collect();
1198 assert_eq!(
1199 ids,
1200 [
1201 "update-pacman",
1202 "update-aur",
1203 "update-flatpak-system",
1204 "update-home-manager"
1205 ]
1206 );
1207 assert_eq!(plan.depends[1], [0]);
1208 assert!(plan.privileged);
1209 assert_eq!(plan.risk, Risk::High);
1210 assert!(
1211 plan.notes
1212 .iter()
1213 .any(|note| note.contains("partial upgrades"))
1214 );
1215 assert_eq!(plan.steps[0].display(), "sudo pacman -Syu");
1216 assert_eq!(plan.steps[1].display(), "paru -Sua");
1217 }
1218}