use std::path::{Path, PathBuf};
use helixforge_core::{PackageManager, printable};
use helixforge_platform::LinuxInfo;
use crate::doctor::System;
use crate::{Plan, Risk, Source, Step};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Pending {
pub name: String,
pub from: Option<String>,
pub to: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Channel {
pub id: String,
pub name: String,
pub pending: Option<Vec<Pending>>,
pub note: String,
pub commands: Vec<Vec<String>>,
pub requires: Vec<String>,
pub caveat: Option<String>,
}
impl Channel {
pub fn count(&self) -> Option<usize> {
self.pending.as_ref().map(Vec::len)
}
pub fn summary(&self) -> String {
match self.count() {
Some(0) => "up to date".to_string(),
Some(1) => "1 update".to_string(),
Some(count) => format!("{count} updates"),
None => "not counted".to_string(),
}
}
pub fn command_line(&self) -> String {
printable(
&self
.commands
.iter()
.map(|command| command.join(" "))
.collect::<Vec<_>>()
.join(" && "),
)
}
}
pub struct Places {
pub config: PathBuf,
pub home: PathBuf,
pub now: u64,
pub this: Option<ThisCopy>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ThisCopy {
pub version: String,
pub exe: PathBuf,
pub install: Install,
pub cache: PathBuf,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Install {
Package,
Cargo,
Source(PathBuf),
Launcher,
Download,
Other,
}
pub struct Homes {
pub cargo: PathBuf,
pub data: PathBuf,
pub temp: PathBuf,
}
pub fn install_of(exe: &Path, homes: &Homes, checkout: &dyn Fn(&Path) -> bool) -> Install {
if exe.starts_with("/usr/bin") || exe.starts_with("/usr/sbin") {
Install::Package
} else if exe.starts_with(homes.cargo.join("bin")) {
Install::Cargo
} else if exe.starts_with(homes.data.join("helixforge")) {
Install::Launcher
} else if let Some(root) = exe
.ancestors()
.find(|dir| dir.file_name().is_some_and(|name| name == "target"))
.and_then(Path::parent)
.filter(|root| checkout(root))
{
Install::Source(root.to_path_buf())
} else if exe.starts_with(&homes.temp) {
Install::Download
} else {
Install::Other
}
}
impl Places {
pub fn here() -> Self {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"));
let config = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| home.join(".config"));
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or(0);
Places {
config,
home,
now,
this: None,
}
}
pub fn with_this(mut self, version: &str, exe: PathBuf) -> Self {
let variable = |name: &str| {
std::env::var_os(name)
.map(PathBuf::from)
.filter(|path| path.is_absolute())
};
let homes = Homes {
cargo: variable("CARGO_HOME").unwrap_or_else(|| self.home.join(".cargo")),
data: variable("XDG_DATA_HOME").unwrap_or_else(|| self.home.join(".local/share")),
temp: std::env::temp_dir(),
};
let checkout =
|root: &Path| root.join("Cargo.toml").is_file() && root.join(".git").exists();
let cache = variable("XDG_CACHE_HOME").unwrap_or_else(|| self.home.join(".cache"));
self.this = Some(ThisCopy {
version: version.to_string(),
install: install_of(&exe, &homes, &checkout),
exe,
cache: cache.join("helixforge/release"),
});
self
}
}
pub const RELEASES: &str = "https://github.com/Commanderx-code/Helix-Forge";
const LATEST: &str = "https://api.github.com/repos/Commanderx-code/Helix-Forge/releases/latest";
fn release(version: &str) -> Option<(u64, u64, u64)> {
let mut parts = version.trim_start_matches('v').split('.');
let part = |part: Option<&str>| part?.parse::<u64>().ok();
let found = (
part(parts.next())?,
part(parts.next())?,
part(parts.next())?,
);
parts.next().is_none().then_some(found)
}
pub fn package_asset(manager: PackageManager, version: &str) -> Option<String> {
release(version)?;
match manager {
PackageManager::Arch => Some(format!("helixforge-{version}-1-x86_64.pkg.tar.zst")),
PackageManager::Debian => Some(format!("helixforge_{version}-1_amd64.deb")),
PackageManager::Fedora | PackageManager::Suse => {
Some(format!("helixforge-{version}-1.x86_64.rpm"))
}
_ => None,
}
}
fn this_channel(
system: &dyn System,
platform: &LinuxInfo,
this: &ThisCopy,
online: bool,
) -> Channel {
let mut channel = Channel {
id: "helixforge".into(),
name: "HelixForge".into(),
pending: None,
note: String::new(),
commands: Vec::new(),
requires: Vec::new(),
caveat: None,
};
let latest = || -> Option<String> {
let text = read(
system,
&[
"curl",
"-fsSL",
"--max-time",
"15",
"--proto",
"=https",
LATEST,
],
)?;
let json: serde_json::Value = serde_json::from_str(&text).ok()?;
let tag = json["tag_name"].as_str()?;
release(tag)?;
Some(tag.trim_start_matches('v').to_string())
};
let behind = |latest: &str| {
let pending = match (release(&this.version), release(latest)) {
(Some(current), Some(newest)) if newest > current => vec![Pending {
name: "helixforge".into(),
from: Some(this.version.clone()),
to: Some(latest.to_string()),
}],
_ => Vec::new(),
};
Some(pending)
};
let exe = this.exe.to_string_lossy().into_owned();
match &this.install {
Install::Launcher | Install::Download => {
channel.pending = Some(Vec::new());
channel.note = format!(
"{}; it fetches the latest release each time it starts",
this.version
);
}
Install::Other => {
channel.note = format!(
"{} at {}, installed in a way HelixForge cannot update; update it the way you installed it",
this.version,
this.exe.display()
);
}
Install::Package | Install::Cargo if !online => {
channel.note = format!("{}; r checks GitHub for a newer release", this.version);
}
Install::Package | Install::Cargo => match latest() {
None => {
channel.note = format!(
"{}; GitHub could not be asked for the latest release",
this.version
);
}
Some(newest) => {
channel.pending = behind(&newest);
channel.note = format!("{}, the latest release is {newest}", this.version);
let waiting = channel.count().unwrap_or(0) > 0;
if this.install == Install::Cargo {
if waiting && system.on_path("cargo") {
channel.commands =
vec![words(&["cargo", "install", "--locked", "helixforge"])];
channel.requires = strings(&["cargo"]);
}
} else if waiting
&& let Some(manager) = platform.manager
&& let Some(asset) = package_asset(manager, &newest)
{
let cache = this.cache.to_string_lossy().into_owned();
let file = this.cache.join(&asset).to_string_lossy().into_owned();
let install: &[&str] = match manager {
PackageManager::Arch => &["sudo", "pacman", "-U"],
PackageManager::Debian => &["sudo", "apt-get", "install"],
PackageManager::Fedora => &["sudo", "dnf", "install"],
_ => &["sudo", "zypper", "install", "--allow-unsigned-rpm"],
};
let mut install = words(install);
install.push(file);
channel.commands = vec![
vec![exe, "fetch-release".into(), newest.clone(), asset, cache],
install,
];
channel.requires = strings(&["curl", "sha256sum", "sudo"]);
channel.caveat = Some(format!(
"downloads the {newest} package from {RELEASES}/releases and checks it against the release's SHA256SUMS before installing it"
));
}
}
},
Install::Source(root) => {
let git = |args: &[&str]| {
let directory = root.to_string_lossy();
let mut command = vec!["git", "-C", directory.as_ref()];
command.extend(args);
read(system, &command)
};
let head = git(&["rev-parse", "HEAD"]).map(|text| text.trim().to_string());
let short = |hash: &str| hash.chars().take(12).collect::<String>();
channel.note = format!(
"a checkout at {}, {}",
root.display(),
head.as_deref()
.map(short)
.unwrap_or_else(|| "unknown".into())
);
if online {
let remote = git(&["ls-remote", "origin", "refs/heads/main"])
.and_then(|text| text.split_whitespace().next().map(String::from));
if let (Some(head), Some(remote)) = (&head, remote) {
channel.pending = Some(if *head == remote {
Vec::new()
} else {
vec![Pending {
name: "main".into(),
from: Some(short(head)),
to: Some(short(&remote)),
}]
});
}
} else {
channel.note.push_str("; r compares it with GitHub");
}
let directory = root.to_string_lossy().into_owned();
let manifest = root.join("Cargo.toml").to_string_lossy().into_owned();
channel.commands = vec![
words(&["git", "-C", &directory, "diff", "--quiet", "HEAD"]),
words(&["git", "-C", &directory, "pull", "--ff-only"]),
words(&[
"cargo",
"build",
"--release",
"--locked",
"--manifest-path",
&manifest,
"-p",
"helixforge",
]),
];
channel.requires = strings(&["git", "cargo"]);
channel.caveat = Some(
"fast-forwards the checkout and builds it; a checkout with local changes or other commits is left alone".into(),
);
}
}
channel
}
fn words(command: &[&str]) -> Vec<String> {
command.iter().map(|word| word.to_string()).collect()
}
fn strings(tools: &[&str]) -> Vec<String> {
words(tools)
}
fn read(system: &dyn System, command: &[&str]) -> Option<String> {
system.run(command).map(|(_, text)| text)
}
pub fn overview(
system: &dyn System,
platform: &LinuxInfo,
places: &Places,
online: bool,
) -> Vec<Channel> {
let mut channels = Vec::new();
match platform.manager {
Some(PackageManager::Arch) if system.on_path("pacman") => {
let (pending, note) = if online && system.on_path("checkupdates") {
(
read(system, &["checkupdates"]).map(|text| arrows(&text)),
"checked online with checkupdates",
)
} else {
(
read(system, &["pacman", "-Qu"]).map(|text| arrows(&text)),
"as of pacman's last sync",
)
};
channels.push(Channel {
id: "pacman".into(),
name: "Arch packages".into(),
pending,
note: note.into(),
commands: vec![words(&["sudo", "pacman", "-Syu"])],
requires: strings(&["pacman", "sudo"]),
caveat: Some(
"updates the whole system: Arch does not support partial upgrades".into(),
),
});
if let Some(helper) = ["paru", "yay"]
.into_iter()
.find(|tool| system.on_path(tool))
{
let pending = online
.then(|| read(system, &[helper, "-Qua"]).map(|text| arrows(&text)))
.flatten();
channels.push(Channel {
id: "aur".into(),
name: format!("AUR ({helper})"),
note: if online {
format!("checked online with {helper} -Qua")
} else {
"the AUR is only asked in an online check".into()
},
pending,
commands: vec![words(&[helper, "-Sua"])],
requires: strings(&[helper]),
caveat: Some(
"builds AUR packages from their PKGBUILDs; read them when asked".into(),
),
});
}
}
Some(PackageManager::Debian) if system.on_path("apt") => {
channels.push(Channel {
id: "apt".into(),
name: "Debian packages".into(),
pending: read(system, &["apt", "list", "--upgradable"]).map(|text| apt(&text)),
note: "as of the last apt update, which the update runs first".into(),
commands: vec![
words(&["sudo", "apt-get", "update"]),
words(&["sudo", "apt-get", "upgrade"]),
],
requires: strings(&["apt-get", "sudo"]),
caveat: None,
});
}
Some(PackageManager::Fedora) if system.on_path("dnf") => {
let command: &[&str] = if online {
&["dnf", "-q", "list", "--upgrades"]
} else {
&["dnf", "-C", "-q", "list", "--upgrades"]
};
channels.push(Channel {
id: "dnf".into(),
name: "Fedora packages".into(),
pending: read(system, command).map(|text| dnf(&text)),
note: if online {
"checked online with dnf".into()
} else {
"from dnf's cached metadata".into()
},
commands: vec![words(&["sudo", "dnf", "upgrade"])],
requires: strings(&["dnf", "sudo"]),
caveat: None,
});
}
Some(PackageManager::Suse) if system.on_path("zypper") => {
let rolling = platform.id.contains("tumbleweed");
channels.push(Channel {
id: "zypper".into(),
name: "openSUSE packages".into(),
pending: read(system, &["zypper", "-q", "--no-refresh", "list-updates"])
.map(|text| zypper(&text)),
note: "from zypper's cached repositories".into(),
commands: vec![words(if rolling {
&["sudo", "zypper", "dist-upgrade"]
} else {
&["sudo", "zypper", "update"]
})],
requires: strings(&["zypper", "sudo"]),
caveat: None,
});
}
_ => {}
}
if system.on_path("flatpak") {
for (installation, id, name) in [
("--system", "flatpak-system", "Flatpak (system)"),
("--user", "flatpak-user", "Flatpak (user)"),
] {
let installed = read(
system,
&["flatpak", "list", installation, "--columns=application"],
)
.unwrap_or_default();
if installed.trim().is_empty() {
continue;
}
let mut command = vec![
"flatpak",
"remote-ls",
"--updates",
installation,
"--columns=application,version",
];
if !online {
command.push("--cached");
}
let system_wide = installation == "--system";
channels.push(Channel {
id: id.into(),
name: name.into(),
pending: read(system, &command).map(|text| columns(&text)),
note: if online {
"checked online with flatpak remote-ls".into()
} else {
"from Flatpak's cached remote metadata".into()
},
commands: vec![if system_wide {
words(&["sudo", "flatpak", "update", "--system"])
} else {
words(&["flatpak", "update", "--user"])
}],
requires: if system_wide {
strings(&["flatpak", "sudo"])
} else {
strings(&["flatpak"])
},
caveat: None,
});
}
}
if let Some(channel) = home_manager(system, places) {
channels.push(channel);
}
if system.exists(Path::new("/etc/NIXOS")) {
let flake = Path::new("/etc/nixos/flake.nix");
let (commands, caveat) = if system.exists(flake) {
(
vec![
words(&["sudo", "nix", "flake", "update", "--flake", "/etc/nixos"]),
words(&["sudo", "nixos-rebuild", "switch", "--flake", "/etc/nixos"]),
],
Some("updates /etc/nixos/flake.lock".to_string()),
)
} else {
(
vec![words(&["sudo", "nixos-rebuild", "switch", "--upgrade"])],
None,
)
};
channels.push(Channel {
id: "nixos".into(),
name: "NixOS".into(),
pending: None,
note: lock_age(system, Path::new("/etc/nixos/flake.lock"), places.now)
.unwrap_or_else(|| "NixOS counts nothing before it rebuilds".into()),
commands,
requires: strings(&["nixos-rebuild", "sudo"]),
caveat,
});
}
let manifest = places.home.join(".nix-profile/manifest.json");
if system.on_path("nix")
&& let Some(text) = system.read(&manifest)
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(&text)
{
let count = match &json["elements"] {
serde_json::Value::Object(map) => map.len(),
serde_json::Value::Array(list) => list.len(),
_ => 0,
};
if count > 0 {
channels.push(Channel {
id: "nix-profile".into(),
name: "Nix profile".into(),
pending: None,
note: format!(
"{count} package{} installed with nix profile",
if count == 1 { "" } else { "s" }
),
commands: vec![words(&["nix", "profile", "upgrade", "--all"])],
requires: strings(&["nix"]),
caveat: None,
});
}
}
if let Some(this) = &places.this {
channels.push(this_channel(system, platform, this, online));
}
channels.into_iter().map(cleaned).collect()
}
fn cleaned(mut channel: Channel) -> Channel {
channel.name = printable(&channel.name);
channel.note = printable(&channel.note);
channel.caveat = channel.caveat.as_deref().map(printable);
for pending in channel.pending.iter_mut().flatten() {
pending.name = printable(&pending.name);
pending.from = pending.from.as_deref().map(printable);
pending.to = pending.to.as_deref().map(printable);
}
channel
}
fn home_manager(system: &dyn System, places: &Places) -> Option<Channel> {
if !system.on_path("home-manager") {
return None;
}
let directory = places.config.join("home-manager");
if !system.exists(&directory.join("flake.nix")) {
return None;
}
let shown = system
.canonical(&directory)
.unwrap_or_else(|| directory.clone());
let flake = directory.to_string_lossy().into_owned();
Some(Channel {
id: "home-manager".into(),
name: "Home Manager".into(),
pending: None,
note: lock_age(system, &directory.join("flake.lock"), places.now)
.unwrap_or_else(|| "its flake inputs are not locked yet".into()),
commands: vec![
words(&["nix", "flake", "update", "--flake", &flake]),
words(&["home-manager", "switch", "--flake", &flake]),
],
requires: strings(&["nix", "home-manager"]),
caveat: Some(format!(
"updates {}; commit it there if you keep it in Git",
shown.join("flake.lock").display()
)),
})
}
fn lock_age(system: &dyn System, lock: &Path, now: u64) -> Option<String> {
let json: serde_json::Value = serde_json::from_str(&system.read(lock)?).ok()?;
let nodes = json["nodes"].as_object()?;
let when = |node: &serde_json::Value| node["locked"]["lastModified"].as_u64();
let (name, time) = match nodes.get("nixpkgs").and_then(when) {
Some(time) => ("nixpkgs", time),
None => nodes
.iter()
.filter_map(|(name, node)| Some((name.as_str(), when(node)?)))
.min_by_key(|(_, time)| *time)?,
};
let days = now.saturating_sub(time) / 86_400;
Some(format!(
"{name} locked at {}, {}",
date(time),
match days {
0 => "today".to_string(),
1 => "1 day ago".to_string(),
days => format!("{days} days ago"),
}
))
}
pub fn date(seconds: u64) -> String {
let days = (seconds / 86_400) as i64 + 719_468;
let era = days.div_euclid(146_097);
let day_of_era = days.rem_euclid(146_097);
let year_of_era =
(day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_index = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_index + 2) / 5 + 1;
let month = if month_index < 10 {
month_index + 3
} else {
month_index - 9
};
let year = year_of_era + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}")
}
pub fn arrows(text: &str) -> Vec<Pending> {
text.lines()
.filter(|line| !line.contains("[ignored]"))
.filter_map(|line| {
let mut words = line.split_whitespace();
let name = words.next()?;
let from = words.next()?;
(words.next()? == "->").then_some(())?;
Some(Pending {
name: name.to_string(),
from: Some(from.to_string()),
to: Some(words.next()?.to_string()),
})
})
.collect()
}
pub fn apt(text: &str) -> Vec<Pending> {
text.lines()
.filter_map(|line| {
let (head, from) = line.split_once("[upgradable from:")?;
let mut words = head.split_whitespace();
let name = words.next()?.split('/').next()?;
Some(Pending {
name: name.to_string(),
from: Some(from.trim().trim_end_matches(']').trim().to_string()),
to: words.next().map(String::from),
})
})
.collect()
}
pub fn dnf(text: &str) -> Vec<Pending> {
let mut found: Vec<Pending> = crate::inventory::parse_dnf_upgrades(text)
.into_iter()
.map(|(name, to)| Pending {
name,
from: None,
to: Some(to),
})
.collect();
found.sort_by(|a, b| a.name.cmp(&b.name));
found
}
pub fn zypper(text: &str) -> Vec<Pending> {
text.lines()
.filter_map(|line| {
let cells: Vec<&str> = line.split('|').map(str::trim).collect();
(cells.len() >= 5 && cells[0] == "v").then(|| Pending {
name: cells[2].to_string(),
from: Some(cells[3].to_string()),
to: Some(cells[4].to_string()),
})
})
.collect()
}
pub fn columns(text: &str) -> Vec<Pending> {
text.lines()
.filter_map(|line| {
let mut cells = line.split('\t').map(str::trim);
let name = cells.next().filter(|name| !name.is_empty())?;
Some(Pending {
name: name.to_string(),
from: None,
to: cells
.next()
.filter(|version| !version.is_empty())
.map(String::from),
})
})
.collect()
}
pub fn plan(channels: &[&Channel]) -> Plan {
let mut plan = Plan::default();
for channel in channels {
if channel.commands.is_empty() {
plan.notes.push(format!(
"{}: nothing to run ({})",
channel.name, channel.note
));
continue;
}
plan.privileged |= channel.requires.iter().any(|tool| tool == "sudo");
plan.risk = plan.risk.max(match channel.id.as_str() {
"pacman" | "apt" | "dnf" | "zypper" | "nixos" => Risk::High,
_ => Risk::Medium,
});
plan.requirements.extend(channel.requires.iter().cloned());
if let Some(caveat) = &channel.caveat {
plan.notes.push(format!("{}: {caveat}", channel.name));
}
plan.steps.push(Step::Update {
id: format!("update-{}", channel.id),
name: format!("Update {}", channel.name),
source: Source {
label: channel.name.clone(),
commands: channel.commands.clone(),
requires: channel.requires.clone(),
probe: None,
},
targets: Vec::new(),
});
}
let native = plan.steps.iter().position(|step| {
matches!(
step.id(),
"update-pacman" | "update-apt" | "update-dnf" | "update-zypper"
)
});
plan.depends = plan
.steps
.iter()
.map(|step| match native {
Some(index) if step.id() == "update-aur" => vec![index],
_ => Vec::new(),
})
.collect();
plan.requirements.sort();
plan.requirements.dedup();
plan
}
#[cfg(test)]
mod tests {
use super::*;
use crate::doctor::Fake;
fn arch() -> LinuxInfo {
LinuxInfo::from_os_release("ID=garuda\nID_LIKE=arch\n").unwrap()
}
fn places() -> Places {
Places {
config: PathBuf::from("/home/me/.config"),
home: PathBuf::from("/home/me"),
now: 1_792_022_400,
this: None,
}
}
fn machine() -> Fake {
let mut fake = Fake {
tools: [
"pacman",
"checkupdates",
"paru",
"flatpak",
"home-manager",
"nix",
]
.map(String::from)
.to_vec(),
..Fake::default()
};
fake.commands.insert(
"pacman -Qu".into(),
(
true,
"ripgrep 15.2.0-1 -> 15.3.0-1\nlinux 6.1-1 -> 6.2-1 [ignored]\n".into(),
),
);
fake.commands.insert(
"checkupdates".into(),
(
true,
"ripgrep 15.2.0-1 -> 15.3.0-1\nmesa 1-1 -> 2-1\n".into(),
),
);
fake.commands
.insert("paru -Qua".into(), (false, String::new()));
fake.commands.insert(
"flatpak list --system --columns=application".into(),
(true, "org.zapzap.ZapZap\n".into()),
);
fake.commands.insert(
"flatpak list --user --columns=application".into(),
(true, String::new()),
);
fake.commands.insert(
"flatpak remote-ls --updates --system --columns=application,version --cached".into(),
(true, "org.zapzap.ZapZap\t7.6\n".into()),
);
fake.files.insert(
PathBuf::from("/home/me/.config/home-manager/flake.nix"),
"{}".into(),
);
fake.files.insert(
PathBuf::from("/home/me/.config/home-manager/flake.lock"),
r#"{"nodes":{"nixpkgs":{"locked":{"lastModified":1791331200}},"systems":{"locked":{"lastModified":1681000000}},"root":{}}}"#.into(),
);
fake.canonical.insert(
PathBuf::from("/home/me/.config/home-manager"),
PathBuf::from("/home/me/dotfiles/home-manager"),
);
fake
}
#[test]
fn offline_the_overview_reads_what_the_tools_know() {
let channels = overview(&machine(), &arch(), &places(), false);
let ids: Vec<&str> = channels.iter().map(|channel| channel.id.as_str()).collect();
assert_eq!(ids, ["pacman", "aur", "flatpak-system", "home-manager"]);
assert_eq!(
channels[0].summary(),
"1 update",
"ignored packages are left out"
);
assert_eq!(
channels[0].pending.as_ref().unwrap()[0],
Pending {
name: "ripgrep".into(),
from: Some("15.2.0-1".into()),
to: Some("15.3.0-1".into()),
}
);
assert_eq!(channels[1].pending, None, "the AUR only online");
assert_eq!(channels[2].summary(), "1 update");
let home = &channels[3];
assert_eq!(home.note, "nixpkgs locked at 2026-10-07, 8 days ago");
assert_eq!(
home.command_line(),
"nix flake update --flake /home/me/.config/home-manager && home-manager switch --flake /home/me/.config/home-manager"
);
assert!(
home.caveat
.as_deref()
.unwrap()
.contains("/home/me/dotfiles/home-manager/flake.lock")
);
}
#[test]
fn online_the_overview_asks_read_only_commands() {
let channels = overview(&machine(), &arch(), &places(), true);
assert_eq!(channels[0].summary(), "2 updates");
assert!(channels[0].note.contains("checkupdates"));
assert_eq!(channels[1].summary(), "up to date");
assert_eq!(channels[2].pending, None);
}
#[test]
fn control_characters_from_the_tools_are_not_shown() {
let mut fake = machine();
fake.commands.insert(
"pacman -Qu".into(),
(true, "evil\x1b]0;x\x07 1 -> 2\x1b[2J\n".into()),
);
fake.files.insert(
PathBuf::from("/home/me/.config/home-manager/flake.lock"),
r#"{"nodes":{"bad\u001b[31m":{"locked":{"lastModified":1791331200}},"root":{}}}"#
.into(),
);
let channels = overview(&fake, &arch(), &places(), false);
let shown = format!("{channels:?}");
assert!(
!shown.contains('\x1b') && !shown.contains('\x07'),
"{shown}"
);
let pending = &channels[0].pending.as_ref().unwrap()[0];
assert_eq!(pending.name, "evil?]0;x?");
assert_eq!(pending.to.as_deref(), Some("2?[2J"));
assert!(channels[3].note.starts_with("bad?[31m locked at"));
let mut odd = channels[3].clone();
odd.commands = vec![vec!["nix".into(), "/home/\x1b[2Jme".into()]];
let plan = plan(&[&odd]);
assert_eq!(plan.steps[0].display(), "nix /home/?[2Jme");
}
fn this(install: Install) -> ThisCopy {
ThisCopy {
version: "1.0.3".into(),
exe: PathBuf::from("/usr/bin/helixforge"),
install,
cache: PathBuf::from("/home/me/.cache/helixforge/release"),
}
}
fn latest(fake: &mut Fake, tag: &str) {
fake.commands.insert(
format!("curl -fsSL --max-time 15 --proto =https {LATEST}"),
(true, format!(r#"{{"tag_name": "{tag}", "name": "x"}}"#)),
);
}
fn only_this(fake: &Fake, install: Install, online: bool) -> Channel {
let mut places = places();
places.this = Some(this(install));
overview(fake, &arch(), &places, online)
.into_iter()
.find(|channel| channel.id == "helixforge")
.unwrap()
}
#[test]
fn where_it_runs_from_says_how_it_was_installed() {
let homes = Homes {
cargo: PathBuf::from("/home/me/.cargo"),
data: PathBuf::from("/home/me/.local/share"),
temp: PathBuf::from("/tmp"),
};
let checkout = |root: &Path| root == Path::new("/home/me/Helix-Forge");
let of = |path: &str| install_of(Path::new(path), &homes, &checkout);
assert_eq!(of("/usr/bin/helixforge"), Install::Package);
assert_eq!(of("/home/me/.cargo/bin/helixforge"), Install::Cargo);
assert_eq!(
of("/home/me/.local/share/helixforge/helixforge-linux-x86_64"),
Install::Launcher
);
assert_eq!(
of("/home/me/Helix-Forge/target/release/helixforge"),
Install::Source(PathBuf::from("/home/me/Helix-Forge"))
);
assert_eq!(
of("/tmp/tmp.abc/helixforge-linux-x86_64"),
Install::Download
);
assert_eq!(
of("/tmp/other/target/release/helixforge"),
Install::Download
);
assert_eq!(of("/opt/hf/helixforge"), Install::Other);
}
#[test]
fn a_package_install_downloads_checks_and_installs_the_newer_package() {
let mut fake = machine();
latest(&mut fake, "v1.0.4");
let offline = only_this(&fake, Install::Package, false);
assert_eq!(offline.pending, None);
assert!(offline.note.contains("r checks GitHub"), "{}", offline.note);
let channel = only_this(&fake, Install::Package, true);
assert_eq!(channel.summary(), "1 update");
assert_eq!(channel.note, "1.0.3, the latest release is 1.0.4");
assert_eq!(
channel.command_line(),
"/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"
);
latest(&mut fake, "v1.0.3");
let current = only_this(&fake, Install::Package, true);
assert_eq!(current.summary(), "up to date");
assert!(current.commands.is_empty());
let plan = plan(&[¤t]);
assert!(plan.steps.is_empty());
assert!(plan.notes[0].contains("nothing to run"), "{:?}", plan.notes);
latest(&mut fake, "v1.0.4; rm -rf /");
assert_eq!(only_this(&fake, Install::Package, true).pending, None);
}
#[test]
fn package_files_follow_each_family() {
assert_eq!(
package_asset(PackageManager::Debian, "1.2.3").as_deref(),
Some("helixforge_1.2.3-1_amd64.deb")
);
assert_eq!(
package_asset(PackageManager::Suse, "1.2.3").as_deref(),
Some("helixforge-1.2.3-1.x86_64.rpm")
);
assert_eq!(package_asset(PackageManager::Alpine, "1.2.3"), None);
assert_eq!(package_asset(PackageManager::Arch, "1.2.3/../x"), None);
}
#[test]
fn cargo_launcher_source_and_unknown_copies_each_say_how_they_update() {
let mut fake = machine();
fake.tools.push("cargo".into());
latest(&mut fake, "v2.0.0");
let cargo = only_this(&fake, Install::Cargo, true);
assert_eq!(cargo.command_line(), "cargo install --locked helixforge");
let launcher = only_this(&fake, Install::Launcher, true);
assert_eq!(launcher.summary(), "up to date");
assert!(launcher.commands.is_empty());
assert!(launcher.note.contains("each time it starts"));
let other = only_this(&fake, Install::Other, true);
assert_eq!(other.pending, None);
assert!(other.note.contains("cannot update"), "{}", other.note);
let root = PathBuf::from("/home/me/Helix-Forge");
fake.commands.insert(
"git -C /home/me/Helix-Forge rev-parse HEAD".into(),
(true, "aaaaaaaaaaaaaaaaaaaa\n".into()),
);
fake.commands.insert(
"git -C /home/me/Helix-Forge ls-remote origin refs/heads/main".into(),
(true, "bbbbbbbbbbbbbbbbbbbb\trefs/heads/main\n".into()),
);
let source = only_this(&fake, Install::Source(root), true);
assert_eq!(source.summary(), "1 update");
assert_eq!(
source.command_line(),
"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"
);
}
#[test]
fn a_machine_without_the_tools_has_no_channels() {
let fake = Fake::default();
assert!(overview(&fake, &arch(), &places(), false).is_empty());
}
#[test]
fn the_parsers_read_each_tool() {
let apt = apt("Listing...\nbash/stable 5.2.15-2+b3 amd64 [upgradable from: 5.2.15-2+b2]\n");
assert_eq!(apt[0].name, "bash");
assert_eq!(apt[0].from.as_deref(), Some("5.2.15-2+b2"));
assert_eq!(apt[0].to.as_deref(), Some("5.2.15-2+b3"));
let zypper = zypper(
"S | Repository | Name | Current Version | Available Version | Arch\n--+---\nv | Main | vim | 9.0-1 | 9.1-1 | x86_64\n",
);
assert_eq!(zypper.len(), 1);
assert_eq!(zypper[0].to.as_deref(), Some("9.1-1"));
let dnf = dnf("Available Upgrades\ngit.x86_64 2.47.0-1.fc41 updates\n");
assert_eq!(dnf[0].name, "git");
assert_eq!(date(0), "1970-01-01");
assert_eq!(date(1_792_022_400), "2026-10-15");
}
#[test]
fn the_plan_runs_each_tool_in_order_and_the_aur_after_pacman() {
let channels = overview(&machine(), &arch(), &places(), false);
let chosen: Vec<&Channel> = channels.iter().collect();
let plan = plan(&chosen);
let ids: Vec<&str> = plan.steps.iter().map(|step| step.id()).collect();
assert_eq!(
ids,
[
"update-pacman",
"update-aur",
"update-flatpak-system",
"update-home-manager"
]
);
assert_eq!(plan.depends[1], [0]);
assert!(plan.privileged);
assert_eq!(plan.risk, Risk::High);
assert!(
plan.notes
.iter()
.any(|note| note.contains("partial upgrades"))
);
assert_eq!(plan.steps[0].display(), "sudo pacman -Syu");
assert_eq!(plan.steps[1].display(), "paru -Sua");
}
}