use std::{collections::HashSet, process::Command};
use anyhow::{Context, Result, bail};
pub const REPOSITORY: &str = "https://github.com/VladasZ/smon";
pub const PACKAGE: &str = "smon";
pub const INSTALLED: &str = env!("CARGO_PKG_VERSION");
pub type Version = (u64, u64, u64);
pub struct Release {
pub tag: String,
pub version: Option<Version>,
}
pub fn installed_version() -> Option<Version> {
parse_version(INSTALLED)
}
pub fn format_version((major, minor, patch): Version) -> String {
format!("{major}.{minor}.{patch}")
}
pub fn parse_version(tag: &str) -> Option<Version> {
let tag = tag.strip_prefix('v').unwrap_or(tag);
if tag.contains('-') {
return None;
}
let mut parts = tag.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))
}
pub fn fetch_tags() -> Result<String> {
let output = Command::new("git")
.args(["ls-remote", "--tags", REPOSITORY])
.output()
.context("could not query the smon repository")?;
if !output.status.success() {
bail!("git ls-remote exited with {}", output.status);
}
String::from_utf8(output.stdout).context("git returned non-UTF-8 tags")
}
fn tag_names(ls_remote: &str) -> HashSet<String> {
ls_remote
.lines()
.filter_map(|line| {
let reference = line.split_whitespace().nth(1)?;
let tag = reference.strip_prefix("refs/tags/")?;
Some(tag.strip_suffix("^{}").unwrap_or(tag).to_string())
})
.collect()
}
pub fn newest(ls_remote: &str) -> Option<Release> {
tag_names(ls_remote)
.into_iter()
.filter_map(|tag| {
Some(Release {
version: Some(parse_version(&tag)?),
tag,
})
})
.max_by_key(|release| release.version)
}
pub fn exact(ls_remote: &str, wanted: &str) -> Option<Release> {
let tags = tag_names(ls_remote);
let prefixed = format!("v{wanted}");
[wanted, prefixed.as_str()]
.into_iter()
.find(|name| tags.contains(*name))
.map(|name| Release {
tag: name.to_string(),
version: parse_version(name),
})
}
#[cfg(test)]
mod tests {
use super::{exact, newest, parse_version};
const LS_REMOTE: &str = r"1111111111111111111111111111111111111111 refs/tags/v0.1.0
2222222222222222222222222222222222222222 refs/tags/v0.2.0
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/tags/v0.2.0^{}
3333333333333333333333333333333333333333 refs/tags/v0.2
4444444444444444444444444444444444444444 refs/tags/v0.3.0-rc.1
5555555555555555555555555555555555555555 refs/tags/v0.10.0
";
#[test]
fn versions_parse_with_and_without_the_v_prefix() {
assert_eq!(parse_version("v0.2.1"), Some((0, 2, 1)));
assert_eq!(parse_version("0.2.1"), Some((0, 2, 1)));
assert_eq!(parse_version("v10.20.30"), Some((10, 20, 30)));
}
#[test]
fn moving_tags_and_prereleases_are_not_update_targets() {
assert_eq!(parse_version("v0.2"), None);
assert_eq!(parse_version("v0.2.0-rc.1"), None);
assert_eq!(parse_version("v0.2.0.1"), None);
assert_eq!(parse_version("main"), None);
assert_eq!(parse_version("vX.Y.Z"), None);
}
#[test]
fn the_newest_release_wins_over_peeled_and_moving_tags() {
let newest = newest(LS_REMOTE).unwrap();
assert_eq!(newest.tag, "v0.10.0");
assert_eq!(newest.version, Some((0, 10, 0)));
}
#[test]
fn a_repository_without_releases_has_no_newest_release() {
let ls_remote = "3333333333333333333333333333333333333333 refs/tags/v0.2\n";
assert!(newest(ls_remote).is_none());
assert!(newest("").is_none());
}
#[test]
fn an_exact_tag_is_found_with_or_without_the_v() {
assert_eq!(exact(LS_REMOTE, "0.1.0").unwrap().tag, "v0.1.0");
assert_eq!(exact(LS_REMOTE, "v0.1.0").unwrap().tag, "v0.1.0");
assert!(exact(LS_REMOTE, "v9.9.9").is_none());
}
#[test]
fn an_annotated_tag_is_found_once() {
assert_eq!(exact(LS_REMOTE, "v0.2.0").unwrap().tag, "v0.2.0");
}
#[test]
fn a_prerelease_is_reachable_by_asking_for_it() {
let release = exact(LS_REMOTE, "v0.3.0-rc.1").unwrap();
assert_eq!(release.tag, "v0.3.0-rc.1");
assert_eq!(release.version, None);
}
}