1use std::fmt;
10use std::str::FromStr;
11
12pub const SUPPORTED_FLOOR: Version = Version {
24 major: 1,
25 minor: 78,
26 patch: 0,
27};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub struct Version {
32 pub major: u32,
33 pub minor: u32,
34 pub patch: u32,
35}
36
37impl Version {
38 pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
39 Self {
40 major,
41 minor,
42 patch,
43 }
44 }
45
46 pub const fn is_unstable(self) -> bool {
52 self.minor % 2 == 1
53 }
54
55 pub fn parse_cli_output(output: &str) -> Option<Self> {
61 output.lines().find_map(|line| line.trim().parse().ok())
62 }
63}
64
65impl FromStr for Version {
66 type Err = ();
67
68 fn from_str(s: &str) -> Result<Self, Self::Err> {
69 let s = s.trim().trim_start_matches('v');
70 let end = s
73 .find(|c: char| !c.is_ascii_digit() && c != '.')
74 .unwrap_or(s.len());
75 let mut parts = s[..end].split('.');
76 let mut next = || parts.next().and_then(|p| p.parse::<u32>().ok());
77 let major = next().ok_or(())?;
78 let minor = next().ok_or(())?;
79 let patch = next().unwrap_or(0);
80 if parts.next().is_some() {
81 return Err(());
82 }
83 Ok(Self {
84 major,
85 minor,
86 patch,
87 })
88 }
89}
90
91impl fmt::Display for Version {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
94 }
95}
96
97pub fn satisfies(found: Option<Version>, required: Option<&str>) -> bool {
103 match (found, required.and_then(|r| r.parse::<Version>().ok())) {
104 (Some(found), Some(required)) => found >= required,
105 _ => true,
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn a_plain_version_parses() {
115 assert_eq!("1.102.2".parse(), Ok(Version::new(1, 102, 2)));
116 assert_eq!("1.78".parse(), Ok(Version::new(1, 78, 0)));
117 assert_eq!("v1.80.0".parse(), Ok(Version::new(1, 80, 0)));
118 }
119
120 #[test]
121 fn a_build_suffix_parses_as_its_release() {
122 assert_eq!("1.102.2-t01a2b3c4".parse(), Ok(Version::new(1, 102, 2)));
123 assert_eq!("1.99.0-dev".parse(), Ok(Version::new(1, 99, 0)));
124 }
125
126 #[test]
127 fn prose_is_not_a_version() {
128 for text in ["", "tailscale", "go1.24.1 (rev abcdef)", "1", "1.2.3.4"] {
129 assert_eq!(text.parse::<Version>(), Err(()), "{text} parsed");
130 }
131 }
132
133 #[test]
134 fn the_first_line_of_the_cli_output_is_the_client_version() {
135 let output = "1.102.2\n tailscale commit: 0123456789abcdef\n go version: go1.24.1\n";
136 assert_eq!(
137 Version::parse_cli_output(output),
138 Some(Version::new(1, 102, 2))
139 );
140 }
141
142 #[test]
143 fn versions_order_by_component() {
144 assert!(Version::new(1, 102, 0) > Version::new(1, 78, 9));
145 assert!(Version::new(1, 78, 1) > Version::new(1, 78, 0));
146 assert!(Version::new(1, 78, 0) >= SUPPORTED_FLOOR);
147 assert!(Version::new(1, 72, 0) < SUPPORTED_FLOOR);
148 }
149
150 #[test]
151 fn odd_minors_are_the_unstable_track() {
152 assert!(Version::new(1, 103, 0).is_unstable());
153 assert!(!Version::new(1, 102, 0).is_unstable());
154 }
155
156 #[test]
157 fn a_requirement_is_met_by_a_newer_release() {
158 assert!(satisfies(Some(Version::new(1, 102, 2)), Some("1.80")));
159 assert!(satisfies(Some(Version::new(1, 80, 0)), Some("1.80")));
160 assert!(!satisfies(Some(Version::new(1, 78, 0)), Some("1.80")));
161 }
162
163 #[test]
164 fn an_unknown_version_blocks_nothing() {
165 assert!(satisfies(None, Some("1.80")));
166 assert!(satisfies(Some(Version::new(1, 50, 0)), None));
167 assert!(satisfies(Some(Version::new(1, 50, 0)), Some("soon")));
170 }
171}