use std::{
ffi::OsStr,
fmt::{Display, Formatter},
str::FromStr,
};
use winnow::{
ModalResult,
Parser,
combinator::{alt, cut_err, eof, not, opt, peek, repeat_till},
error::StrContext,
};
use crate::{
Error,
identifiers::{IdentifierString, base::SegmentPath},
};
fn identifier_string_parser(input: &mut &str) -> ModalResult<IdentifierString> {
repeat_till::<_, _, (), _, _, _, _>(1.., IdentifierString::valid_chars, peek(alt((":", eof))))
.take()
.and_then(cut_err(IdentifierString::parser))
.parse_next(input)
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct Os {
id: IdentifierString,
version_id: Option<IdentifierString>,
variant_id: Option<IdentifierString>,
image_id: Option<IdentifierString>,
image_version: Option<IdentifierString>,
}
impl Os {
pub fn new(
id: IdentifierString,
version_id: Option<IdentifierString>,
variant_id: Option<IdentifierString>,
image_id: Option<IdentifierString>,
image_version: Option<IdentifierString>,
) -> Self {
Self {
id,
version_id,
variant_id,
image_id,
image_version,
}
}
pub fn os_to_string(&self) -> String {
let os = format!(
"{}:{}:{}:{}:{}",
&self.id,
self.version_id.as_deref().unwrap_or(""),
self.variant_id.as_deref().unwrap_or(""),
self.image_id.as_deref().unwrap_or(""),
self.image_version.as_deref().unwrap_or(""),
);
os.trim_end_matches(':').into()
}
pub(crate) fn path_segment(&self) -> Result<SegmentPath, Error> {
self.os_to_string().try_into()
}
pub fn parser(input: &mut &str) -> ModalResult<Self> {
let id = identifier_string_parser
.context(StrContext::Label("VOA OS ID"))
.parse_next(input)?;
if opt(":").parse_next(input)?.is_none() {
return Ok(Self {
id,
version_id: None,
variant_id: None,
image_id: None,
image_version: None,
});
}
let version_id = opt(identifier_string_parser)
.context(StrContext::Label("optional VOA OS VERSION_ID"))
.parse_next(input)?;
if opt(":").parse_next(input)?.is_none() {
return Ok(Self {
id,
version_id,
variant_id: None,
image_id: None,
image_version: None,
});
}
let variant_id = opt(identifier_string_parser)
.context(StrContext::Label("optional VOA OS VARIANT_ID"))
.parse_next(input)?;
if opt(":").parse_next(input)?.is_none() {
return Ok(Self {
id,
version_id,
variant_id,
image_id: None,
image_version: None,
});
}
let image_id = opt(identifier_string_parser)
.context(StrContext::Label("optional VOA OS IMAGE_ID"))
.parse_next(input)?;
if opt(":").parse_next(input)?.is_none() {
return Ok(Self {
id,
version_id,
variant_id,
image_id,
image_version: None,
});
}
let image_version = opt(identifier_string_parser)
.context(StrContext::Label("optional VOA OS IMAGE_VERSION"))
.parse_next(input)?;
not(":")
.context(StrContext::Expected(
winnow::error::StrContextValue::Description("no further colon"),
))
.parse_next(input)?;
Ok(Self {
id,
version_id,
variant_id,
image_id,
image_version,
})
}
}
impl Display for Os {
fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
write!(fmt, "{}", self.os_to_string())
}
}
impl FromStr for Os {
type Err = crate::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Os::parser.parse(s)?)
}
}
impl TryFrom<&OsStr> for Os {
type Error = crate::Error;
fn try_from(value: &OsStr) -> Result<Self, Self::Error> {
Self::from_str(value.to_string_lossy().as_ref())
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use testresult::TestResult;
use super::*;
#[rstest]
#[case(Os::new("arch".parse()?, None, None, None, None), "arch")]
#[case(
Os::new(
"debian".parse()?,
Some("12".parse()?),
Some("workstation".parse()?),
Some("cashier-system".parse()?),
Some("1.0.0".parse()?),
),
"debian:12:workstation:cashier-system:1.0.0"
)]
#[case(
Os::new(
"debian".parse()?,
Some("12".parse()?),
Some("workstation".parse()?),
None,
None,
),
"debian:12:workstation"
)]
#[case(
Os::new(
"debian".parse()?,
None,
None,
None,
Some("25.01".parse()?),
),
"debian::::25.01"
)]
fn os_display(#[case] os: Os, #[case] display: &str) -> testresult::TestResult {
assert_eq!(format!("{os}"), display);
Ok(())
}
#[rstest]
#[case::id_with_trailing_colons("id::::", Some("id"))]
#[case::all_components("id:version_id:variant_id:image_id:image_version", None)]
#[case::only_id("id", None)]
#[case::only_id_and_version_id("id:version_id", None)]
#[case::only_id_version_id_and_variant_id("id:version_id:variant_id", None)]
#[case::all_but_image_version("id:version_id:variant_id:image_id", None)]
#[case::all_but_image_id_and_image_version("id:version_id:variant_id", None)]
#[case::all_but_image_id_and_image_version("id:version_id:variant_id", None)]
#[case::only_id_and_variant_id("id::variant_id", None)]
#[case::only_id_and_image_id("id:::image_id", None)]
#[case::only_id_and_image_version("id::::image_version", None)]
fn os_from_str_valid_chars(
#[case] input: &str,
#[case] string_repr: Option<&str>,
) -> TestResult {
match Os::from_str(input) {
Ok(id_string) => {
assert_eq!(id_string.to_string(), string_repr.unwrap_or(input));
Ok(())
}
Err(error) => {
panic!("Should have succeeded to parse {input} but failed: {error}");
}
}
}
#[rstest]
#[case::all_components_trailing_colon(
"id:version_id:variant_id:image_id:image_version:other",
"id:version_id:variant_id:image_id:image_version:other\n ^\nexpected no further colon"
)]
#[case::all_caps_id(
"ID",
"ID\n^\ninvalid VOA OS ID\nexpected lowercase alphanumeric ASCII characters, `_`, `-`, `.`"
)]
#[case::all_caps_id(
"üd",
"üd\n^\ninvalid VOA OS ID\nexpected lowercase alphanumeric ASCII characters, `_`, `-`, `.`"
)]
fn os_from_str_invalid_chars(#[case] input: &str, #[case] error_msg: &str) -> TestResult {
match Os::from_str(input) {
Ok(id_string) => {
panic!("Should have failed to parse {input} but succeeded: {id_string}");
}
Err(error) => {
assert_eq!(error.to_string(), format!("Parser error:\n{error_msg}"));
Ok(())
}
}
}
}