Skip to main content

bare_types/sys/
distro.rs

1//! OS distribution type for system information.
2//!
3//! This module provides a type-safe abstraction for OS distribution names,
4//! ensuring valid distribution name strings.
5//!
6//! # Distribution Name Format
7//!
8//! OS distribution names follow these rules:
9//!
10//! - Must be 1-64 characters
11//! - Must start with an alphanumeric character
12//! - Can contain alphanumeric characters, spaces, hyphens, and underscores
13//!
14//! # Examples
15//!
16//! ```rust
17//! use bare_types::sys::Distro;
18//!
19//! // Parse from string
20//! let distro: Distro = "Ubuntu".parse()?;
21//!
22//! // Access as string
23//! assert_eq!(distro.as_str(), "Ubuntu");
24//! # Ok::<(), bare_types::sys::DistroError>(())
25//! ```
26
27use core::fmt;
28use core::str::FromStr;
29
30#[cfg(feature = "serde")]
31use serde::{Deserialize, Serialize};
32
33/// Error type for distribution name parsing.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
36pub enum DistroError {
37    /// Empty distribution name
38    Empty,
39    /// Distribution name too long (max 64 characters)
40    TooLong(usize),
41    /// Invalid first character (must be alphanumeric)
42    InvalidFirstCharacter,
43    /// Invalid character in distribution name
44    InvalidCharacter,
45}
46
47impl fmt::Display for DistroError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::Empty => write!(f, "distribution name cannot be empty"),
51            Self::TooLong(len) => write!(
52                f,
53                "distribution name too long (got {len}, max 64 characters)"
54            ),
55            Self::InvalidFirstCharacter => {
56                write!(
57                    f,
58                    "distribution name must start with an alphanumeric character"
59                )
60            }
61            Self::InvalidCharacter => write!(f, "distribution name contains invalid character"),
62        }
63    }
64}
65
66#[cfg(feature = "std")]
67impl std::error::Error for DistroError {}
68
69/// OS distribution name.
70///
71/// This type provides type-safe OS distribution names.
72/// It uses the newtype pattern with `#[repr(transparent)]` for zero-cost abstraction.
73#[repr(transparent)]
74#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
76pub struct Distro(heapless::String<64>);
77
78impl Distro {
79    /// Maximum length of a distribution name
80    pub const MAX_LEN: usize = 64;
81
82    /// Creates a new distribution name from a string.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error `DistroError` if the string is not a valid distribution name.
87    pub fn new(s: &str) -> Result<Self, DistroError> {
88        Self::validate(s)?;
89        let mut value = heapless::String::new();
90        value
91            .push_str(s)
92            .map_err(|_| DistroError::TooLong(s.len()))?;
93        Ok(Self(value))
94    }
95
96    /// Validates a distribution name string.
97    fn validate(s: &str) -> Result<(), DistroError> {
98        if s.is_empty() {
99            return Err(DistroError::Empty);
100        }
101        if s.len() > Self::MAX_LEN {
102            return Err(DistroError::TooLong(s.len()));
103        }
104        let mut chars = s.chars();
105        if let Some(first) = chars.next() {
106            if !first.is_ascii_alphanumeric() {
107                return Err(DistroError::InvalidFirstCharacter);
108            }
109        }
110        for ch in chars {
111            if !ch.is_ascii_alphanumeric() && ch != ' ' && ch != '-' && ch != '_' {
112                return Err(DistroError::InvalidCharacter);
113            }
114        }
115        Ok(())
116    }
117
118    /// Returns the distribution name as a string slice.
119    #[must_use]
120    #[inline]
121    pub fn as_str(&self) -> &str {
122        &self.0
123    }
124
125    /// Returns a reference to the underlying `heapless::String`.
126    #[must_use]
127    #[inline]
128    pub const fn as_inner(&self) -> &heapless::String<64> {
129        &self.0
130    }
131
132    /// Consumes this distribution name and returns the underlying string.
133    #[must_use]
134    #[inline]
135    pub fn into_inner(self) -> heapless::String<64> {
136        self.0
137    }
138
139    /// Returns `true` if this is a Debian-based distribution.
140    ///
141    /// This includes Debian, Ubuntu, Linux Mint, Pop!_OS, Kali Linux, etc.
142    #[must_use]
143    pub fn is_debian_based(&self) -> bool {
144        let s = self.0.to_lowercase();
145        s.contains("debian")
146            || s.contains("ubuntu")
147            || s.contains("mint")
148            || s.contains("pop")
149            || s.contains("kali")
150    }
151
152    /// Returns `true` if this is a Red Hat-based distribution.
153    ///
154    /// This includes Red Hat Enterprise Linux, Fedora, `CentOS`, Rocky Linux, `AlmaLinux`, etc.
155    #[must_use]
156    pub fn is_redhat_based(&self) -> bool {
157        let s = self.0.to_lowercase();
158        s.contains("red hat")
159            || s.contains("redhat")
160            || s.contains("fedora")
161            || s.contains("centos")
162            || s.contains("rhel")
163            || s.contains("rocky")
164            || s.contains("almalinux")
165    }
166
167    /// Returns `true` if this is an Arch-based distribution.
168    ///
169    /// This includes Arch Linux, Manjaro, `EndeavourOS`, etc.
170    #[must_use]
171    pub fn is_arch_based(&self) -> bool {
172        let s = self.0.to_lowercase();
173        s.contains("arch") || s.contains("manjaro") || s.contains("endeavouros")
174    }
175
176    /// Returns `true` if this is a rolling release distribution.
177    ///
178    /// This includes Arch Linux, Gentoo, Fedora, Void Linux, Debian Sid, etc.
179    #[must_use]
180    pub fn is_rolling_release(&self) -> bool {
181        let s = self.0.to_lowercase();
182        s.contains("arch")
183            || s.contains("gentoo")
184            || s.contains("fedora")
185            || s.contains("void")
186            || s.contains("sid")
187    }
188
189    /// Returns `true` if this is a long-term support (LTS) distribution.
190    ///
191    /// This includes Ubuntu LTS, Debian, RHEL, Rocky Linux, `AlmaLinux`, etc.
192    #[must_use]
193    pub fn is_lts(&self) -> bool {
194        let s = self.0.to_lowercase();
195        s.contains("lts")
196            || s.contains("ubuntu")
197            || s.contains("debian")
198            || s.contains("rhel")
199            || s.contains("rocky")
200            || s.contains("almalinux")
201    }
202}
203
204impl AsRef<str> for Distro {
205    fn as_ref(&self) -> &str {
206        self.as_str()
207    }
208}
209
210impl TryFrom<&str> for Distro {
211    type Error = DistroError;
212
213    fn try_from(s: &str) -> Result<Self, Self::Error> {
214        Self::new(s)
215    }
216}
217
218impl FromStr for Distro {
219    type Err = DistroError;
220
221    fn from_str(s: &str) -> Result<Self, Self::Err> {
222        Self::new(s)
223    }
224}
225
226impl fmt::Display for Distro {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        write!(f, "{}", self.0)
229    }
230}
231
232#[cfg(feature = "arbitrary")]
233impl<'a> arbitrary::Arbitrary<'a> for Distro {
234    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
235        const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
236        const DIGITS: &[u8] = b"0123456789";
237
238        // Generate 1-64 character distribution name
239        let len = 1 + (u8::arbitrary(u)? % 64).min(63);
240        let mut inner = heapless::String::<64>::new();
241
242        // First character: alphanumeric
243        let first_byte = u8::arbitrary(u)?;
244        if first_byte % 2 == 0 {
245            let first = ALPHABET[(first_byte % 26) as usize] as char;
246            inner
247                .push(first)
248                .map_err(|_| arbitrary::Error::IncorrectFormat)?;
249        } else {
250            let first = DIGITS[(first_byte % 10) as usize] as char;
251            inner
252                .push(first)
253                .map_err(|_| arbitrary::Error::IncorrectFormat)?;
254        }
255
256        // Remaining characters: alphanumeric, space, hyphen, or underscore
257        for _ in 1..len {
258            let byte = u8::arbitrary(u)?;
259            let c = match byte % 5 {
260                0 => ALPHABET[((byte >> 2) % 26) as usize] as char,
261                1 => DIGITS[((byte >> 2) % 10) as usize] as char,
262                2 => ' ',
263                3 => '-',
264                _ => '_',
265            };
266            inner
267                .push(c)
268                .map_err(|_| arbitrary::Error::IncorrectFormat)?;
269        }
270
271        Ok(Self(inner))
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_new_valid() {
281        let distro = Distro::new("Ubuntu").unwrap();
282        assert_eq!(distro.as_str(), "Ubuntu");
283    }
284
285    #[test]
286    fn test_new_empty() {
287        assert!(matches!(Distro::new(""), Err(DistroError::Empty)));
288    }
289
290    #[test]
291    fn test_new_too_long() {
292        let long_name = "a".repeat(65);
293        assert!(matches!(
294            Distro::new(&long_name),
295            Err(DistroError::TooLong(65))
296        ));
297    }
298
299    #[test]
300    fn test_new_invalid_first_character() {
301        assert!(matches!(
302            Distro::new("-Ubuntu"),
303            Err(DistroError::InvalidFirstCharacter)
304        ));
305        assert!(matches!(
306            Distro::new(" Ubuntu"),
307            Err(DistroError::InvalidFirstCharacter)
308        ));
309    }
310
311    #[test]
312    fn test_new_invalid_character() {
313        assert!(matches!(
314            Distro::new("Ubuntu@"),
315            Err(DistroError::InvalidCharacter)
316        ));
317        assert!(matches!(
318            Distro::new("Ubuntu.Distro"),
319            Err(DistroError::InvalidCharacter)
320        ));
321    }
322
323    #[test]
324    fn test_is_debian_based() {
325        let ubuntu = Distro::new("Ubuntu").unwrap();
326        assert!(ubuntu.is_debian_based());
327        let debian = Distro::new("Debian").unwrap();
328        assert!(debian.is_debian_based());
329        let mint = Distro::new("Linux Mint").unwrap();
330        assert!(mint.is_debian_based());
331        let fedora = Distro::new("Fedora").unwrap();
332        assert!(!fedora.is_debian_based());
333    }
334
335    #[test]
336    fn test_is_redhat_based() {
337        let fedora = Distro::new("Fedora").unwrap();
338        assert!(fedora.is_redhat_based());
339        let centos = Distro::new("CentOS").unwrap();
340        assert!(centos.is_redhat_based());
341        let rhel = Distro::new("RHEL").unwrap();
342        assert!(rhel.is_redhat_based());
343        let ubuntu = Distro::new("Ubuntu").unwrap();
344        assert!(!ubuntu.is_redhat_based());
345    }
346
347    #[test]
348    fn test_is_arch_based() {
349        let arch = Distro::new("Arch Linux").unwrap();
350        assert!(arch.is_arch_based());
351        let manjaro = Distro::new("Manjaro").unwrap();
352        assert!(manjaro.is_arch_based());
353        let ubuntu = Distro::new("Ubuntu").unwrap();
354        assert!(!ubuntu.is_arch_based());
355    }
356
357    #[test]
358    fn test_is_rolling_release() {
359        let arch = Distro::new("Arch Linux").unwrap();
360        assert!(arch.is_rolling_release());
361        let gentoo = Distro::new("Gentoo").unwrap();
362        assert!(gentoo.is_rolling_release());
363        let fedora = Distro::new("Fedora").unwrap();
364        assert!(fedora.is_rolling_release());
365        let ubuntu = Distro::new("Ubuntu").unwrap();
366        assert!(!ubuntu.is_rolling_release());
367    }
368
369    #[test]
370    fn test_is_lts() {
371        let ubuntu = Distro::new("Ubuntu LTS").unwrap();
372        assert!(ubuntu.is_lts());
373        let debian = Distro::new("Debian").unwrap();
374        assert!(debian.is_lts());
375        let fedora = Distro::new("Fedora").unwrap();
376        assert!(!fedora.is_lts());
377    }
378
379    #[test]
380    fn test_from_str() {
381        let distro: Distro = "Ubuntu".parse().unwrap();
382        assert_eq!(distro.as_str(), "Ubuntu");
383    }
384
385    #[test]
386    fn test_from_str_error() {
387        assert!("".parse::<Distro>().is_err());
388        assert!("-Ubuntu".parse::<Distro>().is_err());
389    }
390
391    #[test]
392    fn test_display() {
393        let distro = Distro::new("Ubuntu").unwrap();
394        assert_eq!(format!("{}", distro), "Ubuntu");
395    }
396
397    #[test]
398    fn test_as_ref() {
399        let distro = Distro::new("Ubuntu").unwrap();
400        let s: &str = distro.as_ref();
401        assert_eq!(s, "Ubuntu");
402    }
403
404    #[test]
405    fn test_clone() {
406        let distro = Distro::new("Ubuntu").unwrap();
407        let distro2 = distro.clone();
408        assert_eq!(distro, distro2);
409    }
410
411    #[test]
412    fn test_equality() {
413        let d1 = Distro::new("Ubuntu").unwrap();
414        let d2 = Distro::new("Ubuntu").unwrap();
415        let d3 = Distro::new("Fedora").unwrap();
416        assert_eq!(d1, d2);
417        assert_ne!(d1, d3);
418    }
419}