diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8732b4f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "cSpell.words": [ + "apimachinery", + "thiserror" + ] +} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8ee3b0e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "k8s-quantity" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +k8s-openapi = { version = "0.17.0", default-features = false, features = [ + "v1_26", +] } +nom = "7.1.3" +rust_decimal = "1.28.1" +thiserror = "1.0.38" diff --git a/README.md b/README.md index 3446eb0..c5f79fa 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # k8s-quantity-rs + Kubernetes quantity arithmetics implemented in Rust diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..85974b7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,24 @@ +#![forbid(unsafe_code)] + +mod parser; + +use k8s_openapi::apimachinery::pkg::api::resource::Quantity; +use parser::parse_quantity_string; + +pub use parser::{ParseQuantityError, ParsedQuantity}; + +impl TryFrom for ParsedQuantity { + type Error = ParseQuantityError; + + fn try_from(value: Quantity) -> Result { + (&value).try_into() + } +} + +impl TryFrom<&Quantity> for ParsedQuantity { + type Error = ParseQuantityError; + + fn try_from(value: &Quantity) -> Result { + parse_quantity_string(&value.0).map(|(_, quantity)| quantity) + } +} diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..5ee9f39 --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,318 @@ +use std::{fmt::Display, ops::Add}; + +use nom::{ + branch::alt, + bytes::complete::tag, + character::complete::one_of, + combinator::{eof, opt}, + number::complete::double, + IResult, +}; +use rust_decimal::prelude::*; +use thiserror::Error; + +// --- Errors --- + +#[derive(Debug, Error)] +pub enum ParseQuantityError { + #[error("empty string")] + EmptyString, + + #[error("parsing failed")] + ParsingFailed(#[from] nom::Err>), +} + +// --- Types --- + +// - Parser Quantity - + +#[derive(Debug, Clone)] +pub struct ParsedQuantity { + // The actual value of the quantity + value: Decimal, + // Scale used to indicate the base-10 exponent of the value + scale: Scale, + // Used to indicate the format of the suffix used + format: Format, + + // The string representation of this quantity to avoid recalculation + string_representation: String, +} + +impl Display for ParsedQuantity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.string_representation,) + } +} + +impl Add for ParsedQuantity { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + todo!() + } +} + +// - Format - + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Format { + BinarySI, // e.g., 12Mi (12 * 2^20) + DecimalExponent, // e.g., 12e6 + DecimalSI, // e.g., 12M (12 * 10^6) +} + +// - Scale + +/// Scale is used for getting and setting the base-10 scaled value. Base-2 +/// scales are omitted for mathematical simplicity. +#[derive(PartialEq, Eq, Debug, Clone)] +enum Scale { + Milli, + One, + Kilo, + Mega, + Giga, + Tera, + Peta, + Exa, +} + +// Returns a tuple indicating wether the exponent is positive and the exponent +// itself +impl From for (bool, u32) { + fn from(value: Scale) -> Self { + // TODO: https://en.wikipedia.org/wiki/Kilobyte + match value { + Scale::Milli => (false, 1), + Scale::One => (true, 0), + Scale::Kilo => (true, 1), + Scale::Mega => (true, 2), + Scale::Giga => (true, 3), + Scale::Tera => (true, 4), + Scale::Peta => (true, 5), + Scale::Exa => (true, 6), + } + } +} + +// --- Functions --- + +fn scale_format_to_string(scale: &Scale, format: &Format) -> String { + match format { + Format::BinarySI => match scale { + Scale::Milli => "".to_owned(), + Scale::One => "".to_owned(), + Scale::Kilo => "Ki".to_owned(), + Scale::Mega => "MI".to_owned(), + Scale::Giga => "Gi".to_owned(), + Scale::Tera => "Ti".to_owned(), + Scale::Peta => "Pi".to_owned(), + Scale::Exa => "Ei".to_owned(), + }, + Format::DecimalSI => match scale { + Scale::Milli => "m".to_owned(), + Scale::One => "".to_owned(), + Scale::Kilo => "k".to_owned(), + Scale::Mega => "M".to_owned(), + Scale::Giga => "G".to_owned(), + Scale::Tera => "T".to_owned(), + Scale::Peta => "P".to_owned(), + Scale::Exa => "E".to_owned(), + }, + Format::DecimalExponent => "e".to_owned(), + } +} + +// --- Parsers --- + +pub(crate) fn parse_quantity_string( + input: &str, +) -> Result<(&str, ParsedQuantity), ParseQuantityError> { + if input.is_empty() { + return Err(ParseQuantityError::EmptyString); + } + + let original_input = input.to_owned(); + + let error_mapper = |err: nom::Err>| match err { + nom::Err::Incomplete(err) => nom::Err::Incomplete(err), + nom::Err::Error(err) => nom::Err::Error(nom::error::Error { + input: err.input.to_owned(), + code: err.code, + }), + nom::Err::Failure(err) => nom::Err::Failure(nom::error::Error { + input: err.input.to_owned(), + code: err.code, + }), + }; + + let (input, signed_number) = parse_signed_number(input).map_err(error_mapper)?; + let (input, (format, scale)) = parse_suffix(input).map_err(error_mapper)?; + let (input, _) = eof(input).map_err(error_mapper)?; + + Ok(( + input, + ParsedQuantity { + format, + scale, + string_representation: original_input, + value: Decimal::from_f64(signed_number).unwrap_or_default(), + }, + )) +} + +fn parse_signed_number(input: &str) -> IResult<&str, f64> { + // Default to true + let (input, positive) = + opt(parse_sign)(input).map(|(input, positive)| (input, positive.unwrap_or(true)))?; + // Default num to 0.0 + let (input, num) = opt(double)(input).map(|(input, num)| (input, num.unwrap_or(0.0)))?; + + Ok((input, if positive { num } else { -num })) +} + +fn parse_suffix(input: &str) -> IResult<&str, (Format, Scale)> { + // If the input is empty, then in a previous step we have already parsed the number + // and we can classify this as a decimal exponent, yet one is going to + // set this to a decimal si for compatibility reasons + if input.is_empty() { + return Ok((input, (Format::DecimalSI, Scale::One))); + } + + // In the case that the string is not empty, we need to parse the suffix + let (input, si) = alt(( + tag("Ki"), + tag("Mi"), + tag("Gi"), + tag("Ti"), + tag("Pi"), + tag("Ei"), + tag("m"), + tag("k"), + tag("M"), + tag("G"), + tag("T"), + tag("P"), + tag("E"), + ))(input)?; + + Ok(( + input, + match si { + "Ki" => (Format::BinarySI, Scale::Kilo), + "Mi" => (Format::BinarySI, Scale::Mega), + "Gi" => (Format::BinarySI, Scale::Giga), + "Ti" => (Format::BinarySI, Scale::Tera), + "Pi" => (Format::BinarySI, Scale::Peta), + "Ei" => (Format::BinarySI, Scale::Exa), + // + "m" => (Format::DecimalSI, Scale::Milli), + "" => (Format::DecimalSI, Scale::One), + "k" => (Format::DecimalSI, Scale::Kilo), + "M" => (Format::DecimalSI, Scale::Mega), + "G" => (Format::DecimalSI, Scale::Giga), + "T" => (Format::DecimalSI, Scale::Tera), + "P" => (Format::DecimalSI, Scale::Peta), + "E" => (Format::DecimalSI, Scale::Exa), + // + _ => (Format::DecimalSI, Scale::One), + }, + )) +} + +fn parse_sign(input: &str) -> IResult<&str, bool> { + let (input, sign) = one_of("+-")(input)?; + Ok((input, sign == '+')) +} + +// --- Tests --- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_quantity_string_parsing() { + let quantity = parse_quantity_string("1.25Ki"); + assert!(quantity.is_ok()); + + let quantity = quantity.unwrap().1; + assert_eq!(quantity.value, Decimal::new(125, 2)); + assert_eq!(quantity.scale, Scale::Kilo); + assert_eq!(quantity.format, Format::BinarySI); + + assert_eq!(quantity.to_string(), "1.25Ki".to_owned()); + } + + #[test] + fn test_scientific_notation() { + let quantity = parse_quantity_string("1.25e3"); + assert!(quantity.is_ok()); + + let quantity = quantity.unwrap().1; + assert_eq!(quantity.value, Decimal::new(1250, 0)); + assert_eq!(quantity.scale, Scale::One); + // FIXME: This should probably be a decimal exponent format + // but that would require rewriting the way it's handled in the parser + // and for now this should be good enough + assert_eq!(quantity.format, Format::DecimalSI); + + assert_eq!(quantity.to_string(), "1.25e3".to_owned()); + } + + #[test] + fn test_decimal_notation() { + let quantity = parse_quantity_string("1250000"); + assert!(quantity.is_ok()); + + let quantity = quantity.unwrap().1; + assert_eq!(quantity.value, Decimal::new(1250000, 0)); + assert_eq!(quantity.scale, Scale::One); + assert_eq!(quantity.format, Format::DecimalSI); + + assert_eq!(quantity.to_string(), "1250000".to_owned()); + } + + #[test] + fn test_incorrect_quantity() { + let quantity = parse_quantity_string("1.25.123K"); + assert!(quantity.is_err()); + } + + #[test] + fn test_zero_quantity() { + let quantity = parse_quantity_string("0"); + assert!(quantity.is_ok()); + + let quantity = quantity.unwrap().1; + assert_eq!(quantity.value, Decimal::new(0, 0)); + assert_eq!(quantity.scale, Scale::One); + assert_eq!(quantity.format, Format::DecimalSI); + + assert_eq!(quantity.to_string(), "0".to_owned()); + } + + #[test] + fn test_milli_quantity() { + let quantity = parse_quantity_string("100m"); + assert!(quantity.is_ok()); + + let quantity = quantity.unwrap().1; + assert_eq!(quantity.value, Decimal::new(100, 0)); + assert_eq!(quantity.scale, Scale::Milli); + assert_eq!(quantity.format, Format::DecimalSI); + + assert_eq!(quantity.to_string(), "100m"); + } + + #[test] + fn test_quantity_addition() { + let q1 = parse_quantity_string("1Ki").unwrap().1; + let q2 = parse_quantity_string("2Ki").unwrap().1; + + let q3 = q1 + q2; + + assert_eq!(q3.to_string(), "3Ki"); + } +}