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