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}
78
79impl Places {
80    pub fn here() -> Self {
81        let home = std::env::var_os("HOME")
82            .map(PathBuf::from)
83            .unwrap_or_else(|| PathBuf::from("/"));
84        let config = std::env::var_os("XDG_CONFIG_HOME")
85            .map(PathBuf::from)
86            .filter(|path| path.is_absolute())
87            .unwrap_or_else(|| home.join(".config"));
88        let now = std::time::SystemTime::now()
89            .duration_since(std::time::UNIX_EPOCH)
90            .map(|elapsed| elapsed.as_secs())
91            .unwrap_or(0);
92        Places { config, home, now }
93    }
94}
95
96fn words(command: &[&str]) -> Vec<String> {
97    command.iter().map(|word| word.to_string()).collect()
98}
99
100fn strings(tools: &[&str]) -> Vec<String> {
101    words(tools)
102}
103
104/// The standard output of a command, whatever its exit status: pacman -Qu
105/// and checkupdates exit non-zero when nothing waits. None when it cannot
106/// run.
107fn read(system: &dyn System, command: &[&str]) -> Option<String> {
108    system.run(command).map(|(_, text)| text)
109}
110
111/// Every channel this machine has. `online` also asks the network, through
112/// read-only commands (checkupdates, the AUR helper, Flatpak's remotes).
113pub fn overview(
114    system: &dyn System,
115    platform: &LinuxInfo,
116    places: &Places,
117    online: bool,
118) -> Vec<Channel> {
119    let mut channels = Vec::new();
120    match platform.manager {
121        Some(PackageManager::Arch) if system.on_path("pacman") => {
122            let (pending, note) = if online && system.on_path("checkupdates") {
123                (
124                    read(system, &["checkupdates"]).map(|text| arrows(&text)),
125                    "checked online with checkupdates",
126                )
127            } else {
128                (
129                    read(system, &["pacman", "-Qu"]).map(|text| arrows(&text)),
130                    "as of pacman's last sync",
131                )
132            };
133            channels.push(Channel {
134                id: "pacman".into(),
135                name: "Arch packages".into(),
136                pending,
137                note: note.into(),
138                commands: vec![words(&["sudo", "pacman", "-Syu"])],
139                requires: strings(&["pacman", "sudo"]),
140                caveat: Some(
141                    "updates the whole system: Arch does not support partial upgrades".into(),
142                ),
143            });
144            if let Some(helper) = ["paru", "yay"]
145                .into_iter()
146                .find(|tool| system.on_path(tool))
147            {
148                let pending = online
149                    .then(|| read(system, &[helper, "-Qua"]).map(|text| arrows(&text)))
150                    .flatten();
151                channels.push(Channel {
152                    id: "aur".into(),
153                    name: format!("AUR ({helper})"),
154                    note: if online {
155                        format!("checked online with {helper} -Qua")
156                    } else {
157                        "the AUR is only asked in an online check".into()
158                    },
159                    pending,
160                    commands: vec![words(&[helper, "-Sua"])],
161                    requires: strings(&[helper]),
162                    caveat: Some(
163                        "builds AUR packages from their PKGBUILDs; read them when asked".into(),
164                    ),
165                });
166            }
167        }
168        Some(PackageManager::Debian) if system.on_path("apt") => {
169            channels.push(Channel {
170                id: "apt".into(),
171                name: "Debian packages".into(),
172                pending: read(system, &["apt", "list", "--upgradable"]).map(|text| apt(&text)),
173                note: "as of the last apt update, which the update runs first".into(),
174                commands: vec![
175                    words(&["sudo", "apt-get", "update"]),
176                    words(&["sudo", "apt-get", "upgrade"]),
177                ],
178                requires: strings(&["apt-get", "sudo"]),
179                caveat: None,
180            });
181        }
182        Some(PackageManager::Fedora) if system.on_path("dnf") => {
183            let command: &[&str] = if online {
184                &["dnf", "-q", "list", "--upgrades"]
185            } else {
186                &["dnf", "-C", "-q", "list", "--upgrades"]
187            };
188            channels.push(Channel {
189                id: "dnf".into(),
190                name: "Fedora packages".into(),
191                pending: read(system, command).map(|text| dnf(&text)),
192                note: if online {
193                    "checked online with dnf".into()
194                } else {
195                    "from dnf's cached metadata".into()
196                },
197                commands: vec![words(&["sudo", "dnf", "upgrade"])],
198                requires: strings(&["dnf", "sudo"]),
199                caveat: None,
200            });
201        }
202        Some(PackageManager::Suse) if system.on_path("zypper") => {
203            // Tumbleweed moves as a whole, through a distribution upgrade.
204            let rolling = platform.id.contains("tumbleweed");
205            channels.push(Channel {
206                id: "zypper".into(),
207                name: "openSUSE packages".into(),
208                pending: read(system, &["zypper", "-q", "--no-refresh", "list-updates"])
209                    .map(|text| zypper(&text)),
210                note: "from zypper's cached repositories".into(),
211                commands: vec![words(if rolling {
212                    &["sudo", "zypper", "dist-upgrade"]
213                } else {
214                    &["sudo", "zypper", "update"]
215                })],
216                requires: strings(&["zypper", "sudo"]),
217                caveat: None,
218            });
219        }
220        _ => {}
221    }
222    if system.on_path("flatpak") {
223        for (installation, id, name) in [
224            ("--system", "flatpak-system", "Flatpak (system)"),
225            ("--user", "flatpak-user", "Flatpak (user)"),
226        ] {
227            let installed = read(
228                system,
229                &["flatpak", "list", installation, "--columns=application"],
230            )
231            .unwrap_or_default();
232            if installed.trim().is_empty() {
233                continue;
234            }
235            let mut command = vec![
236                "flatpak",
237                "remote-ls",
238                "--updates",
239                installation,
240                "--columns=application,version",
241            ];
242            if !online {
243                command.push("--cached");
244            }
245            let system_wide = installation == "--system";
246            channels.push(Channel {
247                id: id.into(),
248                name: name.into(),
249                pending: read(system, &command).map(|text| columns(&text)),
250                note: if online {
251                    "checked online with flatpak remote-ls".into()
252                } else {
253                    "from Flatpak's cached remote metadata".into()
254                },
255                commands: vec![if system_wide {
256                    words(&["sudo", "flatpak", "update", "--system"])
257                } else {
258                    words(&["flatpak", "update", "--user"])
259                }],
260                requires: if system_wide {
261                    strings(&["flatpak", "sudo"])
262                } else {
263                    strings(&["flatpak"])
264                },
265                caveat: None,
266            });
267        }
268    }
269    if let Some(channel) = home_manager(system, places) {
270        channels.push(channel);
271    }
272    if system.exists(Path::new("/etc/NIXOS")) {
273        let flake = Path::new("/etc/nixos/flake.nix");
274        let (commands, caveat) = if system.exists(flake) {
275            (
276                vec![
277                    words(&["sudo", "nix", "flake", "update", "--flake", "/etc/nixos"]),
278                    words(&["sudo", "nixos-rebuild", "switch", "--flake", "/etc/nixos"]),
279                ],
280                Some("updates /etc/nixos/flake.lock".to_string()),
281            )
282        } else {
283            (
284                vec![words(&["sudo", "nixos-rebuild", "switch", "--upgrade"])],
285                None,
286            )
287        };
288        channels.push(Channel {
289            id: "nixos".into(),
290            name: "NixOS".into(),
291            pending: None,
292            note: lock_age(system, Path::new("/etc/nixos/flake.lock"), places.now)
293                .unwrap_or_else(|| "NixOS counts nothing before it rebuilds".into()),
294            commands,
295            requires: strings(&["nixos-rebuild", "sudo"]),
296            caveat,
297        });
298    }
299    let manifest = places.home.join(".nix-profile/manifest.json");
300    if system.on_path("nix")
301        && let Some(text) = system.read(&manifest)
302        && let Ok(json) = serde_json::from_str::<serde_json::Value>(&text)
303    {
304        let count = match &json["elements"] {
305            serde_json::Value::Object(map) => map.len(),
306            serde_json::Value::Array(list) => list.len(),
307            _ => 0,
308        };
309        if count > 0 {
310            channels.push(Channel {
311                id: "nix-profile".into(),
312                name: "Nix profile".into(),
313                pending: None,
314                note: format!(
315                    "{count} package{} installed with nix profile",
316                    if count == 1 { "" } else { "s" }
317                ),
318                commands: vec![words(&["nix", "profile", "upgrade", "--all"])],
319                requires: strings(&["nix"]),
320                caveat: None,
321            });
322        }
323    }
324    // Package names, versions, flake input names and paths come from outside
325    // HelixForge: keep terminal control characters out of what is shown.
326    channels.into_iter().map(cleaned).collect()
327}
328
329fn cleaned(mut channel: Channel) -> Channel {
330    channel.name = printable(&channel.name);
331    channel.note = printable(&channel.note);
332    channel.caveat = channel.caveat.as_deref().map(printable);
333    for pending in channel.pending.iter_mut().flatten() {
334        pending.name = printable(&pending.name);
335        pending.from = pending.from.as_deref().map(printable);
336        pending.to = pending.to.as_deref().map(printable);
337    }
338    channel
339}
340
341/// Home Manager with a flake in its configuration directory.
342fn home_manager(system: &dyn System, places: &Places) -> Option<Channel> {
343    if !system.on_path("home-manager") {
344        return None;
345    }
346    let directory = places.config.join("home-manager");
347    if !system.exists(&directory.join("flake.nix")) {
348        return None;
349    }
350    // A link to a dotfiles checkout is where the lock file really changes.
351    let shown = system
352        .canonical(&directory)
353        .unwrap_or_else(|| directory.clone());
354    let flake = directory.to_string_lossy().into_owned();
355    Some(Channel {
356        id: "home-manager".into(),
357        name: "Home Manager".into(),
358        pending: None,
359        note: lock_age(system, &directory.join("flake.lock"), places.now)
360            .unwrap_or_else(|| "its flake inputs are not locked yet".into()),
361        commands: vec![
362            words(&["nix", "flake", "update", "--flake", &flake]),
363            words(&["home-manager", "switch", "--flake", &flake]),
364        ],
365        requires: strings(&["nix", "home-manager"]),
366        caveat: Some(format!(
367            "updates {}; commit it there if you keep it in Git",
368            shown.join("flake.lock").display()
369        )),
370    })
371}
372
373/// How old the locked nixpkgs (or else the oldest input) is.
374fn lock_age(system: &dyn System, lock: &Path, now: u64) -> Option<String> {
375    let json: serde_json::Value = serde_json::from_str(&system.read(lock)?).ok()?;
376    let nodes = json["nodes"].as_object()?;
377    let when = |node: &serde_json::Value| node["locked"]["lastModified"].as_u64();
378    let (name, time) = match nodes.get("nixpkgs").and_then(when) {
379        Some(time) => ("nixpkgs", time),
380        None => nodes
381            .iter()
382            .filter_map(|(name, node)| Some((name.as_str(), when(node)?)))
383            .min_by_key(|(_, time)| *time)?,
384    };
385    let days = now.saturating_sub(time) / 86_400;
386    Some(format!(
387        "{name} locked at {}, {}",
388        date(time),
389        match days {
390            0 => "today".to_string(),
391            1 => "1 day ago".to_string(),
392            days => format!("{days} days ago"),
393        }
394    ))
395}
396
397/// A UTC date, YYYY-MM-DD.
398pub fn date(seconds: u64) -> String {
399    // Days to a civil date (Howard Hinnant's algorithm).
400    let days = (seconds / 86_400) as i64 + 719_468;
401    let era = days.div_euclid(146_097);
402    let day_of_era = days.rem_euclid(146_097);
403    let year_of_era =
404        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
405    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
406    let month_index = (5 * day_of_year + 2) / 153;
407    let day = day_of_year - (153 * month_index + 2) / 5 + 1;
408    let month = if month_index < 10 {
409        month_index + 3
410    } else {
411        month_index - 9
412    };
413    let year = year_of_era + era * 400 + i64::from(month <= 2);
414    format!("{year:04}-{month:02}-{day:02}")
415}
416
417/// `name old -> new` lines: pacman -Qu, checkupdates, paru and yay -Qua.
418pub fn arrows(text: &str) -> Vec<Pending> {
419    text.lines()
420        .filter(|line| !line.contains("[ignored]"))
421        .filter_map(|line| {
422            let mut words = line.split_whitespace();
423            let name = words.next()?;
424            let from = words.next()?;
425            (words.next()? == "->").then_some(())?;
426            Some(Pending {
427                name: name.to_string(),
428                from: Some(from.to_string()),
429                to: Some(words.next()?.to_string()),
430            })
431        })
432        .collect()
433}
434
435/// `name/suite new arch [upgradable from: old]` lines from apt list.
436pub fn apt(text: &str) -> Vec<Pending> {
437    text.lines()
438        .filter_map(|line| {
439            let (head, from) = line.split_once("[upgradable from:")?;
440            let mut words = head.split_whitespace();
441            let name = words.next()?.split('/').next()?;
442            Some(Pending {
443                name: name.to_string(),
444                from: Some(from.trim().trim_end_matches(']').trim().to_string()),
445                to: words.next().map(String::from),
446            })
447        })
448        .collect()
449}
450
451/// `name.arch version repository` lines from dnf list --upgrades.
452pub fn dnf(text: &str) -> Vec<Pending> {
453    let mut found: Vec<Pending> = crate::inventory::parse_dnf_upgrades(text)
454        .into_iter()
455        .map(|(name, to)| Pending {
456            name,
457            from: None,
458            to: Some(to),
459        })
460        .collect();
461    found.sort_by(|a, b| a.name.cmp(&b.name));
462    found
463}
464
465/// `v | repository | name | current | available | arch` rows from zypper.
466pub fn zypper(text: &str) -> Vec<Pending> {
467    text.lines()
468        .filter_map(|line| {
469            let cells: Vec<&str> = line.split('|').map(str::trim).collect();
470            (cells.len() >= 5 && cells[0] == "v").then(|| Pending {
471                name: cells[2].to_string(),
472                from: Some(cells[3].to_string()),
473                to: Some(cells[4].to_string()),
474            })
475        })
476        .collect()
477}
478
479/// Tab-separated `application version` columns from flatpak remote-ls.
480pub fn columns(text: &str) -> Vec<Pending> {
481    text.lines()
482        .filter_map(|line| {
483            let mut cells = line.split('\t').map(str::trim);
484            let name = cells.next().filter(|name| !name.is_empty())?;
485            Some(Pending {
486                name: name.to_string(),
487                from: None,
488                to: cells
489                    .next()
490                    .filter(|version| !version.is_empty())
491                    .map(String::from),
492            })
493        })
494        .collect()
495}
496
497/// A plan updating the channels in order, each through its own tool, one
498/// after another; a later channel waits for the native packages.
499pub fn plan(channels: &[&Channel]) -> Plan {
500    let mut plan = Plan::default();
501    for channel in channels {
502        plan.privileged |= channel.requires.iter().any(|tool| tool == "sudo");
503        plan.risk = plan.risk.max(match channel.id.as_str() {
504            "pacman" | "apt" | "dnf" | "zypper" | "nixos" => Risk::High,
505            _ => Risk::Medium,
506        });
507        plan.requirements.extend(channel.requires.iter().cloned());
508        if let Some(caveat) = &channel.caveat {
509            plan.notes.push(format!("{}: {caveat}", channel.name));
510        }
511        plan.steps.push(Step::Update {
512            id: format!("update-{}", channel.id),
513            name: format!("Update {}", channel.name),
514            source: Source {
515                label: channel.name.clone(),
516                commands: channel.commands.clone(),
517                requires: channel.requires.clone(),
518                probe: None,
519            },
520            targets: Vec::new(),
521        });
522    }
523    // The AUR builds against the repositories' packages: only after them.
524    let native = plan.steps.iter().position(|step| {
525        matches!(
526            step.id(),
527            "update-pacman" | "update-apt" | "update-dnf" | "update-zypper"
528        )
529    });
530    plan.depends = plan
531        .steps
532        .iter()
533        .map(|step| match native {
534            Some(index) if step.id() == "update-aur" => vec![index],
535            _ => Vec::new(),
536        })
537        .collect();
538    plan.requirements.sort();
539    plan.requirements.dedup();
540    plan
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use crate::doctor::Fake;
547
548    fn arch() -> LinuxInfo {
549        LinuxInfo::from_os_release("ID=garuda\nID_LIKE=arch\n").unwrap()
550    }
551
552    fn places() -> Places {
553        Places {
554            config: PathBuf::from("/home/me/.config"),
555            home: PathBuf::from("/home/me"),
556            // 2026-10-15
557            now: 1_792_022_400,
558        }
559    }
560
561    fn machine() -> Fake {
562        let mut fake = Fake {
563            tools: [
564                "pacman",
565                "checkupdates",
566                "paru",
567                "flatpak",
568                "home-manager",
569                "nix",
570            ]
571            .map(String::from)
572            .to_vec(),
573            ..Fake::default()
574        };
575        fake.commands.insert(
576            "pacman -Qu".into(),
577            (
578                true,
579                "ripgrep 15.2.0-1 -> 15.3.0-1\nlinux 6.1-1 -> 6.2-1 [ignored]\n".into(),
580            ),
581        );
582        fake.commands.insert(
583            "checkupdates".into(),
584            (
585                true,
586                "ripgrep 15.2.0-1 -> 15.3.0-1\nmesa 1-1 -> 2-1\n".into(),
587            ),
588        );
589        // paru exits 1 when nothing waits.
590        fake.commands
591            .insert("paru -Qua".into(), (false, String::new()));
592        fake.commands.insert(
593            "flatpak list --system --columns=application".into(),
594            (true, "org.zapzap.ZapZap\n".into()),
595        );
596        fake.commands.insert(
597            "flatpak list --user --columns=application".into(),
598            (true, String::new()),
599        );
600        fake.commands.insert(
601            "flatpak remote-ls --updates --system --columns=application,version --cached".into(),
602            (true, "org.zapzap.ZapZap\t7.6\n".into()),
603        );
604        fake.files.insert(
605            PathBuf::from("/home/me/.config/home-manager/flake.nix"),
606            "{}".into(),
607        );
608        fake.files.insert(
609            PathBuf::from("/home/me/.config/home-manager/flake.lock"),
610            r#"{"nodes":{"nixpkgs":{"locked":{"lastModified":1791331200}},"systems":{"locked":{"lastModified":1681000000}},"root":{}}}"#.into(),
611        );
612        fake.canonical.insert(
613            PathBuf::from("/home/me/.config/home-manager"),
614            PathBuf::from("/home/me/dotfiles/home-manager"),
615        );
616        fake
617    }
618
619    #[test]
620    fn offline_the_overview_reads_what_the_tools_know() {
621        let channels = overview(&machine(), &arch(), &places(), false);
622        let ids: Vec<&str> = channels.iter().map(|channel| channel.id.as_str()).collect();
623        assert_eq!(ids, ["pacman", "aur", "flatpak-system", "home-manager"]);
624        assert_eq!(
625            channels[0].summary(),
626            "1 update",
627            "ignored packages are left out"
628        );
629        assert_eq!(
630            channels[0].pending.as_ref().unwrap()[0],
631            Pending {
632                name: "ripgrep".into(),
633                from: Some("15.2.0-1".into()),
634                to: Some("15.3.0-1".into()),
635            }
636        );
637        assert_eq!(channels[1].pending, None, "the AUR only online");
638        assert_eq!(channels[2].summary(), "1 update");
639        let home = &channels[3];
640        assert_eq!(home.note, "nixpkgs locked at 2026-10-07, 8 days ago");
641        assert_eq!(
642            home.command_line(),
643            "nix flake update --flake /home/me/.config/home-manager && home-manager switch --flake /home/me/.config/home-manager"
644        );
645        assert!(
646            home.caveat
647                .as_deref()
648                .unwrap()
649                .contains("/home/me/dotfiles/home-manager/flake.lock")
650        );
651    }
652
653    #[test]
654    fn online_the_overview_asks_read_only_commands() {
655        let channels = overview(&machine(), &arch(), &places(), true);
656        assert_eq!(channels[0].summary(), "2 updates");
657        assert!(channels[0].note.contains("checkupdates"));
658        assert_eq!(channels[1].summary(), "up to date");
659        // The online Flatpak command was not given, so it is unknown.
660        assert_eq!(channels[2].pending, None);
661    }
662
663    #[test]
664    fn control_characters_from_the_tools_are_not_shown() {
665        let mut fake = machine();
666        fake.commands.insert(
667            "pacman -Qu".into(),
668            (true, "evil\x1b]0;x\x07 1 -> 2\x1b[2J\n".into()),
669        );
670        fake.files.insert(
671            PathBuf::from("/home/me/.config/home-manager/flake.lock"),
672            r#"{"nodes":{"bad\u001b[31m":{"locked":{"lastModified":1791331200}},"root":{}}}"#
673                .into(),
674        );
675        let channels = overview(&fake, &arch(), &places(), false);
676        let shown = format!("{channels:?}");
677        assert!(
678            !shown.contains('\x1b') && !shown.contains('\x07'),
679            "{shown}"
680        );
681        let pending = &channels[0].pending.as_ref().unwrap()[0];
682        assert_eq!(pending.name, "evil?]0;x?");
683        assert_eq!(pending.to.as_deref(), Some("2?[2J"));
684        assert!(channels[3].note.starts_with("bad?[31m locked at"));
685        // The plan's commands are shown cleaned, though they run as given.
686        let mut odd = channels[3].clone();
687        odd.commands = vec![vec!["nix".into(), "/home/\x1b[2Jme".into()]];
688        let plan = plan(&[&odd]);
689        assert_eq!(plan.steps[0].display(), "nix /home/?[2Jme");
690    }
691
692    #[test]
693    fn a_machine_without_the_tools_has_no_channels() {
694        let fake = Fake::default();
695        assert!(overview(&fake, &arch(), &places(), false).is_empty());
696    }
697
698    #[test]
699    fn the_parsers_read_each_tool() {
700        let apt = apt("Listing...\nbash/stable 5.2.15-2+b3 amd64 [upgradable from: 5.2.15-2+b2]\n");
701        assert_eq!(apt[0].name, "bash");
702        assert_eq!(apt[0].from.as_deref(), Some("5.2.15-2+b2"));
703        assert_eq!(apt[0].to.as_deref(), Some("5.2.15-2+b3"));
704        let zypper = zypper(
705            "S | Repository | Name | Current Version | Available Version | Arch\n--+---\nv | Main | vim | 9.0-1 | 9.1-1 | x86_64\n",
706        );
707        assert_eq!(zypper.len(), 1);
708        assert_eq!(zypper[0].to.as_deref(), Some("9.1-1"));
709        let dnf = dnf("Available Upgrades\ngit.x86_64 2.47.0-1.fc41 updates\n");
710        assert_eq!(dnf[0].name, "git");
711        assert_eq!(date(0), "1970-01-01");
712        assert_eq!(date(1_792_022_400), "2026-10-15");
713    }
714
715    #[test]
716    fn the_plan_runs_each_tool_in_order_and_the_aur_after_pacman() {
717        let channels = overview(&machine(), &arch(), &places(), false);
718        let chosen: Vec<&Channel> = channels.iter().collect();
719        let plan = plan(&chosen);
720        let ids: Vec<&str> = plan.steps.iter().map(|step| step.id()).collect();
721        assert_eq!(
722            ids,
723            [
724                "update-pacman",
725                "update-aur",
726                "update-flatpak-system",
727                "update-home-manager"
728            ]
729        );
730        assert_eq!(plan.depends[1], [0]);
731        assert!(plan.privileged);
732        assert_eq!(plan.risk, Risk::High);
733        assert!(
734            plan.notes
735                .iter()
736                .any(|note| note.contains("partial upgrades"))
737        );
738        assert_eq!(plan.steps[0].display(), "sudo pacman -Syu");
739        assert_eq!(plan.steps[1].display(), "paru -Sua");
740    }
741}