Skip to main content

uv_platform/
host.rs

1//! Host system information (OS type, kernel release, distro metadata).
2
3use std::convert::Infallible;
4use std::fmt;
5use std::str::FromStr;
6
7/// The operating system type.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum OsType {
10    /// Linux, with the kernel type from `/proc/sys/kernel/ostype` (typically `"Linux"`).
11    Linux(String),
12    /// macOS / Darwin.
13    Darwin,
14    /// Windows.
15    Windows,
16}
17
18impl OsType {
19    /// Returns the operating system type for the current host.
20    ///
21    /// Returns `None` on unsupported platforms.
22    pub fn from_env() -> Option<Self> {
23        #[cfg(target_os = "linux")]
24        {
25            fs_err::read_to_string("/proc/sys/kernel/ostype")
26                .ok()
27                .map(|s| Self::Linux(s.trim().to_string()))
28        }
29        #[cfg(target_os = "macos")]
30        {
31            Some(Self::Darwin)
32        }
33        #[cfg(target_os = "windows")]
34        {
35            Some(Self::Windows)
36        }
37        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
38        {
39            None
40        }
41    }
42}
43
44impl fmt::Display for OsType {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            Self::Linux(os_type) => f.write_str(os_type),
48            Self::Darwin => f.write_str("Darwin"),
49            Self::Windows => f.write_str("Windows"),
50        }
51    }
52}
53
54/// The OS kernel release version.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum OsRelease {
57    /// Unix kernel release from `uname -r` (e.g., `"6.8.0-90-generic"`).
58    Unix(String),
59    /// Windows version major.minor.build.revision (e.g., `"10.0.26100.6901"`).
60    Windows {
61        major: u32,
62        minor: u32,
63        build: u32,
64        revision: u32,
65    },
66}
67
68impl OsRelease {
69    /// Returns the OS kernel release for the current host.
70    ///
71    /// Returns `None` on unsupported platforms or if the release cannot be read.
72    pub fn from_env() -> Option<Self> {
73        #[cfg(unix)]
74        {
75            let uname = rustix::system::uname();
76            let release = uname.release().to_str().ok()?;
77            Some(Self::Unix(release.to_string()))
78        }
79        #[cfg(windows)]
80        {
81            let os_version = windows_version::OsVersion::current();
82            Some(Self::Windows {
83                major: os_version.major,
84                minor: os_version.minor,
85                build: os_version.build,
86                revision: windows_version::revision(),
87            })
88        }
89        #[cfg(not(any(unix, windows)))]
90        {
91            None
92        }
93    }
94}
95
96impl fmt::Display for OsRelease {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        match self {
99            Self::Unix(release) => f.write_str(release),
100            Self::Windows {
101                major,
102                minor,
103                build,
104                revision,
105            } => write!(f, "{major}.{minor}.{build}.{revision}"),
106        }
107    }
108}
109
110/// Parsed fields from `/etc/os-release`.
111#[derive(Debug, Clone, Default)]
112pub struct LinuxOsRelease {
113    /// Distribution name (e.g., `"Ubuntu"`).
114    pub name: Option<String>,
115    /// Version identifier (e.g., `"22.04"`).
116    pub version_id: Option<String>,
117    /// Version codename (e.g., `"jammy"`).
118    pub version_codename: Option<String>,
119}
120
121impl LinuxOsRelease {
122    /// Reads and parses `/etc/os-release` on Linux, falling back to `/usr/lib/os-release`.
123    ///
124    /// See <https://www.freedesktop.org/software/systemd/man/latest/os-release.html>.
125    ///
126    /// Returns `None` on non-Linux platforms or if the file cannot be read.
127    pub fn from_env() -> Option<Self> {
128        #[cfg(target_os = "linux")]
129        {
130            let content = fs_err::read_to_string("/etc/os-release")
131                .or_else(|_| fs_err::read_to_string("/usr/lib/os-release"))
132                .ok()?;
133            Some(content.parse().unwrap())
134        }
135        #[cfg(not(target_os = "linux"))]
136        {
137            None
138        }
139    }
140}
141
142impl FromStr for LinuxOsRelease {
143    type Err = Infallible;
144
145    /// Parse the contents of an os-release file (KEY=VALUE format, optionally quoted).
146    fn from_str(contents: &str) -> Result<Self, Self::Err> {
147        let mut release = Self::default();
148        for line in contents.lines() {
149            let line = line.trim();
150            if line.is_empty() || line.starts_with('#') {
151                continue;
152            }
153            let Some((key, value)) = line.split_once('=') else {
154                continue;
155            };
156            let value = unquote(value);
157            match key {
158                "NAME" => release.name = Some(value.to_string()),
159                "VERSION_ID" => release.version_id = Some(value.to_string()),
160                "VERSION_CODENAME" => release.version_codename = Some(value.to_string()),
161                _ => {}
162            }
163        }
164        Ok(release)
165    }
166}
167
168/// Strip matching single or double quotes from a value.
169fn unquote(s: &str) -> &str {
170    for quote in ['"', '\''] {
171        if let Some(inner) = s.strip_prefix(quote).and_then(|s| s.strip_suffix(quote)) {
172            return inner;
173        }
174    }
175    s
176}
177
178#[cfg(test)]
179mod tests {
180    use insta::assert_debug_snapshot;
181
182    use super::*;
183
184    #[test]
185    fn test_parse_os_release_ubuntu() {
186        let contents = "\
187NAME=\"Ubuntu\"
188VERSION_ID=\"22.04\"
189VERSION_CODENAME=jammy
190ID=ubuntu
191";
192        let release: LinuxOsRelease = contents.parse().unwrap();
193        assert_debug_snapshot!(release, @r#"
194        LinuxOsRelease {
195            name: Some(
196                "Ubuntu",
197            ),
198            version_id: Some(
199                "22.04",
200            ),
201            version_codename: Some(
202                "jammy",
203            ),
204        }
205        "#);
206    }
207
208    #[test]
209    fn test_parse_os_release_empty() {
210        let release: LinuxOsRelease = "".parse().unwrap();
211        assert_eq!(release.name, None);
212        assert_eq!(release.version_id, None);
213        assert_eq!(release.version_codename, None);
214    }
215
216    #[test]
217    fn test_parse_os_release_comments_and_blanks() {
218        let contents = "\
219# This is a comment
220
221NAME='Fedora Linux'
222VERSION_ID=40
223";
224        let release: LinuxOsRelease = contents.parse().unwrap();
225        assert_eq!(release.name.as_deref(), Some("Fedora Linux"));
226        assert_eq!(release.version_id.as_deref(), Some("40"));
227        assert_eq!(release.version_codename, None);
228    }
229
230    #[test]
231    fn test_unquote() {
232        assert_eq!(unquote("\"hello\""), "hello");
233        assert_eq!(unquote("'hello'"), "hello");
234        assert_eq!(unquote("hello"), "hello");
235        assert_eq!(unquote("\"\""), "");
236        assert_eq!(unquote(""), "");
237    }
238
239    #[test]
240    fn test_os_type_returns_value() {
241        let os_type =
242            OsType::from_env().expect("OsType should be available on supported platforms");
243        #[cfg(target_os = "linux")]
244        assert!(matches!(os_type, OsType::Linux(_)));
245        #[cfg(target_os = "macos")]
246        assert_eq!(os_type, OsType::Darwin);
247        #[cfg(target_os = "windows")]
248        assert_eq!(os_type, OsType::Windows);
249    }
250
251    #[test]
252    fn test_os_release_returns_value() {
253        let os_release =
254            OsRelease::from_env().expect("OsRelease should be available on supported platforms");
255        #[cfg(unix)]
256        assert!(matches!(os_release, OsRelease::Unix(_)));
257        #[cfg(windows)]
258        assert!(matches!(os_release, OsRelease::Windows { .. }));
259    }
260}