1use std::path::{Path, PathBuf};
7
8use helixforge_core::{PackageManager, printable};
9use helixforge_platform::LinuxInfo;
10
11use crate::doctor::System;
12use crate::{Plan, Risk, Source, Step};
13
14#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct Pending {
17 pub name: String,
18 pub from: Option<String>,
19 pub to: Option<String>,
20}
21
22#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct Channel {
25 pub id: String,
28 pub name: String,
29 pub pending: Option<Vec<Pending>>,
32 pub note: String,
34 pub commands: Vec<Vec<String>>,
36 pub requires: Vec<String>,
37 pub caveat: Option<String>,
39}
40
41impl Channel {
42 pub fn count(&self) -> Option<usize> {
44 self.pending.as_ref().map(Vec::len)
45 }
46
47 pub fn summary(&self) -> String {
49 match self.count() {
50 Some(0) => "up to date".to_string(),
51 Some(1) => "1 update".to_string(),
52 Some(count) => format!("{count} updates"),
53 None => "not counted".to_string(),
54 }
55 }
56
57 pub fn command_line(&self) -> String {
59 printable(
60 &self
61 .commands
62 .iter()
63 .map(|command| command.join(" "))
64 .collect::<Vec<_>>()
65 .join(" && "),
66 )
67 }
68}
69
70pub struct Places {
72 pub config: PathBuf,
74 pub home: PathBuf,
75 pub now: u64,
77}
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
104fn read(system: &dyn System, command: &[&str]) -> Option<String> {
108 system.run(command).map(|(_, text)| text)
109}
110
111pub 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 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 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
341fn 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 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
373fn 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
397pub fn date(seconds: u64) -> String {
399 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
417pub 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
435pub 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
451pub 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
465pub 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
479pub 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
497pub 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 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 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 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 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 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}