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            if !first.is_ascii_alphanumeric() {
119                return Err(DistroError::InvalidFirstCharacter);
120            }
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    pub fn is_debian_based(&self) -> bool {
156        let s = self.0.to_lowercase();
157        s.contains("debian")
158            || s.contains("ubuntu")
159            || s.contains("mint")
160            || s.contains("pop")
161            || s.contains("kali")
162    }
163
164    /// Returns `true` if this is a Red Hat-based distribution.
165    ///
166    /// This includes Red Hat Enterprise Linux, Fedora, `CentOS`, Rocky Linux, `AlmaLinux`, etc.
167    #[must_use]
168    pub fn is_redhat_based(&self) -> bool {
169        let s = self.0.to_lowercase();
170        s.contains("red hat")
171            || s.contains("redhat")
172            || s.contains("fedora")
173            || s.contains("centos")
174            || s.contains("rhel")
175            || s.contains("rocky")
176            || s.contains("almalinux")
177    }
178
179    /// Returns `true` if this is an Arch-based distribution.
180    ///
181    /// This includes Arch Linux, Manjaro, `EndeavourOS`, etc.
182    #[must_use]
183    pub fn is_arch_based(&self) -> bool {
184        let s = self.0.to_lowercase();
185        s.contains("arch") || s.contains("manjaro") || s.contains("endeavouros")
186    }
187
188    /// Returns `true` if this is a rolling release distribution.
189    ///
190    /// This includes Arch Linux, Gentoo, Fedora, Void Linux, Debian Sid, etc.
191    #[must_use]
192    pub fn is_rolling_release(&self) -> bool {
193        let s = self.0.to_lowercase();
194        s.contains("arch")
195            || s.contains("gentoo")
196            || s.contains("fedora")
197            || s.contains("void")
198            || s.contains("sid")
199    }
200
201    /// Returns `true` if this is a long-term support (LTS) distribution.
202    ///
203    /// This includes Ubuntu LTS, Debian, RHEL, Rocky Linux, `AlmaLinux`, etc.
204    #[must_use]
205    pub fn is_lts(&self) -> bool {
206        let s = self.0.to_lowercase();
207        s.contains("lts")
208            || s.contains("ubuntu")
209            || s.contains("debian")
210            || s.contains("rhel")
211            || s.contains("rocky")
212            || s.contains("almalinux")
213    }
214}
215
216impl AsRef<str> for Distro {
217    fn as_ref(&self) -> &str {
218        self.as_str()
219    }
220}
221
222impl TryFrom<&str> for Distro {
223    type Error = DistroError;
224
225    fn try_from(s: &str) -> Result<Self, Self::Error> {
226        Self::new(s)
227    }
228}
229
230impl FromStr for Distro {
231    type Err = DistroError;
232
233    fn from_str(s: &str) -> Result<Self, Self::Err> {
234        Self::new(s)
235    }
236}
237
238impl fmt::Display for Distro {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        write!(f, "{}", self.0)
241    }
242}
243
244#[cfg(feature = "arbitrary")]
245impl<'a> arbitrary::Arbitrary<'a> for Distro {
246    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
247        const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
248        const DIGITS: &[u8] = b"0123456789";
249
250        // Generate 1-64 character distribution name
251        let len = 1 + (u8::arbitrary(u)? % 64).min(63);
252        let mut inner = heapless::String::<64>::new();
253
254        // First character: alphanumeric
255        let first_byte = u8::arbitrary(u)?;
256        if first_byte % 2 == 0 {
257            let first = ALPHABET[(first_byte % 26) as usize] as char;
258            inner
259                .push(first)
260                .map_err(|_| arbitrary::Error::IncorrectFormat)?;
261        } else {
262            let first = DIGITS[(first_byte % 10) as usize] as char;
263            inner
264                .push(first)
265                .map_err(|_| arbitrary::Error::IncorrectFormat)?;
266        }
267
268        // Remaining characters: alphanumeric, space, hyphen, or underscore
269        for _ in 1..len {
270            let byte = u8::arbitrary(u)?;
271            let c = match byte % 5 {
272                0 => ALPHABET[((byte >> 2) % 26) as usize] as char,
273                1 => DIGITS[((byte >> 2) % 10) as usize] as char,
274                2 => ' ',
275                3 => '-',
276                _ => '_',
277            };
278            inner
279                .push(c)
280                .map_err(|_| arbitrary::Error::IncorrectFormat)?;
281        }
282
283        Ok(Self(inner))
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn test_new_valid() {
293        let distro = Distro::new("Ubuntu").unwrap();
294        assert_eq!(distro.as_str(), "Ubuntu");
295    }
296
297    #[test]
298    fn test_new_empty() {
299        assert!(matches!(Distro::new(""), Err(DistroError::Empty)));
300    }
301
302    #[test]
303    fn test_new_too_long() {
304        let long_name = "a".repeat(65);
305        assert!(matches!(
306            Distro::new(&long_name),
307            Err(DistroError::TooLong(65))
308        ));
309    }
310
311    #[test]
312    fn test_new_invalid_first_character() {
313        assert!(matches!(
314            Distro::new("-Ubuntu"),
315            Err(DistroError::InvalidFirstCharacter)
316        ));
317        assert!(matches!(
318            Distro::new(" Ubuntu"),
319            Err(DistroError::InvalidFirstCharacter)
320        ));
321    }
322
323    #[test]
324    fn test_new_invalid_character() {
325        assert!(matches!(
326            Distro::new("Ubuntu@"),
327            Err(DistroError::InvalidCharacter)
328        ));
329        assert!(matches!(
330            Distro::new("Ubuntu.Distro"),
331            Err(DistroError::InvalidCharacter)
332        ));
333    }
334
335    #[test]
336    fn test_is_debian_based() {
337        let ubuntu = Distro::new("Ubuntu").unwrap();
338        assert!(ubuntu.is_debian_based());
339        let debian = Distro::new("Debian").unwrap();
340        assert!(debian.is_debian_based());
341        let mint = Distro::new("Linux Mint").unwrap();
342        assert!(mint.is_debian_based());
343        let fedora = Distro::new("Fedora").unwrap();
344        assert!(!fedora.is_debian_based());
345    }
346
347    #[test]
348    fn test_is_redhat_based() {
349        let fedora = Distro::new("Fedora").unwrap();
350        assert!(fedora.is_redhat_based());
351        let centos = Distro::new("CentOS").unwrap();
352        assert!(centos.is_redhat_based());
353        let rhel = Distro::new("RHEL").unwrap();
354        assert!(rhel.is_redhat_based());
355        let ubuntu = Distro::new("Ubuntu").unwrap();
356        assert!(!ubuntu.is_redhat_based());
357    }
358
359    #[test]
360    fn test_is_arch_based() {
361        let arch = Distro::new("Arch Linux").unwrap();
362        assert!(arch.is_arch_based());
363        let manjaro = Distro::new("Manjaro").unwrap();
364        assert!(manjaro.is_arch_based());
365        let ubuntu = Distro::new("Ubuntu").unwrap();
366        assert!(!ubuntu.is_arch_based());
367    }
368
369    #[test]
370    fn test_is_rolling_release() {
371        let arch = Distro::new("Arch Linux").unwrap();
372        assert!(arch.is_rolling_release());
373        let gentoo = Distro::new("Gentoo").unwrap();
374        assert!(gentoo.is_rolling_release());
375        let fedora = Distro::new("Fedora").unwrap();
376        assert!(fedora.is_rolling_release());
377        let ubuntu = Distro::new("Ubuntu").unwrap();
378        assert!(!ubuntu.is_rolling_release());
379    }
380
381    #[test]
382    fn test_is_lts() {
383        let ubuntu = Distro::new("Ubuntu LTS").unwrap();
384        assert!(ubuntu.is_lts());
385        let debian = Distro::new("Debian").unwrap();
386        assert!(debian.is_lts());
387        let fedora = Distro::new("Fedora").unwrap();
388        assert!(!fedora.is_lts());
389    }
390
391    #[test]
392    fn test_from_str() {
393        let distro: Distro = "Ubuntu".parse().unwrap();
394        assert_eq!(distro.as_str(), "Ubuntu");
395    }
396
397    #[test]
398    fn test_from_str_error() {
399        assert!("".parse::<Distro>().is_err());
400        assert!("-Ubuntu".parse::<Distro>().is_err());
401    }
402
403    #[test]
404    fn test_display() {
405        let distro = Distro::new("Ubuntu").unwrap();
406        assert_eq!(format!("{}", distro), "Ubuntu");
407    }
408
409    #[test]
410    fn test_as_ref() {
411        let distro = Distro::new("Ubuntu").unwrap();
412        let s: &str = distro.as_ref();
413        assert_eq!(s, "Ubuntu");
414    }
415
416    #[test]
417    fn test_clone() {
418        let distro = Distro::new("Ubuntu").unwrap();
419        let distro2 = distro.clone();
420        assert_eq!(distro, distro2);
421    }
422
423    #[test]
424    fn test_equality() {
425        let d1 = Distro::new("Ubuntu").unwrap();
426        let d2 = Distro::new("Ubuntu").unwrap();
427        let d3 = Distro::new("Fedora").unwrap();
428        assert_eq!(d1, d2);
429        assert_ne!(d1, d3);
430    }
431}