use std::io::IsTerminal;
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::{env, fs, sync::mpsc, thread};
use anyhow::{bail, Context, Result};
use crate::prompt::confirm;
const REPO: &str = "lararosekelley/navi";
const REPO_URL: &str = "https://github.com/lararosekelley/navi";
const MIN_DOWNGRADE_VERSION: &str = "0.1.5";
const UPDATE_CHECK_FILE: &str = "update-check";
const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
pub(crate) fn config_dir() -> Option<PathBuf> {
let base = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("LOCALAPPDATA").map(PathBuf::from))
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
Some(base.join("navi"))
}
pub fn upgrade(head: bool, force: bool, no_restart: bool) -> Result<()> {
if head {
return upgrade_to_head();
}
let installed = env!("CARGO_PKG_VERSION");
let latest = if force {
None
} else {
latest_release_version()
};
if let Some(installed_version) = parse_version(installed) {
if !upgrade_needed(installed_version, latest, force) {
println!(
"navi {installed} is already the latest release; nothing to upgrade (use --force to reinstall)"
);
return Ok(());
}
}
println!("upgrading navi to the latest release");
run_installer(None)?;
println!("upgraded (re-open your shell if the version looks stale)");
crate::service::restart_after_upgrade(!no_restart)?;
Ok(())
}
fn upgrade_to_head() -> Result<()> {
println!("--head builds and installs the latest unreleased commit from {REPO_URL}");
println!("HEAD is a pre-release snapshot: it may be broken or untested");
if !confirm("continue? [y/N] ")? {
println!("upgrade cancelled");
return Ok(());
}
let status = Command::new("cargo")
.args(["install", "--git", REPO_URL, "--locked", "navi-notifier"])
.status()
.context("failed to run cargo; --head requires a Rust toolchain")?;
if !status.success() {
bail!("cargo install exited with status {status}");
}
println!("installed navi from HEAD; to return to a release, run: navi upgrade");
Ok(())
}
pub fn downgrade(to: Option<String>, yes: bool, no_restart: bool) -> Result<()> {
let installed = env!("CARGO_PKG_VERSION");
let installed_version = parse_version(installed)
.with_context(|| format!("could not parse the installed version {installed}"))?;
let floor = parse_version(MIN_DOWNGRADE_VERSION).expect("floor is a valid version");
if installed_version <= floor {
println!("navi {installed} is the earliest release `downgrade` can reach; nothing older");
return Ok(());
}
let requested = match &to {
Some(to) => Some(parse_version(to).with_context(|| format!("not a version: {to}"))?),
None => None,
};
let available = match requested {
Some(_) => Vec::new(),
None => remote_release_versions()?,
};
let target = version_string(resolve_target(
installed_version,
floor,
requested,
&available,
)?);
println!("downgrade navi {installed} -> {target}");
println!("a release older than {installed} may not understand state a newer one wrote");
if !yes && !confirm("continue? [y/N] ")? {
println!("downgrade cancelled");
return Ok(());
}
run_installer(Some(&target))?;
println!("downgraded to {target}; to move forward again, run: navi upgrade");
crate::service::restart_after_upgrade(!no_restart)?;
Ok(())
}
fn run_installer(version: Option<&str>) -> Result<()> {
let base = match version {
Some(v) => format!("{REPO_URL}/releases/download/v{v}/navi-notifier-installer"),
None => format!("{REPO_URL}/releases/latest/download/navi-notifier-installer"),
};
let status = if cfg!(windows) {
Command::new("powershell")
.args([
"-ExecutionPolicy",
"Bypass",
"-c",
&windows_install_script(&base),
])
.status()
.context("failed to run the PowerShell installer")?
} else {
Command::new("sh")
.arg("-c")
.arg(unix_install_script(&base))
.status()
.context("failed to run the installer; is curl available?")?
};
if !status.success() {
bail!("installer exited with status {status}");
}
Ok(())
}
fn unix_install_script(base: &str) -> String {
format!(
"t=$(mktemp) || exit 1; \
curl --proto '=https' --tlsv1.2 -fLsS \"{base}.sh\" -o \"$t\" && sh \"$t\"; \
rc=$?; rm -f \"$t\"; exit $rc"
)
}
fn windows_install_script(base: &str) -> String {
format!("$ErrorActionPreference = 'Stop'; irm {base}.ps1 | iex")
}
pub fn maybe_hint_update() {
if !std::io::stderr().is_terminal() {
return;
}
if env::var("NAVI_NO_UPDATE_CHECK").is_ok_and(|v| !v.is_empty() && v != "0") {
return;
}
let Some(path) = config_dir().map(|d| d.join(UPDATE_CHECK_FILE)) else {
return;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|e| e.as_secs())
.unwrap_or(0);
if !should_check(fs::read_to_string(&path).ok().as_deref(), now) {
return;
}
let installed = parse_version(env!("CARGO_PKG_VERSION"));
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let behind = installed
.zip(latest_release_version())
.is_some_and(|(installed, latest)| latest > installed);
let _ = sender.send(behind);
});
if let Ok(behind) = receiver.recv_timeout(Duration::from_secs(5)) {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::write(&path, format!("checked={now}\n"));
if behind {
eprintln!("a newer navi release is available; run `navi upgrade`");
}
}
}
fn should_check(cache: Option<&str>, now: u64) -> bool {
let Some(cache) = cache else {
return true;
};
cache
.lines()
.find_map(|line| line.strip_prefix("checked="))
.and_then(|value| value.trim().parse::<u64>().ok())
.is_none_or(|checked| now.saturating_sub(checked) >= CHECK_INTERVAL_SECS)
}
type Version3 = (u64, u64, u64);
fn resolve_target(
installed: Version3,
floor: Version3,
requested: Option<Version3>,
available: &[Version3],
) -> Result<Version3> {
match requested {
Some(target) => {
if target >= installed {
bail!(
"{} is not older than the installed {}; use `navi upgrade` to move forward",
version_string(target),
version_string(installed)
);
}
if target < floor {
bail!(
"{} is below {}, the earliest release `downgrade` can reach",
version_string(target),
version_string(floor)
);
}
Ok(target)
}
None => available
.iter()
.copied()
.filter(|v| *v < installed && *v >= floor)
.max()
.with_context(|| {
format!(
"no release between {} and {} to downgrade to",
version_string(floor),
version_string(installed)
)
}),
}
}
fn parse_version(text: &str) -> Option<Version3> {
let mut parts = text.trim().split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some((major, minor, patch))
}
fn version_string((major, minor, patch): Version3) -> String {
format!("{major}.{minor}.{patch}")
}
fn upgrade_needed(installed: Version3, latest: Option<Version3>, force: bool) -> bool {
force || latest.is_none_or(|latest| installed < latest)
}
fn latest_release_version() -> Option<Version3> {
let output = Command::new("curl")
.args([
"-sSL",
"-o",
"/dev/null",
"-w",
"%{url_effective}",
&format!("{REPO_URL}/releases/latest"),
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
version_from_release_url(&String::from_utf8_lossy(&output.stdout))
}
fn version_from_release_url(url: &str) -> Option<Version3> {
let tag = url.trim().trim_end_matches('/').rsplit('/').next()?;
parse_version(tag.strip_prefix('v').unwrap_or(tag))
}
fn remote_release_versions() -> Result<Vec<Version3>> {
let output = Command::new("git")
.args(["ls-remote", "--tags", REPO_URL])
.output()
.context("failed to list releases; check your network connection")?;
if !output.status.success() {
bail!("failed to fetch the release list from {REPO}");
}
let text = String::from_utf8_lossy(&output.stdout);
Ok(text
.lines()
.filter_map(|line| line.split("refs/tags/").nth(1))
.filter(|tag| !tag.ends_with("^{}"))
.filter_map(|tag| parse_version(tag.strip_prefix('v').unwrap_or(tag)))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_from_release_url_parses_the_redirect_target() {
assert_eq!(
version_from_release_url("https://github.com/lararosekelley/navi/releases/tag/v0.2.1"),
Some((0, 2, 1))
);
assert_eq!(
version_from_release_url("https://github.com/x/y/releases/tag/v1.10.3/"),
Some((1, 10, 3))
);
assert_eq!(
version_from_release_url("https://github.com/x/y/releases"),
None
);
}
#[test]
fn unix_install_script_hard_fails_on_download_error() {
let s = unix_install_script("https://ex/navi-notifier-installer");
assert!(s.contains("-fLsS"), "{s}");
assert!(s.contains("mktemp"), "{s}");
assert!(s.contains("-o \"$t\" && sh \"$t\""), "{s}");
assert!(s.contains("rm -f \"$t\""), "cleanup: {s}");
assert!(
!s.contains(".sh | sh"),
"must not pipe curl straight into sh: {s}"
);
}
#[test]
fn windows_install_script_stops_on_error() {
assert!(windows_install_script("https://ex/x").contains("ErrorActionPreference = 'Stop'"));
}
#[test]
fn parse_version_accepts_plain_xyz_only() {
assert_eq!(parse_version("0.1.4"), Some((0, 1, 4)));
assert_eq!(parse_version("10.0.3"), Some((10, 0, 3)));
assert_eq!(parse_version("0.1"), None);
assert_eq!(parse_version("0.1.4.1"), None);
assert_eq!(parse_version("v0.1.4"), None);
}
#[test]
fn resolve_target_requires_explicit_to_be_older() {
let (installed, floor) = ((0, 2, 0), (0, 1, 4));
assert!(resolve_target(installed, floor, Some((0, 2, 0)), &[]).is_err());
assert!(resolve_target(installed, floor, Some((0, 2, 1)), &[]).is_err());
}
#[test]
fn resolve_target_refuses_below_the_floor() {
let (installed, floor) = ((0, 2, 0), (0, 1, 4));
assert!(resolve_target(installed, floor, Some((0, 1, 3)), &[]).is_err());
assert_eq!(
resolve_target(installed, floor, Some((0, 1, 4)), &[]).unwrap(),
(0, 1, 4)
);
}
#[test]
fn resolve_target_default_picks_previous_release() {
let (installed, floor) = ((0, 2, 0), (0, 1, 4));
let available = [(0, 1, 4), (0, 1, 5), (0, 2, 0)];
assert_eq!(
resolve_target(installed, floor, None, &available).unwrap(),
(0, 1, 5)
);
}
#[test]
fn upgrade_needed_skips_only_when_confirmed_current() {
assert!(!upgrade_needed((0, 1, 9), Some((0, 1, 9)), false));
assert!(upgrade_needed((0, 1, 8), Some((0, 1, 9)), false));
assert!(!upgrade_needed((0, 2, 0), Some((0, 1, 9)), false));
assert!(upgrade_needed((0, 1, 9), Some((0, 1, 9)), true));
assert!(upgrade_needed((0, 1, 9), None, false));
}
#[test]
fn should_check_missing_or_garbled() {
assert!(should_check(None, 1_000_000));
assert!(should_check(Some("checked=nope\n"), 1_000_000));
}
#[test]
fn should_check_once_per_day() {
let stamp = format!("checked={}\n", 1_000_000u64);
assert!(!should_check(Some(&stamp), 1_000_000 + 60));
assert!(should_check(Some(&stamp), 1_000_000 + CHECK_INTERVAL_SECS));
}
}