use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use crate::store;
const NO_CHECK: &str = "LORE_NO_UPDATE_CHECK";
const CACHE: &str = ".lore-latest";
const FRESH: Duration = Duration::from_secs(24 * 60 * 60);
const REPOSITORY: &str = "https://github.com/alpcakin/lore";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version {
major: u32,
minor: u32,
patch: u32,
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.label())
}
}
impl Version {
pub fn parse(text: &str) -> Option<Self> {
let mut numbers = text.trim().trim_start_matches('v').split('.');
let mut next = || numbers.next()?.parse::<u32>().ok();
let version = Self {
major: next()?,
minor: next()?,
patch: next()?,
};
numbers.next().is_none().then_some(version)
}
pub fn this_build() -> Self {
Self::parse(env!("CARGO_PKG_VERSION")).expect("the crate's own version should parse")
}
fn worth_announcing_over(&self, current: &Self) -> bool {
(self.major, self.minor) > (current.major, current.minor)
}
fn label(&self) -> String {
format!("{}.{}.{}", self.major, self.minor, self.patch)
}
}
pub fn status() -> String {
let current = Version::this_build();
let exe = env::current_exe().ok();
let latest = check().ok().flatten();
report(¤t, latest, exe.as_deref())
}
fn report(current: &Version, latest: Option<Version>, exe: Option<&Path>) -> String {
let mut lines = vec![format!("lore {current}")];
match latest {
None => {
lines.push("Could not check for a newer release".to_string());
lines.push(format!("Releases are listed at {REPOSITORY}/releases"));
}
Some(latest) if latest > *current => {
lines.push(format!("The newest release is {latest}"));
lines.push(match exe {
Some(exe) => upgrade_command(exe),
None => format!("Upgrade from {REPOSITORY}/releases"),
});
}
Some(_) => lines.push("This is the newest release".to_string()),
}
lines.join("\n")
}
pub fn notice() -> Option<String> {
if turned_off() {
return None;
}
let latest = Version::parse(&fs::read_to_string(cache().ok()?).ok()?)?;
advice(&Version::this_build(), &latest, &env::current_exe().ok()?)
}
fn advice(current: &Version, latest: &Version, exe: &Path) -> Option<String> {
if !latest.worth_announcing_over(current) {
return None;
}
Some(format!(
"lore {} is out. {}",
latest.label(),
upgrade_command(exe)
))
}
fn upgrade_command(exe: &Path) -> String {
let path = exe.to_string_lossy().replace('\\', "/");
if path.contains("/Cellar/") || path.contains("/homebrew/") || path.contains("/linuxbrew/") {
"Run: brew upgrade lore".to_string()
} else if path.contains("/scoop/") {
"Run: scoop update lore".to_string()
} else if path.contains("/.cargo/") {
"Run: cargo install cmdlore --force".to_string()
} else {
format!("Upgrade from {REPOSITORY}/releases")
}
}
fn turned_off() -> bool {
env::var_os(NO_CHECK).is_some_and(|value| !value.is_empty())
}
fn cache() -> anyhow::Result<PathBuf> {
Ok(store::sync_dir()?.with_file_name(CACHE))
}
pub fn refresh_in_background() {
if turned_off() {
return;
}
let Ok(cache) = cache() else {
return;
};
let fresh = fs::metadata(&cache)
.and_then(|meta| meta.modified())
.is_ok_and(|at| at.elapsed().is_ok_and(|age| age < FRESH));
if fresh {
return;
}
let Ok(exe) = env::current_exe() else {
return;
};
let mut command = Command::new(exe);
command
.args(["check-update", "--background"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const DETACHED_PROCESS: u32 = 0x0000_0008;
command.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS);
}
let _ = command.spawn();
}
pub fn check() -> anyhow::Result<Option<Version>> {
let cache = cache()?;
if let Some(parent) = cache.parent() {
fs::create_dir_all(parent)?;
}
let output = Command::new("git")
.args(["ls-remote", "--tags", "--refs", REPOSITORY, "v*"])
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::null())
.output()?;
if !output.status.success() {
return Ok(None);
}
let latest = newest(&String::from_utf8_lossy(&output.stdout));
fs::write(&cache, latest.unwrap_or_else(Version::this_build).label())?;
Ok(latest)
}
fn newest(refs: &str) -> Option<Version> {
refs.lines()
.filter_map(|line| line.rsplit("refs/tags/").next())
.filter_map(Version::parse)
.max()
}
#[cfg(test)]
mod tests {
use super::*;
fn version(text: &str) -> Version {
Version::parse(text).expect("should parse")
}
#[test]
fn versions_are_read_and_ordered_by_what_they_mean() {
assert_eq!(version("v0.2.2"), version("0.2.2"));
assert!(version("0.10.0") > version("0.9.9"));
assert!(version("1.0.0") > version("0.99.99"));
assert!(Version::parse("0.2").is_none());
assert!(Version::parse("0.3.0-rc1").is_none());
assert!(Version::parse("nightly").is_none());
}
#[test]
fn only_a_new_first_or_second_number_is_worth_saying() {
let current = version("0.2.2");
let exe = Path::new("/opt/homebrew/Cellar/lore/0.2.2/bin/lore");
assert!(advice(¤t, &version("0.2.3"), exe).is_none());
assert!(advice(¤t, &version("0.2.99"), exe).is_none());
assert!(advice(¤t, &version("0.2.2"), exe).is_none());
assert!(advice(¤t, &version("0.1.9"), exe).is_none());
assert!(advice(¤t, &version("0.3.0"), exe).is_some());
assert!(advice(¤t, &version("1.0.0"), exe).is_some());
}
#[test]
fn the_advice_names_the_version_and_the_right_command() {
let current = version("0.2.2");
let said = |exe: &str| advice(¤t, &version("0.3.0"), Path::new(exe)).unwrap();
assert!(said("/opt/homebrew/Cellar/lore/0.2.2/bin/lore").contains("lore 0.3.0 is out"));
assert!(said("/opt/homebrew/Cellar/lore/0.2.2/bin/lore").contains("brew upgrade lore"));
assert!(said("/home/alp/.cargo/bin/lore").contains("cargo install cmdlore --force"));
assert!(
said(r"C:\Users\alp\scoop\apps\lore\current\lore.exe").contains("scoop update lore")
);
assert!(said("/home/alp/.local/bin/lore").contains("releases"));
}
#[test]
fn the_version_command_says_where_you_stand() {
let current = version("0.2.2");
let exe = Path::new("/opt/homebrew/Cellar/lore/0.2.2/bin/lore");
let behind = report(¤t, Some(version("0.3.0")), Some(exe));
assert!(behind.starts_with("lore 0.2.2\n"), "{behind}");
assert!(behind.contains("newest release is 0.3.0"), "{behind}");
assert!(behind.contains("brew upgrade lore"), "{behind}");
let patch = report(¤t, Some(version("0.2.3")), Some(exe));
assert!(patch.contains("newest release is 0.2.3"), "{patch}");
assert!(patch.contains("brew upgrade lore"), "{patch}");
let current_release = report(¤t, Some(current), Some(exe));
assert!(
current_release.contains("This is the newest release"),
"{current_release}"
);
let offline = report(¤t, None, Some(exe));
assert!(offline.contains("Could not check"), "{offline}");
assert!(offline.contains("/releases"), "{offline}");
}
#[test]
fn the_newest_tag_wins_whatever_order_they_arrive_in() {
let refs = "a1\trefs/tags/v0.1.0\nb2\trefs/tags/v0.10.1\nc3\trefs/tags/v0.9.0\nd4\trefs/tags/broken\n";
assert_eq!(newest(refs), Version::parse("0.10.1"));
assert!(newest("").is_none());
}
}