use std::fmt;
use std::str::FromStr;
pub const SUPPORTED_FLOOR: Version = Version {
major: 1,
minor: 78,
patch: 0,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Version {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl Version {
pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
Self {
major,
minor,
patch,
}
}
pub const fn is_unstable(self) -> bool {
self.minor % 2 == 1
}
pub fn parse_cli_output(output: &str) -> Option<Self> {
output.lines().find_map(|line| line.trim().parse().ok())
}
}
impl FromStr for Version {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim().trim_start_matches('v');
let end = s
.find(|c: char| !c.is_ascii_digit() && c != '.')
.unwrap_or(s.len());
let mut parts = s[..end].split('.');
let mut next = || parts.next().and_then(|p| p.parse::<u32>().ok());
let major = next().ok_or(())?;
let minor = next().ok_or(())?;
let patch = next().unwrap_or(0);
if parts.next().is_some() {
return Err(());
}
Ok(Self {
major,
minor,
patch,
})
}
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
pub fn satisfies(found: Option<Version>, required: Option<&str>) -> bool {
match (found, required.and_then(|r| r.parse::<Version>().ok())) {
(Some(found), Some(required)) => found >= required,
_ => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_version_parses() {
assert_eq!("1.102.2".parse(), Ok(Version::new(1, 102, 2)));
assert_eq!("1.78".parse(), Ok(Version::new(1, 78, 0)));
assert_eq!("v1.80.0".parse(), Ok(Version::new(1, 80, 0)));
}
#[test]
fn a_build_suffix_parses_as_its_release() {
assert_eq!("1.102.2-t01a2b3c4".parse(), Ok(Version::new(1, 102, 2)));
assert_eq!("1.99.0-dev".parse(), Ok(Version::new(1, 99, 0)));
}
#[test]
fn prose_is_not_a_version() {
for text in ["", "tailscale", "go1.24.1 (rev abcdef)", "1", "1.2.3.4"] {
assert_eq!(text.parse::<Version>(), Err(()), "{text} parsed");
}
}
#[test]
fn the_first_line_of_the_cli_output_is_the_client_version() {
let output = "1.102.2\n tailscale commit: 0123456789abcdef\n go version: go1.24.1\n";
assert_eq!(
Version::parse_cli_output(output),
Some(Version::new(1, 102, 2))
);
}
#[test]
fn versions_order_by_component() {
assert!(Version::new(1, 102, 0) > Version::new(1, 78, 9));
assert!(Version::new(1, 78, 1) > Version::new(1, 78, 0));
assert!(Version::new(1, 78, 0) >= SUPPORTED_FLOOR);
assert!(Version::new(1, 72, 0) < SUPPORTED_FLOOR);
}
#[test]
fn a_requirement_is_met_by_a_newer_release() {
assert!(satisfies(Some(Version::new(1, 102, 2)), Some("1.80")));
assert!(satisfies(Some(Version::new(1, 80, 0)), Some("1.80")));
assert!(!satisfies(Some(Version::new(1, 78, 0)), Some("1.80")));
}
#[test]
fn an_unknown_version_blocks_nothing() {
assert!(satisfies(None, Some("1.80")));
assert!(satisfies(Some(Version::new(1, 50, 0)), None));
assert!(satisfies(Some(Version::new(1, 50, 0)), Some("soon")));
}
}