Skip to main content

use_oci_platform/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Errors returned while parsing OCI platform values.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum PlatformError {
10    Empty,
11    InvalidPart,
12    InvalidPlatform,
13}
14
15impl fmt::Display for PlatformError {
16    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            Self::Empty => formatter.write_str("OCI platform value cannot be empty"),
19            Self::InvalidPart => formatter.write_str("invalid OCI platform part"),
20            Self::InvalidPlatform => formatter.write_str("invalid OCI platform string"),
21        }
22    }
23}
24
25impl Error for PlatformError {}
26
27/// OCI operating system labels.
28#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29pub enum OciOs {
30    Linux,
31    Windows,
32    Darwin,
33    FreeBsd,
34    Wasm,
35    Unknown,
36    Custom(String),
37}
38
39impl OciOs {
40    /// Returns the stable OS label.
41    #[must_use]
42    pub fn as_str(&self) -> &str {
43        match self {
44            Self::Linux => "linux",
45            Self::Windows => "windows",
46            Self::Darwin => "darwin",
47            Self::FreeBsd => "freebsd",
48            Self::Wasm => "wasm",
49            Self::Unknown => "unknown",
50            Self::Custom(value) => value,
51        }
52    }
53}
54
55impl fmt::Display for OciOs {
56    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57        formatter.write_str(self.as_str())
58    }
59}
60
61impl FromStr for OciOs {
62    type Err = PlatformError;
63
64    fn from_str(value: &str) -> Result<Self, Self::Err> {
65        let normalized = normalize_part(value)?;
66        match normalized.as_str() {
67            "linux" => Ok(Self::Linux),
68            "windows" => Ok(Self::Windows),
69            "darwin" | "macos" => Ok(Self::Darwin),
70            "freebsd" => Ok(Self::FreeBsd),
71            "wasm" | "wasi" => Ok(Self::Wasm),
72            "unknown" => Ok(Self::Unknown),
73            _ => Ok(Self::Custom(normalized)),
74        }
75    }
76}
77
78/// OCI architecture labels.
79#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
80pub enum OciArchitecture {
81    Amd64,
82    Arm64,
83    Arm,
84    I386,
85    Ppc64le,
86    Riscv64,
87    S390x,
88    Wasm,
89    Unknown,
90    Custom(String),
91}
92
93impl OciArchitecture {
94    /// Returns the stable architecture label.
95    #[must_use]
96    pub fn as_str(&self) -> &str {
97        match self {
98            Self::Amd64 => "amd64",
99            Self::Arm64 => "arm64",
100            Self::Arm => "arm",
101            Self::I386 => "386",
102            Self::Ppc64le => "ppc64le",
103            Self::Riscv64 => "riscv64",
104            Self::S390x => "s390x",
105            Self::Wasm => "wasm",
106            Self::Unknown => "unknown",
107            Self::Custom(value) => value,
108        }
109    }
110}
111
112impl fmt::Display for OciArchitecture {
113    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
114        formatter.write_str(self.as_str())
115    }
116}
117
118impl FromStr for OciArchitecture {
119    type Err = PlatformError;
120
121    fn from_str(value: &str) -> Result<Self, Self::Err> {
122        let normalized = normalize_part(value)?;
123        match normalized.replace(['_', '-', ' '], "").as_str() {
124            "amd64" | "x8664" | "x64" => Ok(Self::Amd64),
125            "arm64" | "aarch64" => Ok(Self::Arm64),
126            "arm" => Ok(Self::Arm),
127            "386" | "i386" | "i686" => Ok(Self::I386),
128            "ppc64le" => Ok(Self::Ppc64le),
129            "riscv64" => Ok(Self::Riscv64),
130            "s390x" => Ok(Self::S390x),
131            "wasm" | "wasm32" | "wasm64" => Ok(Self::Wasm),
132            "unknown" => Ok(Self::Unknown),
133            _ => Ok(Self::Custom(normalized)),
134        }
135    }
136}
137
138macro_rules! text_part {
139    ($name:ident) => {
140        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
141        pub struct $name(String);
142
143        impl $name {
144            /// Creates a validated platform text part.
145            pub fn new(value: impl AsRef<str>) -> Result<Self, PlatformError> {
146                normalize_part(value.as_ref()).map(Self)
147            }
148
149            /// Returns the text part.
150            #[must_use]
151            pub fn as_str(&self) -> &str {
152                &self.0
153            }
154        }
155
156        impl AsRef<str> for $name {
157            fn as_ref(&self) -> &str {
158                self.as_str()
159            }
160        }
161
162        impl fmt::Display for $name {
163            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
164                formatter.write_str(self.as_str())
165            }
166        }
167    };
168}
169
170text_part!(PlatformVariant);
171text_part!(OsVersion);
172text_part!(OsFeature);
173
174/// OCI platform metadata.
175#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
176pub struct OciPlatform {
177    os: OciOs,
178    architecture: OciArchitecture,
179    variant: Option<PlatformVariant>,
180    os_version: Option<OsVersion>,
181    os_features: Vec<OsFeature>,
182}
183
184impl OciPlatform {
185    /// Creates platform metadata from OS and architecture labels.
186    #[must_use]
187    pub fn new(os: OciOs, architecture: OciArchitecture) -> Self {
188        Self {
189            os,
190            architecture,
191            variant: None,
192            os_version: None,
193            os_features: Vec::new(),
194        }
195    }
196
197    /// Adds an architecture variant.
198    pub fn with_variant(mut self, variant: impl AsRef<str>) -> Result<Self, PlatformError> {
199        self.variant = Some(PlatformVariant::new(variant)?);
200        Ok(self)
201    }
202
203    /// Adds an OS version label.
204    pub fn with_os_version(mut self, version: impl AsRef<str>) -> Result<Self, PlatformError> {
205        self.os_version = Some(OsVersion::new(version)?);
206        Ok(self)
207    }
208
209    /// Adds an OS feature label.
210    pub fn with_os_feature(mut self, feature: impl AsRef<str>) -> Result<Self, PlatformError> {
211        self.os_features.push(OsFeature::new(feature)?);
212        Ok(self)
213    }
214
215    /// Returns the OS label.
216    #[must_use]
217    pub const fn os(&self) -> &OciOs {
218        &self.os
219    }
220
221    /// Returns the architecture label.
222    #[must_use]
223    pub const fn architecture(&self) -> &OciArchitecture {
224        &self.architecture
225    }
226
227    /// Returns the optional variant.
228    #[must_use]
229    pub const fn variant(&self) -> Option<&PlatformVariant> {
230        self.variant.as_ref()
231    }
232
233    /// Returns OS features.
234    #[must_use]
235    pub fn os_features(&self) -> &[OsFeature] {
236        &self.os_features
237    }
238}
239
240impl fmt::Display for OciPlatform {
241    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
242        write!(formatter, "{}/{}", self.os, self.architecture)?;
243        if let Some(variant) = &self.variant {
244            write!(formatter, "/{variant}")?;
245        }
246        Ok(())
247    }
248}
249
250impl FromStr for OciPlatform {
251    type Err = PlatformError;
252
253    fn from_str(value: &str) -> Result<Self, Self::Err> {
254        let parts = value.trim().split('/').collect::<Vec<_>>();
255        match parts.as_slice() {
256            [os, architecture] => Ok(Self::new(os.parse()?, architecture.parse()?)),
257            [os, architecture, variant] => {
258                Self::new(os.parse()?, architecture.parse()?).with_variant(variant)
259            },
260            _ => Err(PlatformError::InvalidPlatform),
261        }
262    }
263}
264
265fn normalize_part(value: &str) -> Result<String, PlatformError> {
266    let trimmed = value.trim();
267    if trimmed.is_empty() {
268        return Err(PlatformError::Empty);
269    }
270    if trimmed
271        .bytes()
272        .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace() || byte == b'/')
273    {
274        return Err(PlatformError::InvalidPart);
275    }
276    Ok(trimmed.to_ascii_lowercase())
277}
278
279#[cfg(test)]
280mod tests {
281    use super::{OciArchitecture, OciOs, OciPlatform, PlatformError};
282
283    #[test]
284    fn parses_and_renders_platforms() -> Result<(), Box<dyn std::error::Error>> {
285        let platform: OciPlatform = "linux/arm64/v8".parse()?;
286
287        assert_eq!(platform.os(), &OciOs::Linux);
288        assert_eq!(platform.architecture(), &OciArchitecture::Arm64);
289        assert_eq!(platform.to_string(), "linux/arm64/v8");
290        assert_eq!(
291            "linux".parse::<OciPlatform>(),
292            Err(PlatformError::InvalidPlatform)
293        );
294        Ok(())
295    }
296
297    #[test]
298    fn accepts_common_architecture_aliases() -> Result<(), PlatformError> {
299        assert_eq!("x86_64".parse::<OciArchitecture>()?, OciArchitecture::Amd64);
300        assert_eq!(
301            "aarch64".parse::<OciArchitecture>()?,
302            OciArchitecture::Arm64
303        );
304        assert_eq!("i686".parse::<OciArchitecture>()?, OciArchitecture::I386);
305        Ok(())
306    }
307}