Skip to main content

tailscale_mcp/
version.rs

1//! Reading, comparing and reporting the local Tailscale version.
2//!
3//! Upstream publishes no end-of-life policy: stable releases carry even minor
4//! numbers and land about every four weeks, and Tailscale's stated position is
5//! that they do not break clients people are still running. The floor below is
6//! therefore ours, and it is a statement about what this server models rather
7//! than about what Tailscale supports.
8
9use std::fmt;
10use std::str::FromStr;
11
12/// The oldest release this server models faithfully.
13///
14/// Chosen as the newest release that introduced a command belonging to the
15/// default preset (`tailscale metrics`, 1.78). Everything the core preset
16/// exposes exists at or below it; everything added afterwards carries its own
17/// minimum on its metadata row.
18///
19/// Below the floor the server still starts and still offers every tool. It
20/// warns once, on standard error, and lets the individual commands report what
21/// they need. Hiding tools on a version guess would be worse: the guess would
22/// be wrong for anyone running a fork or a distribution build.
23pub const SUPPORTED_FLOOR: Version = Version {
24    major: 1,
25    minor: 78,
26    patch: 0,
27};
28
29/// A Tailscale version.
30#[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    /// Whether this is a development build.
47    ///
48    /// Odd minor numbers are the unstable track. Such a build is newer than the
49    /// stable release with the next even minor, so it is treated as such rather
50    /// than warned about.
51    pub const fn is_unstable(self) -> bool {
52        self.minor % 2 == 1
53    }
54
55    /// Read the version out of what `tailscale version` prints.
56    ///
57    /// The first line is the client version; the remaining lines are the Go
58    /// toolchain and commit hashes, which are of no interest here. A build with
59    /// a suffix — `1.102.2-t01a2b3c4` — parses as its release.
60    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        // Stop at the first character that cannot be part of a version, so a
71        // build suffix or trailing prose does not defeat the parse.
72        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
97/// Whether `found` satisfies a tool's `min_version`.
98///
99/// An unparseable or unknown version satisfies everything: we would rather
100/// attempt a command and let the CLI refuse it than refuse a command because
101/// we could not read a version string.
102pub 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        // An unparseable requirement is a bug in our own table, and refusing
168        // the call would hide it behind a confusing message.
169        assert!(satisfies(Some(Version::new(1, 50, 0)), Some("soon")));
170    }
171}