Skip to main content

helixforge_engine/
updates.rs

1//! One overview of pending updates across the tools that keep this machine:
2//! the distribution's package manager, an AUR helper, Flatpak and Nix. It
3//! reads what each tool already knows; an online check asks the network only
4//! through read-only commands. Updating is delegated to the tools themselves.
5
6use 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/// One package waiting for an update.
15#[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/// One tool that updates part of the machine.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct Channel {
25    /// `pacman`, `aur`, `apt`, `dnf`, `zypper`, `flatpak-system`,
26    /// `flatpak-user`, `home-manager`, `nixos` or `nix-profile`.
27    pub id: String,
28    pub name: String,
29    /// What is waiting; None when the tool cannot say without updating, or
30    /// only online.
31    pub pending: Option<Vec<Pending>>,
32    /// Where the answer comes from, or what is known instead.
33    pub note: String,
34    /// What updating runs, in order.
35    pub commands: Vec<Vec<String>>,
36    pub requires: Vec<String>,
37    /// What the update changes beyond packages, said before it runs.
38    pub caveat: Option<String>,
39}
40
41impl Channel {
42    /// How many updates are known to wait.
43    pub fn count(&self) -> Option<usize> {
44        self.pending.as_ref().map(Vec::len)
45    }
46
47    /// A short status: "12 updates", "up to date" or "unknown".
48    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    /// The commands as shown, safe to print.
58    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
70/// Where to look, apart from the commands.
71pub struct Places {
72    /// `$XDG_CONFIG_HOME`, or `~/.config`.
73    pub config: PathBuf,
74    pub home: PathBuf,
75    /// Seconds since 1970, for how old the Nix inputs are.
76    pub now: u64,
77    /// The HelixForge asking, for its own update; none leaves it out.
78    pub this: Option<ThisCopy>,
79}
80
81/// The running HelixForge: its version, where it runs from, and so how it
82/// was installed.
83#[derive(Clone, Debug, Eq, PartialEq)]
84pub struct ThisCopy {
85    pub version: String,
86    pub exe: PathBuf,
87    pub install: Install,
88    /// Where a downloaded package waits to be installed.
89    pub cache: PathBuf,
90}
91
92/// How this copy of HelixForge was installed.
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub enum Install {
95    /// A distribution package, in /usr/bin.
96    Package,
97    /// `cargo install`, in Cargo's bin directory.
98    Cargo,
99    /// Built in a checkout of the repository, whose root this is.
100    Source(PathBuf),
101    /// The launcher's copy, which it replaces with the latest release when
102    /// it starts.
103    Launcher,
104    /// Downloaded by start.sh into a temporary directory for one run.
105    Download,
106    /// Somewhere else.
107    Other,
108}
109
110/// The directories that say how a copy was installed.
111pub struct Homes {
112    pub cargo: PathBuf,
113    pub data: PathBuf,
114    pub temp: PathBuf,
115}
116
117/// How the copy at `exe` was installed; `checkout` says whether a directory
118/// is the root of a HelixForge checkout.
119pub 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    /// The same, with the running HelixForge, `version` and at `exe`, for
162    /// its own update.
163    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
187/// Where HelixForge's releases are.
188pub 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
191/// A release version's parts, for comparing.
192fn 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
203/// The release file a family's package manager installs on this machine.
204pub fn package_asset(manager: PackageManager, version: &str) -> Option<String> {
205    package_asset_for(manager, version, std::env::consts::ARCH)
206}
207
208/// The release file a family's package manager installs on `architecture`
209/// (x86_64 or aarch64). Debian names the architectures its own way.
210pub 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
231/// The release's program on its own, for `architecture`.
232pub fn program_asset(architecture: &str) -> Option<String> {
233    helixforge_core::ARCHITECTURES
234        .contains(&architecture)
235        .then(|| format!("helixforge-linux-{architecture}"))
236}
237
238/// HelixForge itself, updated the way it was installed.
239fn 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    // The latest release, asked of GitHub only in an online check.
255    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                // A checkout with local changes is left alone.
382                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
412/// The standard output of a command, whatever its exit status: pacman -Qu
413/// and checkupdates exit non-zero when nothing waits. None when it cannot
414/// run.
415fn read(system: &dyn System, command: &[&str]) -> Option<String> {
416    system.run(command).map(|(_, text)| text)
417}
418
419/// Every channel this machine has. `online` also asks the network, through
420/// read-only commands (checkupdates, the AUR helper, Flatpak's remotes).
421pub 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            // Tumbleweed moves as a whole, through a distribution upgrade.
512            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    // Package names, versions, flake input names and paths come from outside
636    // HelixForge: keep terminal control characters out of what is shown.
637    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
652/// Home Manager with a flake in its configuration directory.
653fn 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    // A link to a dotfiles checkout is where the lock file really changes.
662    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
684/// How old the locked nixpkgs (or else the oldest input) is.
685fn 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
708/// A UTC date, YYYY-MM-DD.
709pub fn date(seconds: u64) -> String {
710    // Days to a civil date (Howard Hinnant's algorithm).
711    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
728/// `name old -> new` lines: pacman -Qu, checkupdates, paru and yay -Qua.
729pub 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
746/// `name/suite new arch [upgradable from: old]` lines from apt list.
747pub 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
762/// `name.arch version repository` lines from dnf list --upgrades.
763pub 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
776/// `v | repository | name | current | available | arch` rows from zypper.
777pub 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
790/// Tab-separated `application version` columns from flatpak remote-ls.
791pub 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
808/// A plan updating the channels in order, each through its own tool, one
809/// after another; a later channel waits for the native packages.
810pub 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    // The AUR builds against the repositories' packages: only after them.
842    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            // 2026-10-15
875            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        // paru exits 1 when nothing waits.
909        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        // The online Flatpak command was not given, so it is unknown.
979        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        // The plan's commands are shown cleaned, though they run as given.
1005        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        // The package for this machine's architecture.
1077        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        // Up to date: nothing to run.
1085        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(&[&current]);
1090        assert!(plan.steps.is_empty());
1091        assert!(plan.notes[0].contains("nothing to run"), "{:?}", plan.notes);
1092        // A tag that is not a release version is not believed.
1093        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        // This machine's own.
1124        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}