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.
204pub fn package_asset(manager: PackageManager, version: &str) -> Option<String> {
205    release(version)?;
206    match manager {
207        PackageManager::Arch => Some(format!("helixforge-{version}-1-x86_64.pkg.tar.zst")),
208        PackageManager::Debian => Some(format!("helixforge_{version}-1_amd64.deb")),
209        PackageManager::Fedora | PackageManager::Suse => {
210            Some(format!("helixforge-{version}-1.x86_64.rpm"))
211        }
212        _ => None,
213    }
214}
215
216/// HelixForge itself, updated the way it was installed.
217fn this_channel(
218    system: &dyn System,
219    platform: &LinuxInfo,
220    this: &ThisCopy,
221    online: bool,
222) -> Channel {
223    let mut channel = Channel {
224        id: "helixforge".into(),
225        name: "HelixForge".into(),
226        pending: None,
227        note: String::new(),
228        commands: Vec::new(),
229        requires: Vec::new(),
230        caveat: None,
231    };
232    // The latest release, asked of GitHub only in an online check.
233    let latest = || -> Option<String> {
234        let text = read(
235            system,
236            &[
237                "curl",
238                "-fsSL",
239                "--max-time",
240                "15",
241                "--proto",
242                "=https",
243                LATEST,
244            ],
245        )?;
246        let json: serde_json::Value = serde_json::from_str(&text).ok()?;
247        let tag = json["tag_name"].as_str()?;
248        release(tag)?;
249        Some(tag.trim_start_matches('v').to_string())
250    };
251    let behind = |latest: &str| {
252        let pending = match (release(&this.version), release(latest)) {
253            (Some(current), Some(newest)) if newest > current => vec![Pending {
254                name: "helixforge".into(),
255                from: Some(this.version.clone()),
256                to: Some(latest.to_string()),
257            }],
258            _ => Vec::new(),
259        };
260        Some(pending)
261    };
262    let exe = this.exe.to_string_lossy().into_owned();
263    match &this.install {
264        Install::Launcher | Install::Download => {
265            channel.pending = Some(Vec::new());
266            channel.note = format!(
267                "{}; it fetches the latest release each time it starts",
268                this.version
269            );
270        }
271        Install::Other => {
272            channel.note = format!(
273                "{} at {}, installed in a way HelixForge cannot update; update it the way you installed it",
274                this.version,
275                this.exe.display()
276            );
277        }
278        Install::Package | Install::Cargo if !online => {
279            channel.note = format!("{}; r checks GitHub for a newer release", this.version);
280        }
281        Install::Package | Install::Cargo => match latest() {
282            None => {
283                channel.note = format!(
284                    "{}; GitHub could not be asked for the latest release",
285                    this.version
286                );
287            }
288            Some(newest) => {
289                channel.pending = behind(&newest);
290                channel.note = format!("{}, the latest release is {newest}", this.version);
291                let waiting = channel.count().unwrap_or(0) > 0;
292                if this.install == Install::Cargo {
293                    if waiting && system.on_path("cargo") {
294                        channel.commands =
295                            vec![words(&["cargo", "install", "--locked", "helixforge"])];
296                        channel.requires = strings(&["cargo"]);
297                    }
298                } else if waiting
299                    && let Some(manager) = platform.manager
300                    && let Some(asset) = package_asset(manager, &newest)
301                {
302                    let cache = this.cache.to_string_lossy().into_owned();
303                    let file = this.cache.join(&asset).to_string_lossy().into_owned();
304                    let install: &[&str] = match manager {
305                        PackageManager::Arch => &["sudo", "pacman", "-U"],
306                        PackageManager::Debian => &["sudo", "apt-get", "install"],
307                        PackageManager::Fedora => &["sudo", "dnf", "install"],
308                        _ => &["sudo", "zypper", "install", "--allow-unsigned-rpm"],
309                    };
310                    let mut install = words(install);
311                    install.push(file);
312                    channel.commands = vec![
313                        vec![exe, "fetch-release".into(), newest.clone(), asset, cache],
314                        install,
315                    ];
316                    channel.requires = strings(&["curl", "sha256sum", "sudo"]);
317                    channel.caveat = Some(format!(
318                        "downloads the {newest} package from {RELEASES}/releases and checks it against the release's SHA256SUMS before installing it"
319                    ));
320                }
321            }
322        },
323        Install::Source(root) => {
324            let git = |args: &[&str]| {
325                let directory = root.to_string_lossy();
326                let mut command = vec!["git", "-C", directory.as_ref()];
327                command.extend(args);
328                read(system, &command)
329            };
330            let head = git(&["rev-parse", "HEAD"]).map(|text| text.trim().to_string());
331            let short = |hash: &str| hash.chars().take(12).collect::<String>();
332            channel.note = format!(
333                "a checkout at {}, {}",
334                root.display(),
335                head.as_deref()
336                    .map(short)
337                    .unwrap_or_else(|| "unknown".into())
338            );
339            if online {
340                let remote = git(&["ls-remote", "origin", "refs/heads/main"])
341                    .and_then(|text| text.split_whitespace().next().map(String::from));
342                if let (Some(head), Some(remote)) = (&head, remote) {
343                    channel.pending = Some(if *head == remote {
344                        Vec::new()
345                    } else {
346                        vec![Pending {
347                            name: "main".into(),
348                            from: Some(short(head)),
349                            to: Some(short(&remote)),
350                        }]
351                    });
352                }
353            } else {
354                channel.note.push_str("; r compares it with GitHub");
355            }
356            let directory = root.to_string_lossy().into_owned();
357            let manifest = root.join("Cargo.toml").to_string_lossy().into_owned();
358            channel.commands = vec![
359                // A checkout with local changes is left alone.
360                words(&["git", "-C", &directory, "diff", "--quiet", "HEAD"]),
361                words(&["git", "-C", &directory, "pull", "--ff-only"]),
362                words(&[
363                    "cargo",
364                    "build",
365                    "--release",
366                    "--locked",
367                    "--manifest-path",
368                    &manifest,
369                    "-p",
370                    "helixforge",
371                ]),
372            ];
373            channel.requires = strings(&["git", "cargo"]);
374            channel.caveat = Some(
375                "fast-forwards the checkout and builds it; a checkout with local changes or other commits is left alone".into(),
376            );
377        }
378    }
379    channel
380}
381
382fn words(command: &[&str]) -> Vec<String> {
383    command.iter().map(|word| word.to_string()).collect()
384}
385
386fn strings(tools: &[&str]) -> Vec<String> {
387    words(tools)
388}
389
390/// The standard output of a command, whatever its exit status: pacman -Qu
391/// and checkupdates exit non-zero when nothing waits. None when it cannot
392/// run.
393fn read(system: &dyn System, command: &[&str]) -> Option<String> {
394    system.run(command).map(|(_, text)| text)
395}
396
397/// Every channel this machine has. `online` also asks the network, through
398/// read-only commands (checkupdates, the AUR helper, Flatpak's remotes).
399pub fn overview(
400    system: &dyn System,
401    platform: &LinuxInfo,
402    places: &Places,
403    online: bool,
404) -> Vec<Channel> {
405    let mut channels = Vec::new();
406    match platform.manager {
407        Some(PackageManager::Arch) if system.on_path("pacman") => {
408            let (pending, note) = if online && system.on_path("checkupdates") {
409                (
410                    read(system, &["checkupdates"]).map(|text| arrows(&text)),
411                    "checked online with checkupdates",
412                )
413            } else {
414                (
415                    read(system, &["pacman", "-Qu"]).map(|text| arrows(&text)),
416                    "as of pacman's last sync",
417                )
418            };
419            channels.push(Channel {
420                id: "pacman".into(),
421                name: "Arch packages".into(),
422                pending,
423                note: note.into(),
424                commands: vec![words(&["sudo", "pacman", "-Syu"])],
425                requires: strings(&["pacman", "sudo"]),
426                caveat: Some(
427                    "updates the whole system: Arch does not support partial upgrades".into(),
428                ),
429            });
430            if let Some(helper) = ["paru", "yay"]
431                .into_iter()
432                .find(|tool| system.on_path(tool))
433            {
434                let pending = online
435                    .then(|| read(system, &[helper, "-Qua"]).map(|text| arrows(&text)))
436                    .flatten();
437                channels.push(Channel {
438                    id: "aur".into(),
439                    name: format!("AUR ({helper})"),
440                    note: if online {
441                        format!("checked online with {helper} -Qua")
442                    } else {
443                        "the AUR is only asked in an online check".into()
444                    },
445                    pending,
446                    commands: vec![words(&[helper, "-Sua"])],
447                    requires: strings(&[helper]),
448                    caveat: Some(
449                        "builds AUR packages from their PKGBUILDs; read them when asked".into(),
450                    ),
451                });
452            }
453        }
454        Some(PackageManager::Debian) if system.on_path("apt") => {
455            channels.push(Channel {
456                id: "apt".into(),
457                name: "Debian packages".into(),
458                pending: read(system, &["apt", "list", "--upgradable"]).map(|text| apt(&text)),
459                note: "as of the last apt update, which the update runs first".into(),
460                commands: vec![
461                    words(&["sudo", "apt-get", "update"]),
462                    words(&["sudo", "apt-get", "upgrade"]),
463                ],
464                requires: strings(&["apt-get", "sudo"]),
465                caveat: None,
466            });
467        }
468        Some(PackageManager::Fedora) if system.on_path("dnf") => {
469            let command: &[&str] = if online {
470                &["dnf", "-q", "list", "--upgrades"]
471            } else {
472                &["dnf", "-C", "-q", "list", "--upgrades"]
473            };
474            channels.push(Channel {
475                id: "dnf".into(),
476                name: "Fedora packages".into(),
477                pending: read(system, command).map(|text| dnf(&text)),
478                note: if online {
479                    "checked online with dnf".into()
480                } else {
481                    "from dnf's cached metadata".into()
482                },
483                commands: vec![words(&["sudo", "dnf", "upgrade"])],
484                requires: strings(&["dnf", "sudo"]),
485                caveat: None,
486            });
487        }
488        Some(PackageManager::Suse) if system.on_path("zypper") => {
489            // Tumbleweed moves as a whole, through a distribution upgrade.
490            let rolling = platform.id.contains("tumbleweed");
491            channels.push(Channel {
492                id: "zypper".into(),
493                name: "openSUSE packages".into(),
494                pending: read(system, &["zypper", "-q", "--no-refresh", "list-updates"])
495                    .map(|text| zypper(&text)),
496                note: "from zypper's cached repositories".into(),
497                commands: vec![words(if rolling {
498                    &["sudo", "zypper", "dist-upgrade"]
499                } else {
500                    &["sudo", "zypper", "update"]
501                })],
502                requires: strings(&["zypper", "sudo"]),
503                caveat: None,
504            });
505        }
506        _ => {}
507    }
508    if system.on_path("flatpak") {
509        for (installation, id, name) in [
510            ("--system", "flatpak-system", "Flatpak (system)"),
511            ("--user", "flatpak-user", "Flatpak (user)"),
512        ] {
513            let installed = read(
514                system,
515                &["flatpak", "list", installation, "--columns=application"],
516            )
517            .unwrap_or_default();
518            if installed.trim().is_empty() {
519                continue;
520            }
521            let mut command = vec![
522                "flatpak",
523                "remote-ls",
524                "--updates",
525                installation,
526                "--columns=application,version",
527            ];
528            if !online {
529                command.push("--cached");
530            }
531            let system_wide = installation == "--system";
532            channels.push(Channel {
533                id: id.into(),
534                name: name.into(),
535                pending: read(system, &command).map(|text| columns(&text)),
536                note: if online {
537                    "checked online with flatpak remote-ls".into()
538                } else {
539                    "from Flatpak's cached remote metadata".into()
540                },
541                commands: vec![if system_wide {
542                    words(&["sudo", "flatpak", "update", "--system"])
543                } else {
544                    words(&["flatpak", "update", "--user"])
545                }],
546                requires: if system_wide {
547                    strings(&["flatpak", "sudo"])
548                } else {
549                    strings(&["flatpak"])
550                },
551                caveat: None,
552            });
553        }
554    }
555    if let Some(channel) = home_manager(system, places) {
556        channels.push(channel);
557    }
558    if system.exists(Path::new("/etc/NIXOS")) {
559        let flake = Path::new("/etc/nixos/flake.nix");
560        let (commands, caveat) = if system.exists(flake) {
561            (
562                vec![
563                    words(&["sudo", "nix", "flake", "update", "--flake", "/etc/nixos"]),
564                    words(&["sudo", "nixos-rebuild", "switch", "--flake", "/etc/nixos"]),
565                ],
566                Some("updates /etc/nixos/flake.lock".to_string()),
567            )
568        } else {
569            (
570                vec![words(&["sudo", "nixos-rebuild", "switch", "--upgrade"])],
571                None,
572            )
573        };
574        channels.push(Channel {
575            id: "nixos".into(),
576            name: "NixOS".into(),
577            pending: None,
578            note: lock_age(system, Path::new("/etc/nixos/flake.lock"), places.now)
579                .unwrap_or_else(|| "NixOS counts nothing before it rebuilds".into()),
580            commands,
581            requires: strings(&["nixos-rebuild", "sudo"]),
582            caveat,
583        });
584    }
585    let manifest = places.home.join(".nix-profile/manifest.json");
586    if system.on_path("nix")
587        && let Some(text) = system.read(&manifest)
588        && let Ok(json) = serde_json::from_str::<serde_json::Value>(&text)
589    {
590        let count = match &json["elements"] {
591            serde_json::Value::Object(map) => map.len(),
592            serde_json::Value::Array(list) => list.len(),
593            _ => 0,
594        };
595        if count > 0 {
596            channels.push(Channel {
597                id: "nix-profile".into(),
598                name: "Nix profile".into(),
599                pending: None,
600                note: format!(
601                    "{count} package{} installed with nix profile",
602                    if count == 1 { "" } else { "s" }
603                ),
604                commands: vec![words(&["nix", "profile", "upgrade", "--all"])],
605                requires: strings(&["nix"]),
606                caveat: None,
607            });
608        }
609    }
610    if let Some(this) = &places.this {
611        channels.push(this_channel(system, platform, this, online));
612    }
613    // Package names, versions, flake input names and paths come from outside
614    // HelixForge: keep terminal control characters out of what is shown.
615    channels.into_iter().map(cleaned).collect()
616}
617
618fn cleaned(mut channel: Channel) -> Channel {
619    channel.name = printable(&channel.name);
620    channel.note = printable(&channel.note);
621    channel.caveat = channel.caveat.as_deref().map(printable);
622    for pending in channel.pending.iter_mut().flatten() {
623        pending.name = printable(&pending.name);
624        pending.from = pending.from.as_deref().map(printable);
625        pending.to = pending.to.as_deref().map(printable);
626    }
627    channel
628}
629
630/// Home Manager with a flake in its configuration directory.
631fn home_manager(system: &dyn System, places: &Places) -> Option<Channel> {
632    if !system.on_path("home-manager") {
633        return None;
634    }
635    let directory = places.config.join("home-manager");
636    if !system.exists(&directory.join("flake.nix")) {
637        return None;
638    }
639    // A link to a dotfiles checkout is where the lock file really changes.
640    let shown = system
641        .canonical(&directory)
642        .unwrap_or_else(|| directory.clone());
643    let flake = directory.to_string_lossy().into_owned();
644    Some(Channel {
645        id: "home-manager".into(),
646        name: "Home Manager".into(),
647        pending: None,
648        note: lock_age(system, &directory.join("flake.lock"), places.now)
649            .unwrap_or_else(|| "its flake inputs are not locked yet".into()),
650        commands: vec![
651            words(&["nix", "flake", "update", "--flake", &flake]),
652            words(&["home-manager", "switch", "--flake", &flake]),
653        ],
654        requires: strings(&["nix", "home-manager"]),
655        caveat: Some(format!(
656            "updates {}; commit it there if you keep it in Git",
657            shown.join("flake.lock").display()
658        )),
659    })
660}
661
662/// How old the locked nixpkgs (or else the oldest input) is.
663fn lock_age(system: &dyn System, lock: &Path, now: u64) -> Option<String> {
664    let json: serde_json::Value = serde_json::from_str(&system.read(lock)?).ok()?;
665    let nodes = json["nodes"].as_object()?;
666    let when = |node: &serde_json::Value| node["locked"]["lastModified"].as_u64();
667    let (name, time) = match nodes.get("nixpkgs").and_then(when) {
668        Some(time) => ("nixpkgs", time),
669        None => nodes
670            .iter()
671            .filter_map(|(name, node)| Some((name.as_str(), when(node)?)))
672            .min_by_key(|(_, time)| *time)?,
673    };
674    let days = now.saturating_sub(time) / 86_400;
675    Some(format!(
676        "{name} locked at {}, {}",
677        date(time),
678        match days {
679            0 => "today".to_string(),
680            1 => "1 day ago".to_string(),
681            days => format!("{days} days ago"),
682        }
683    ))
684}
685
686/// A UTC date, YYYY-MM-DD.
687pub fn date(seconds: u64) -> String {
688    // Days to a civil date (Howard Hinnant's algorithm).
689    let days = (seconds / 86_400) as i64 + 719_468;
690    let era = days.div_euclid(146_097);
691    let day_of_era = days.rem_euclid(146_097);
692    let year_of_era =
693        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
694    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
695    let month_index = (5 * day_of_year + 2) / 153;
696    let day = day_of_year - (153 * month_index + 2) / 5 + 1;
697    let month = if month_index < 10 {
698        month_index + 3
699    } else {
700        month_index - 9
701    };
702    let year = year_of_era + era * 400 + i64::from(month <= 2);
703    format!("{year:04}-{month:02}-{day:02}")
704}
705
706/// `name old -> new` lines: pacman -Qu, checkupdates, paru and yay -Qua.
707pub fn arrows(text: &str) -> Vec<Pending> {
708    text.lines()
709        .filter(|line| !line.contains("[ignored]"))
710        .filter_map(|line| {
711            let mut words = line.split_whitespace();
712            let name = words.next()?;
713            let from = words.next()?;
714            (words.next()? == "->").then_some(())?;
715            Some(Pending {
716                name: name.to_string(),
717                from: Some(from.to_string()),
718                to: Some(words.next()?.to_string()),
719            })
720        })
721        .collect()
722}
723
724/// `name/suite new arch [upgradable from: old]` lines from apt list.
725pub fn apt(text: &str) -> Vec<Pending> {
726    text.lines()
727        .filter_map(|line| {
728            let (head, from) = line.split_once("[upgradable from:")?;
729            let mut words = head.split_whitespace();
730            let name = words.next()?.split('/').next()?;
731            Some(Pending {
732                name: name.to_string(),
733                from: Some(from.trim().trim_end_matches(']').trim().to_string()),
734                to: words.next().map(String::from),
735            })
736        })
737        .collect()
738}
739
740/// `name.arch version repository` lines from dnf list --upgrades.
741pub fn dnf(text: &str) -> Vec<Pending> {
742    let mut found: Vec<Pending> = crate::inventory::parse_dnf_upgrades(text)
743        .into_iter()
744        .map(|(name, to)| Pending {
745            name,
746            from: None,
747            to: Some(to),
748        })
749        .collect();
750    found.sort_by(|a, b| a.name.cmp(&b.name));
751    found
752}
753
754/// `v | repository | name | current | available | arch` rows from zypper.
755pub fn zypper(text: &str) -> Vec<Pending> {
756    text.lines()
757        .filter_map(|line| {
758            let cells: Vec<&str> = line.split('|').map(str::trim).collect();
759            (cells.len() >= 5 && cells[0] == "v").then(|| Pending {
760                name: cells[2].to_string(),
761                from: Some(cells[3].to_string()),
762                to: Some(cells[4].to_string()),
763            })
764        })
765        .collect()
766}
767
768/// Tab-separated `application version` columns from flatpak remote-ls.
769pub fn columns(text: &str) -> Vec<Pending> {
770    text.lines()
771        .filter_map(|line| {
772            let mut cells = line.split('\t').map(str::trim);
773            let name = cells.next().filter(|name| !name.is_empty())?;
774            Some(Pending {
775                name: name.to_string(),
776                from: None,
777                to: cells
778                    .next()
779                    .filter(|version| !version.is_empty())
780                    .map(String::from),
781            })
782        })
783        .collect()
784}
785
786/// A plan updating the channels in order, each through its own tool, one
787/// after another; a later channel waits for the native packages.
788pub fn plan(channels: &[&Channel]) -> Plan {
789    let mut plan = Plan::default();
790    for channel in channels {
791        if channel.commands.is_empty() {
792            plan.notes.push(format!(
793                "{}: nothing to run ({})",
794                channel.name, channel.note
795            ));
796            continue;
797        }
798        plan.privileged |= channel.requires.iter().any(|tool| tool == "sudo");
799        plan.risk = plan.risk.max(match channel.id.as_str() {
800            "pacman" | "apt" | "dnf" | "zypper" | "nixos" => Risk::High,
801            _ => Risk::Medium,
802        });
803        plan.requirements.extend(channel.requires.iter().cloned());
804        if let Some(caveat) = &channel.caveat {
805            plan.notes.push(format!("{}: {caveat}", channel.name));
806        }
807        plan.steps.push(Step::Update {
808            id: format!("update-{}", channel.id),
809            name: format!("Update {}", channel.name),
810            source: Source {
811                label: channel.name.clone(),
812                commands: channel.commands.clone(),
813                requires: channel.requires.clone(),
814                probe: None,
815            },
816            targets: Vec::new(),
817        });
818    }
819    // The AUR builds against the repositories' packages: only after them.
820    let native = plan.steps.iter().position(|step| {
821        matches!(
822            step.id(),
823            "update-pacman" | "update-apt" | "update-dnf" | "update-zypper"
824        )
825    });
826    plan.depends = plan
827        .steps
828        .iter()
829        .map(|step| match native {
830            Some(index) if step.id() == "update-aur" => vec![index],
831            _ => Vec::new(),
832        })
833        .collect();
834    plan.requirements.sort();
835    plan.requirements.dedup();
836    plan
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842    use crate::doctor::Fake;
843
844    fn arch() -> LinuxInfo {
845        LinuxInfo::from_os_release("ID=garuda\nID_LIKE=arch\n").unwrap()
846    }
847
848    fn places() -> Places {
849        Places {
850            config: PathBuf::from("/home/me/.config"),
851            home: PathBuf::from("/home/me"),
852            // 2026-10-15
853            now: 1_792_022_400,
854            this: None,
855        }
856    }
857
858    fn machine() -> Fake {
859        let mut fake = Fake {
860            tools: [
861                "pacman",
862                "checkupdates",
863                "paru",
864                "flatpak",
865                "home-manager",
866                "nix",
867            ]
868            .map(String::from)
869            .to_vec(),
870            ..Fake::default()
871        };
872        fake.commands.insert(
873            "pacman -Qu".into(),
874            (
875                true,
876                "ripgrep 15.2.0-1 -> 15.3.0-1\nlinux 6.1-1 -> 6.2-1 [ignored]\n".into(),
877            ),
878        );
879        fake.commands.insert(
880            "checkupdates".into(),
881            (
882                true,
883                "ripgrep 15.2.0-1 -> 15.3.0-1\nmesa 1-1 -> 2-1\n".into(),
884            ),
885        );
886        // paru exits 1 when nothing waits.
887        fake.commands
888            .insert("paru -Qua".into(), (false, String::new()));
889        fake.commands.insert(
890            "flatpak list --system --columns=application".into(),
891            (true, "org.zapzap.ZapZap\n".into()),
892        );
893        fake.commands.insert(
894            "flatpak list --user --columns=application".into(),
895            (true, String::new()),
896        );
897        fake.commands.insert(
898            "flatpak remote-ls --updates --system --columns=application,version --cached".into(),
899            (true, "org.zapzap.ZapZap\t7.6\n".into()),
900        );
901        fake.files.insert(
902            PathBuf::from("/home/me/.config/home-manager/flake.nix"),
903            "{}".into(),
904        );
905        fake.files.insert(
906            PathBuf::from("/home/me/.config/home-manager/flake.lock"),
907            r#"{"nodes":{"nixpkgs":{"locked":{"lastModified":1791331200}},"systems":{"locked":{"lastModified":1681000000}},"root":{}}}"#.into(),
908        );
909        fake.canonical.insert(
910            PathBuf::from("/home/me/.config/home-manager"),
911            PathBuf::from("/home/me/dotfiles/home-manager"),
912        );
913        fake
914    }
915
916    #[test]
917    fn offline_the_overview_reads_what_the_tools_know() {
918        let channels = overview(&machine(), &arch(), &places(), false);
919        let ids: Vec<&str> = channels.iter().map(|channel| channel.id.as_str()).collect();
920        assert_eq!(ids, ["pacman", "aur", "flatpak-system", "home-manager"]);
921        assert_eq!(
922            channels[0].summary(),
923            "1 update",
924            "ignored packages are left out"
925        );
926        assert_eq!(
927            channels[0].pending.as_ref().unwrap()[0],
928            Pending {
929                name: "ripgrep".into(),
930                from: Some("15.2.0-1".into()),
931                to: Some("15.3.0-1".into()),
932            }
933        );
934        assert_eq!(channels[1].pending, None, "the AUR only online");
935        assert_eq!(channels[2].summary(), "1 update");
936        let home = &channels[3];
937        assert_eq!(home.note, "nixpkgs locked at 2026-10-07, 8 days ago");
938        assert_eq!(
939            home.command_line(),
940            "nix flake update --flake /home/me/.config/home-manager && home-manager switch --flake /home/me/.config/home-manager"
941        );
942        assert!(
943            home.caveat
944                .as_deref()
945                .unwrap()
946                .contains("/home/me/dotfiles/home-manager/flake.lock")
947        );
948    }
949
950    #[test]
951    fn online_the_overview_asks_read_only_commands() {
952        let channels = overview(&machine(), &arch(), &places(), true);
953        assert_eq!(channels[0].summary(), "2 updates");
954        assert!(channels[0].note.contains("checkupdates"));
955        assert_eq!(channels[1].summary(), "up to date");
956        // The online Flatpak command was not given, so it is unknown.
957        assert_eq!(channels[2].pending, None);
958    }
959
960    #[test]
961    fn control_characters_from_the_tools_are_not_shown() {
962        let mut fake = machine();
963        fake.commands.insert(
964            "pacman -Qu".into(),
965            (true, "evil\x1b]0;x\x07 1 -> 2\x1b[2J\n".into()),
966        );
967        fake.files.insert(
968            PathBuf::from("/home/me/.config/home-manager/flake.lock"),
969            r#"{"nodes":{"bad\u001b[31m":{"locked":{"lastModified":1791331200}},"root":{}}}"#
970                .into(),
971        );
972        let channels = overview(&fake, &arch(), &places(), false);
973        let shown = format!("{channels:?}");
974        assert!(
975            !shown.contains('\x1b') && !shown.contains('\x07'),
976            "{shown}"
977        );
978        let pending = &channels[0].pending.as_ref().unwrap()[0];
979        assert_eq!(pending.name, "evil?]0;x?");
980        assert_eq!(pending.to.as_deref(), Some("2?[2J"));
981        assert!(channels[3].note.starts_with("bad?[31m locked at"));
982        // The plan's commands are shown cleaned, though they run as given.
983        let mut odd = channels[3].clone();
984        odd.commands = vec![vec!["nix".into(), "/home/\x1b[2Jme".into()]];
985        let plan = plan(&[&odd]);
986        assert_eq!(plan.steps[0].display(), "nix /home/?[2Jme");
987    }
988
989    fn this(install: Install) -> ThisCopy {
990        ThisCopy {
991            version: "1.0.3".into(),
992            exe: PathBuf::from("/usr/bin/helixforge"),
993            install,
994            cache: PathBuf::from("/home/me/.cache/helixforge/release"),
995        }
996    }
997
998    fn latest(fake: &mut Fake, tag: &str) {
999        fake.commands.insert(
1000            format!("curl -fsSL --max-time 15 --proto =https {LATEST}"),
1001            (true, format!(r#"{{"tag_name": "{tag}", "name": "x"}}"#)),
1002        );
1003    }
1004
1005    fn only_this(fake: &Fake, install: Install, online: bool) -> Channel {
1006        let mut places = places();
1007        places.this = Some(this(install));
1008        overview(fake, &arch(), &places, online)
1009            .into_iter()
1010            .find(|channel| channel.id == "helixforge")
1011            .unwrap()
1012    }
1013
1014    #[test]
1015    fn where_it_runs_from_says_how_it_was_installed() {
1016        let homes = Homes {
1017            cargo: PathBuf::from("/home/me/.cargo"),
1018            data: PathBuf::from("/home/me/.local/share"),
1019            temp: PathBuf::from("/tmp"),
1020        };
1021        let checkout = |root: &Path| root == Path::new("/home/me/Helix-Forge");
1022        let of = |path: &str| install_of(Path::new(path), &homes, &checkout);
1023        assert_eq!(of("/usr/bin/helixforge"), Install::Package);
1024        assert_eq!(of("/home/me/.cargo/bin/helixforge"), Install::Cargo);
1025        assert_eq!(
1026            of("/home/me/.local/share/helixforge/helixforge-linux-x86_64"),
1027            Install::Launcher
1028        );
1029        assert_eq!(
1030            of("/home/me/Helix-Forge/target/release/helixforge"),
1031            Install::Source(PathBuf::from("/home/me/Helix-Forge"))
1032        );
1033        assert_eq!(
1034            of("/tmp/tmp.abc/helixforge-linux-x86_64"),
1035            Install::Download
1036        );
1037        assert_eq!(
1038            of("/tmp/other/target/release/helixforge"),
1039            Install::Download
1040        );
1041        assert_eq!(of("/opt/hf/helixforge"), Install::Other);
1042    }
1043
1044    #[test]
1045    fn a_package_install_downloads_checks_and_installs_the_newer_package() {
1046        let mut fake = machine();
1047        latest(&mut fake, "v1.0.4");
1048        let offline = only_this(&fake, Install::Package, false);
1049        assert_eq!(offline.pending, None);
1050        assert!(offline.note.contains("r checks GitHub"), "{}", offline.note);
1051        let channel = only_this(&fake, Install::Package, true);
1052        assert_eq!(channel.summary(), "1 update");
1053        assert_eq!(channel.note, "1.0.3, the latest release is 1.0.4");
1054        assert_eq!(
1055            channel.command_line(),
1056            "/usr/bin/helixforge fetch-release 1.0.4 helixforge-1.0.4-1-x86_64.pkg.tar.zst /home/me/.cache/helixforge/release && sudo pacman -U /home/me/.cache/helixforge/release/helixforge-1.0.4-1-x86_64.pkg.tar.zst"
1057        );
1058        // Up to date: nothing to run.
1059        latest(&mut fake, "v1.0.3");
1060        let current = only_this(&fake, Install::Package, true);
1061        assert_eq!(current.summary(), "up to date");
1062        assert!(current.commands.is_empty());
1063        let plan = plan(&[&current]);
1064        assert!(plan.steps.is_empty());
1065        assert!(plan.notes[0].contains("nothing to run"), "{:?}", plan.notes);
1066        // A tag that is not a release version is not believed.
1067        latest(&mut fake, "v1.0.4; rm -rf /");
1068        assert_eq!(only_this(&fake, Install::Package, true).pending, None);
1069    }
1070
1071    #[test]
1072    fn package_files_follow_each_family() {
1073        assert_eq!(
1074            package_asset(PackageManager::Debian, "1.2.3").as_deref(),
1075            Some("helixforge_1.2.3-1_amd64.deb")
1076        );
1077        assert_eq!(
1078            package_asset(PackageManager::Suse, "1.2.3").as_deref(),
1079            Some("helixforge-1.2.3-1.x86_64.rpm")
1080        );
1081        assert_eq!(package_asset(PackageManager::Alpine, "1.2.3"), None);
1082        assert_eq!(package_asset(PackageManager::Arch, "1.2.3/../x"), None);
1083    }
1084
1085    #[test]
1086    fn cargo_launcher_source_and_unknown_copies_each_say_how_they_update() {
1087        let mut fake = machine();
1088        fake.tools.push("cargo".into());
1089        latest(&mut fake, "v2.0.0");
1090        let cargo = only_this(&fake, Install::Cargo, true);
1091        assert_eq!(cargo.command_line(), "cargo install --locked helixforge");
1092
1093        let launcher = only_this(&fake, Install::Launcher, true);
1094        assert_eq!(launcher.summary(), "up to date");
1095        assert!(launcher.commands.is_empty());
1096        assert!(launcher.note.contains("each time it starts"));
1097
1098        let other = only_this(&fake, Install::Other, true);
1099        assert_eq!(other.pending, None);
1100        assert!(other.note.contains("cannot update"), "{}", other.note);
1101
1102        let root = PathBuf::from("/home/me/Helix-Forge");
1103        fake.commands.insert(
1104            "git -C /home/me/Helix-Forge rev-parse HEAD".into(),
1105            (true, "aaaaaaaaaaaaaaaaaaaa\n".into()),
1106        );
1107        fake.commands.insert(
1108            "git -C /home/me/Helix-Forge ls-remote origin refs/heads/main".into(),
1109            (true, "bbbbbbbbbbbbbbbbbbbb\trefs/heads/main\n".into()),
1110        );
1111        let source = only_this(&fake, Install::Source(root), true);
1112        assert_eq!(source.summary(), "1 update");
1113        assert_eq!(
1114            source.command_line(),
1115            "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"
1116        );
1117    }
1118
1119    #[test]
1120    fn a_machine_without_the_tools_has_no_channels() {
1121        let fake = Fake::default();
1122        assert!(overview(&fake, &arch(), &places(), false).is_empty());
1123    }
1124
1125    #[test]
1126    fn the_parsers_read_each_tool() {
1127        let apt = apt("Listing...\nbash/stable 5.2.15-2+b3 amd64 [upgradable from: 5.2.15-2+b2]\n");
1128        assert_eq!(apt[0].name, "bash");
1129        assert_eq!(apt[0].from.as_deref(), Some("5.2.15-2+b2"));
1130        assert_eq!(apt[0].to.as_deref(), Some("5.2.15-2+b3"));
1131        let zypper = zypper(
1132            "S | Repository | Name | Current Version | Available Version | Arch\n--+---\nv | Main | vim | 9.0-1 | 9.1-1 | x86_64\n",
1133        );
1134        assert_eq!(zypper.len(), 1);
1135        assert_eq!(zypper[0].to.as_deref(), Some("9.1-1"));
1136        let dnf = dnf("Available Upgrades\ngit.x86_64 2.47.0-1.fc41 updates\n");
1137        assert_eq!(dnf[0].name, "git");
1138        assert_eq!(date(0), "1970-01-01");
1139        assert_eq!(date(1_792_022_400), "2026-10-15");
1140    }
1141
1142    #[test]
1143    fn the_plan_runs_each_tool_in_order_and_the_aur_after_pacman() {
1144        let channels = overview(&machine(), &arch(), &places(), false);
1145        let chosen: Vec<&Channel> = channels.iter().collect();
1146        let plan = plan(&chosen);
1147        let ids: Vec<&str> = plan.steps.iter().map(|step| step.id()).collect();
1148        assert_eq!(
1149            ids,
1150            [
1151                "update-pacman",
1152                "update-aur",
1153                "update-flatpak-system",
1154                "update-home-manager"
1155            ]
1156        );
1157        assert_eq!(plan.depends[1], [0]);
1158        assert!(plan.privileged);
1159        assert_eq!(plan.risk, Risk::High);
1160        assert!(
1161            plan.notes
1162                .iter()
1163                .any(|note| note.contains("partial upgrades"))
1164        );
1165        assert_eq!(plan.steps[0].display(), "sudo pacman -Syu");
1166        assert_eq!(plan.steps[1].display(), "paru -Sua");
1167    }
1168}