diff --git a/CHANGELOG.md b/CHANGELOG.md index b27e7760f..96a6709b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,10 @@ Please see the relevant crate changelogs: +- [k8s-version](./crates/k8s-version/CHANGELOG.md) - [stackable-certs](./crates/stackable-certs/CHANGELOG.md) - [stackable-operator](./crates/stackable-operator/CHANGELOG.md) - [stackable-operator-derive](./crates/stackable-operator-derive/CHANGELOG.md) - [stackable-telemetry](./crates/stackable-telemetry/CHANGELOG.md) +- [stackable-versioned](./crates/stackable-versioned/CHANGELOG.md) - [stackable-webhook](./crates/stackable-webhook/CHANGELOG.md) diff --git a/Cargo.toml b/Cargo.toml index 6c2b394a1..dc0252be1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ futures = "0.3.30" futures-util = "0.3.30" hyper = { version = "1.3.1", features = ["full"] } hyper-util = "0.1.3" -json-patch = "1.2.0" +json-patch = "1.4.0" k8s-openapi = { version = "0.21.1", default-features = false, features = ["schemars", "v1_29"] } # We use rustls instead of openssl for easier portablitly, e.g. so that we can build stackablectl without the need to vendor (build from source) openssl kube = { version = "0.90.0", default-features = false, features = ["client", "jsonpatch", "runtime", "derive", "rustls-tls"] } @@ -46,6 +46,7 @@ rand_core = "0.6.4" regex = "1.10.4" rsa = { version = "0.9.6", features = ["sha2"] } rstest = "0.19.0" +rstest_reuse = "0.6.0" schemars = { version = "0.8.16", features = ["url"] } semver = "1.0.22" serde = { version = "1.0.198", features = ["derive"] } diff --git a/crates/k8s-version/CHANGELOG.md b/crates/k8s-version/CHANGELOG.md new file mode 100644 index 000000000..6d303b20d --- /dev/null +++ b/crates/k8s-version/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] diff --git a/crates/k8s-version/Cargo.toml b/crates/k8s-version/Cargo.toml new file mode 100644 index 000000000..94f4da540 --- /dev/null +++ b/crates/k8s-version/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "k8s-version" +version = "0.1.0" +authors.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true + +[features] +darling = ["dep:darling"] + +[dependencies] +darling = { workspace = true, optional = true } +lazy_static.workspace = true +regex.workspace = true +snafu.workspace = true + +[dev-dependencies] +rstest.workspace = true +rstest_reuse.workspace = true +quote.workspace = true +proc-macro2.workspace = true +syn.workspace = true diff --git a/crates/k8s-version/README.md b/crates/k8s-version/README.md new file mode 100644 index 000000000..b60760da0 --- /dev/null +++ b/crates/k8s-version/README.md @@ -0,0 +1,9 @@ +# k8s-version + +A small helper crate to parse and validate Kubernetes resource API versions. + +```rust +use k8s_version::ApiVersion; + +let api_version = ApiVersion::from_str("extensions/v1beta1")?; +``` diff --git a/crates/k8s-version/src/api_version.rs b/crates/k8s-version/src/api_version.rs new file mode 100644 index 000000000..527f7a6cc --- /dev/null +++ b/crates/k8s-version/src/api_version.rs @@ -0,0 +1,158 @@ +use std::{cmp::Ordering, fmt::Display, str::FromStr}; + +use snafu::{ResultExt, Snafu}; + +#[cfg(feature = "darling")] +use darling::FromMeta; + +use crate::{Group, ParseGroupError, ParseVersionError, Version}; + +/// Error variants which can be encountered when creating a new [`ApiVersion`] +/// from unparsed input. +#[derive(Debug, PartialEq, Snafu)] +pub enum ParseApiVersionError { + #[snafu(display("failed to parse version"))] + ParseVersion { source: ParseVersionError }, + + #[snafu(display("failed to parse group"))] + ParseGroup { source: ParseGroupError }, +} + +/// A Kubernetes API version, following the `(/)` format. +/// +/// The `` string must follow the DNS label format defined in the +/// [Kubernetes design proposals archive][1]. The `` string must be lower +/// case and must be a valid DNS subdomain. +/// +/// ### See +/// +/// - +/// - +/// - +/// +/// [1]: https://github.com/kubernetes/design-proposals-archive/blob/main/architecture/identifiers.md#definitions +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct ApiVersion { + pub group: Option, + pub version: Version, +} + +impl FromStr for ApiVersion { + type Err = ParseApiVersionError; + + fn from_str(input: &str) -> Result { + let (group, version) = if let Some((group, version)) = input.split_once('/') { + let group = Group::from_str(group).context(ParseGroupSnafu)?; + + ( + Some(group), + Version::from_str(version).context(ParseVersionSnafu)?, + ) + } else { + (None, Version::from_str(input).context(ParseVersionSnafu)?) + }; + + Ok(Self { group, version }) + } +} + +impl PartialOrd for ApiVersion { + fn partial_cmp(&self, other: &Self) -> Option { + match self.group.partial_cmp(&other.group) { + Some(Ordering::Equal) => {} + _ => return None, + } + self.version.partial_cmp(&other.version) + } +} + +impl Display for ApiVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.group { + Some(group) => write!(f, "{group}/{version}", version = self.version), + None => write!(f, "{version}", version = self.version), + } + } +} + +#[cfg(feature = "darling")] +impl FromMeta for ApiVersion { + fn from_string(value: &str) -> darling::Result { + Self::from_str(value).map_err(darling::Error::custom) + } +} + +impl ApiVersion { + /// Create a new Kubernetes API version. + pub fn new(group: Option, version: Version) -> Self { + Self { group, version } + } + + /// Try to create a new Kubernetes API version based on the unvalidated + /// `group` string. + pub fn try_new(group: Option<&str>, version: Version) -> Result { + let group = group + .map(|g| g.parse()) + .transpose() + .context(ParseGroupSnafu)?; + + Ok(Self { group, version }) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::Level; + + use rstest::rstest; + + #[cfg(feature = "darling")] + use quote::quote; + + #[cfg(feature = "darling")] + fn parse_meta(tokens: proc_macro2::TokenStream) -> ::std::result::Result { + let attribute: syn::Attribute = syn::parse_quote!(#[#tokens]); + Ok(attribute.meta) + } + + #[rstest] + #[case("extensions/v1beta1", ApiVersion { group: Some("extensions".parse().unwrap()), version: Version { major: 1, level: Some(Level::Beta(1)) } })] + #[case("v1beta1", ApiVersion { group: None, version: Version { major: 1, level: Some(Level::Beta(1)) } })] + #[case("v1", ApiVersion { group: None, version: Version { major: 1, level: None } })] + fn valid_api_version(#[case] input: &str, #[case] expected: ApiVersion) { + let api_version = ApiVersion::from_str(input).expect("valid Kubernetes api version"); + assert_eq!(api_version, expected); + } + + #[rstest] + #[case("extensions/beta1", ParseApiVersionError::ParseVersion { source: ParseVersionError::InvalidFormat })] + #[case("/v1beta1", ParseApiVersionError::ParseGroup { source: ParseGroupError::Empty })] + fn invalid_api_version(#[case] input: &str, #[case] error: ParseApiVersionError) { + let err = ApiVersion::from_str(input).expect_err("invalid Kubernetes api versions"); + assert_eq!(err, error); + } + + #[rstest] + #[case(Version {major: 1, level: Some(Level::Alpha(2))}, Version {major: 1, level: Some(Level::Alpha(1))}, Ordering::Greater)] + #[case(Version {major: 1, level: Some(Level::Alpha(1))}, Version {major: 1, level: Some(Level::Alpha(1))}, Ordering::Equal)] + #[case(Version {major: 1, level: Some(Level::Alpha(1))}, Version {major: 1, level: Some(Level::Alpha(2))}, Ordering::Less)] + #[case(Version {major: 1, level: None}, Version {major: 1, level: Some(Level::Alpha(2))}, Ordering::Greater)] + #[case(Version {major: 1, level: None}, Version {major: 1, level: Some(Level::Beta(2))}, Ordering::Greater)] + #[case(Version {major: 1, level: None}, Version {major: 1, level: None}, Ordering::Equal)] + #[case(Version {major: 1, level: None}, Version {major: 2, level: None}, Ordering::Less)] + fn partial_ord(#[case] input: Version, #[case] other: Version, #[case] expected: Ordering) { + assert_eq!(input.partial_cmp(&other), Some(expected)); + } + + #[cfg(feature = "darling")] + #[rstest] + #[case(quote!(ignore = "extensions/v1beta1"), ApiVersion { group: Some("extensions".parse().unwrap()), version: Version { major: 1, level: Some(Level::Beta(1)) } })] + #[case(quote!(ignore = "v1beta1"), ApiVersion { group: None, version: Version { major: 1, level: Some(Level::Beta(1)) } })] + #[case(quote!(ignore = "v1"), ApiVersion { group: None, version: Version { major: 1, level: None } })] + fn from_meta(#[case] input: proc_macro2::TokenStream, #[case] expected: ApiVersion) { + let meta = parse_meta(input).expect("valid attribute tokens"); + let api_version = ApiVersion::from_meta(&meta).expect("version must parse from attribute"); + assert_eq!(api_version, expected); + } +} diff --git a/crates/k8s-version/src/group.rs b/crates/k8s-version/src/group.rs new file mode 100644 index 000000000..c4d2658b8 --- /dev/null +++ b/crates/k8s-version/src/group.rs @@ -0,0 +1,67 @@ +use std::{fmt, ops::Deref, str::FromStr}; + +use lazy_static::lazy_static; +use regex::Regex; +use snafu::{ensure, Snafu}; + +const MAX_GROUP_LENGTH: usize = 253; + +lazy_static! { + static ref API_GROUP_REGEX: Regex = + Regex::new(r"^(?:(?:[a-z0-9][a-z0-9-]{0,61}[a-z0-9])\.?)+$") + .expect("failed to compile API group regex"); +} + +/// Error variants which can be encountered when creating a new [`Group`] from +/// unparsed input. +#[derive(Debug, PartialEq, Snafu)] +pub enum ParseGroupError { + #[snafu(display("group must not be empty"))] + Empty, + + #[snafu(display("group must not be longer than 253 characters"))] + TooLong, + + #[snafu(display("group must be a valid DNS subdomain"))] + InvalidFormat, +} + +/// A validated Kubernetes group. +/// +/// The group string must follow these rules: +/// +/// - must be non-empty +/// - must only contain lower case characters +/// - and must be a valid DNS subdomain +/// +/// ### See +/// +/// - +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd)] +pub struct Group(String); + +impl FromStr for Group { + type Err = ParseGroupError; + + fn from_str(group: &str) -> Result { + ensure!(!group.is_empty(), EmptySnafu); + ensure!(group.len() <= MAX_GROUP_LENGTH, TooLongSnafu); + ensure!(API_GROUP_REGEX.is_match(group), InvalidFormatSnafu); + + Ok(Self(group.to_string())) + } +} + +impl fmt::Display for Group { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Deref for Group { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/crates/k8s-version/src/level.rs b/crates/k8s-version/src/level.rs new file mode 100644 index 000000000..ae446ebe1 --- /dev/null +++ b/crates/k8s-version/src/level.rs @@ -0,0 +1,208 @@ +use std::{ + cmp::Ordering, + fmt::Display, + num::ParseIntError, + ops::{Add, AddAssign, Sub, SubAssign}, + str::FromStr, +}; + +use lazy_static::lazy_static; +use regex::Regex; +use snafu::{OptionExt, ResultExt, Snafu}; + +#[cfg(feature = "darling")] +use darling::FromMeta; + +lazy_static! { + static ref LEVEL_REGEX: Regex = Regex::new(r"^(?P[a-z]+)(?P\d+)$") + .expect("failed to compile level regex"); +} + +/// Error variants which can be encountered when creating a new [`Level`] from +/// unparsed input. +#[derive(Debug, PartialEq, Snafu)] +pub enum ParseLevelError { + #[snafu(display("invalid level format, expected alpha|beta"))] + InvalidFormat, + + #[snafu(display("failed to parse level version"))] + ParseVersion { source: ParseIntError }, + + #[snafu(display("unknown level identifier, expected alpha|beta"))] + UnknownIdentifier, +} + +/// A minor Kubernetes resource version with the `beta/alpha` format. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub enum Level { + /// Alpha-level minor version, `alpha`. + Alpha(u64), + + /// Beta-level minor version, `beta`. + Beta(u64), +} + +impl FromStr for Level { + type Err = ParseLevelError; + + fn from_str(input: &str) -> Result { + let captures = LEVEL_REGEX.captures(input).context(InvalidFormatSnafu)?; + + let identifier = captures + .name("identifier") + .expect("internal error: check that the correct match label is specified") + .as_str(); + + let version = captures + .name("version") + .expect("internal error: check that the correct match label is specified") + .as_str() + .parse::() + .context(ParseVersionSnafu)?; + + match identifier { + "alpha" => Ok(Self::Alpha(version)), + "beta" => Ok(Self::Beta(version)), + _ => UnknownIdentifierSnafu.fail(), + } + } +} + +impl PartialOrd for Level { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Level { + fn cmp(&self, other: &Self) -> Ordering { + match self { + Level::Alpha(lhs) => match other { + Level::Alpha(rhs) => lhs.cmp(rhs), + Level::Beta(_) => Ordering::Less, + }, + Level::Beta(lhs) => match other { + Level::Alpha(_) => Ordering::Greater, + Level::Beta(rhs) => lhs.cmp(rhs), + }, + } + } +} + +impl Add for Level +where + T: Into, +{ + type Output = Level; + + fn add(self, rhs: T) -> Self::Output { + match self { + Level::Alpha(lhs) => Level::Alpha(lhs + rhs.into()), + Level::Beta(lhs) => Level::Beta(lhs + rhs.into()), + } + } +} + +impl AddAssign for Level +where + T: Into, +{ + fn add_assign(&mut self, rhs: T) { + match self { + Level::Alpha(lhs) => *lhs + rhs.into(), + Level::Beta(lhs) => *lhs + rhs.into(), + }; + } +} + +impl Sub for Level +where + T: Into, +{ + type Output = Level; + + fn sub(self, rhs: T) -> Self::Output { + match self { + Level::Alpha(lhs) => Level::Alpha(lhs - rhs.into()), + Level::Beta(lhs) => Level::Beta(lhs - rhs.into()), + } + } +} + +impl SubAssign for Level +where + T: Into, +{ + fn sub_assign(&mut self, rhs: T) { + match self { + Level::Alpha(lhs) => *lhs - rhs.into(), + Level::Beta(lhs) => *lhs - rhs.into(), + }; + } +} + +impl Display for Level { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Level::Alpha(alpha) => write!(f, "alpha{}", alpha), + Level::Beta(beta) => write!(f, "beta{}", beta), + } + } +} + +#[cfg(feature = "darling")] +impl FromMeta for Level { + fn from_string(value: &str) -> darling::Result { + Self::from_str(value).map_err(darling::Error::custom) + } +} + +#[cfg(test)] +mod test { + use rstest::rstest; + use rstest_reuse::*; + + use super::*; + + #[cfg(feature = "darling")] + use quote::quote; + + #[cfg(feature = "darling")] + fn parse_meta(tokens: proc_macro2::TokenStream) -> ::std::result::Result { + let attribute: syn::Attribute = syn::parse_quote!(#[#tokens]); + Ok(attribute.meta) + } + + #[template] + #[rstest] + #[case(Level::Beta(1), Level::Alpha(1), Ordering::Greater)] + #[case(Level::Alpha(1), Level::Beta(1), Ordering::Less)] + #[case(Level::Alpha(2), Level::Alpha(1), Ordering::Greater)] + #[case(Level::Alpha(2), Level::Alpha(2), Ordering::Equal)] + #[case(Level::Alpha(1), Level::Alpha(2), Ordering::Less)] + #[case(Level::Beta(2), Level::Beta(1), Ordering::Greater)] + #[case(Level::Beta(2), Level::Beta(2), Ordering::Equal)] + #[case(Level::Beta(1), Level::Beta(2), Ordering::Less)] + fn ord_cases(#[case] input: Level, #[case] other: Level, #[case] expected: Ordering) {} + + #[apply(ord_cases)] + fn ord(input: Level, other: Level, expected: Ordering) { + assert_eq!(input.cmp(&other), expected) + } + + #[apply(ord_cases)] + fn partial_ord(input: Level, other: Level, expected: Ordering) { + assert_eq!(input.partial_cmp(&other), Some(expected)) + } + + #[cfg(feature = "darling")] + #[rstest] + #[case(quote!(ignore = "alpha12"), Level::Alpha(12))] + #[case(quote!(ignore = "alpha1"), Level::Alpha(1))] + #[case(quote!(ignore = "beta1"), Level::Beta(1))] + fn from_meta(#[case] input: proc_macro2::TokenStream, #[case] expected: Level) { + let meta = parse_meta(input).expect("valid attribute tokens"); + let version = Level::from_meta(&meta).expect("level must parse from attribute"); + assert_eq!(version, expected); + } +} diff --git a/crates/k8s-version/src/lib.rs b/crates/k8s-version/src/lib.rs new file mode 100644 index 000000000..034071f2e --- /dev/null +++ b/crates/k8s-version/src/lib.rs @@ -0,0 +1,62 @@ +//! This library provides strongly-typed and validated Kubernetes API version +//! definitions. Versions consist of three major components: the optional group, +//! the mandatory major version and the optional level. The format can be +//! described by `(/)`, with `` being defined as +//! `v(alpha|beta)`. +//! +//! ## Usage +//! +//! ### Parsing from [`str`] +//! +//! Versions can be parsed and validated from [`str`] using Rust's standard +//! [`FromStr`](std::str::FromStr) trait. +//! +//! ``` +//! # use std::str::FromStr; +//! use k8s_version::ApiVersion; +//! +//! let api_version = ApiVersion::from_str("extensions/v1beta1").unwrap(); +//! +//! // Or using .parse() +//! let api_version: ApiVersion = "extensions/v1beta1".parse().unwrap(); +//! ``` +//! +//! ### Constructing +//! +//! Alternatively, they can be constructed programatically using the +//! [`ApiVersion::new()`] and [`ApiVersion::try_new()`] functions. +//! +//! ``` +//! # use std::str::FromStr; +//! use k8s_version::{ApiVersion, Version, Level, Group}; +//! +//! let version = Version::new(1, Some(Level::Beta(1))); +//! let group = Group::from_str("extension").unwrap(); +//! let api_version = ApiVersion::new(Some(group), version); +//! +//! assert_eq!(api_version.to_string(), "extension/v1beta1"); +//! +//! // Or using ::try_new() +//! let version = Version::new(1, Some(Level::Beta(1))); +//! let api_version = ApiVersion::try_new( +//! Some("extension"), +//! version +//! ).unwrap(); +//! +//! assert_eq!(api_version.to_string(), "extension/v1beta1"); +//! ``` + +// NOTE (@Techassi): Fixed in https://github.com/la10736/rstest/pull/244 but not +// yet released. +#[cfg(test)] +use rstest_reuse::{self}; + +mod api_version; +mod group; +mod level; +mod version; + +pub use api_version::*; +pub use group::*; +pub use level::*; +pub use version::*; diff --git a/crates/k8s-version/src/version.rs b/crates/k8s-version/src/version.rs new file mode 100644 index 000000000..3a73583c7 --- /dev/null +++ b/crates/k8s-version/src/version.rs @@ -0,0 +1,184 @@ +use std::{cmp::Ordering, fmt::Display, num::ParseIntError, str::FromStr}; + +use lazy_static::lazy_static; +use regex::Regex; +use snafu::{OptionExt, ResultExt, Snafu}; + +#[cfg(feature = "darling")] +use darling::FromMeta; + +use crate::{Level, ParseLevelError}; + +lazy_static! { + static ref VERSION_REGEX: Regex = + Regex::new(r"^v(?P\d+)(?P[a-z0-9][a-z0-9-]{0,60}[a-z0-9])?$") + .expect("failed to compile version regex"); +} + +/// Error variants which can be encountered when creating a new [`Version`] from +/// unparsed input. +#[derive(Debug, PartialEq, Snafu)] +pub enum ParseVersionError { + #[snafu(display("invalid version format. Input is empty, contains non-ASCII characters or contains more than 63 characters"))] + InvalidFormat, + + #[snafu(display("failed to parse major version"))] + ParseMajorVersion { source: ParseIntError }, + + #[snafu(display("failed to parse version level"))] + ParseLevel { source: ParseLevelError }, +} + +/// A Kubernetes resource version, following the +/// `v(alpha)` format. +/// +/// The version must follow the DNS label format defined in the +/// [Kubernetes design proposals archive][1]. +/// +/// ### See +/// +/// - +/// - +/// +/// [1]: https://github.com/kubernetes/design-proposals-archive/blob/main/architecture/identifiers.md#definitions +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub struct Version { + pub major: u64, + pub level: Option, +} + +impl FromStr for Version { + type Err = ParseVersionError; + + fn from_str(input: &str) -> Result { + let captures = VERSION_REGEX.captures(input).context(InvalidFormatSnafu)?; + + let major = captures + .name("major") + .expect("internal error: check that the correct match label is specified") + .as_str() + .parse::() + .context(ParseMajorVersionSnafu)?; + + if let Some(level) = captures.name("level") { + let level = Level::from_str(level.as_str()).context(ParseLevelSnafu)?; + + Ok(Self { + level: Some(level), + major, + }) + } else { + Ok(Self { major, level: None }) + } + } +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + match self.major.cmp(&other.major) { + Ordering::Equal => {} + ord => return ord, + } + + match (&self.level, &other.level) { + (Some(lhs), Some(rhs)) => lhs.cmp(rhs), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + } + } +} + +impl Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.level { + Some(level) => write!(f, "v{major}{level}", major = self.major), + None => write!(f, "v{major}", major = self.major), + } + } +} + +#[cfg(feature = "darling")] +impl FromMeta for Version { + fn from_string(value: &str) -> darling::Result { + Self::from_str(value).map_err(darling::Error::custom) + } +} + +impl Version { + pub fn new(major: u64, level: Option) -> Self { + Self { major, level } + } +} + +#[cfg(test)] +mod test { + use rstest::rstest; + use rstest_reuse::{apply, template}; + + use super::*; + + #[cfg(feature = "darling")] + use quote::quote; + + #[cfg(feature = "darling")] + fn parse_meta(tokens: proc_macro2::TokenStream) -> ::std::result::Result { + let attribute: syn::Attribute = syn::parse_quote!(#[#tokens]); + Ok(attribute.meta) + } + + #[template] + #[rstest] + #[case(Version {major: 1, level: Some(Level::Beta(1))}, Version {major: 1, level: Some(Level::Alpha(1))}, Ordering::Greater)] + #[case(Version {major: 1, level: Some(Level::Alpha(1))}, Version {major: 1, level: Some(Level::Beta(1))}, Ordering::Less)] + #[case(Version {major: 1, level: Some(Level::Beta(1))}, Version {major: 1, level: Some(Level::Beta(1))}, Ordering::Equal)] + fn ord_cases(#[case] input: Version, #[case] other: Version, #[case] expected: Ordering) {} + + #[rstest] + #[case("v1alpha12", Version { major: 1, level: Some(Level::Alpha(12)) })] + #[case("v1alpha1", Version { major: 1, level: Some(Level::Alpha(1)) })] + #[case("v1beta1", Version { major: 1, level: Some(Level::Beta(1)) })] + #[case("v1", Version { major: 1, level: None })] + fn valid_version(#[case] input: &str, #[case] expected: Version) { + let version = Version::from_str(input).expect("valid Kubernetes version"); + assert_eq!(version, expected); + } + + #[rstest] + #[case("v1gamma12", ParseVersionError::ParseLevel { source: ParseLevelError::UnknownIdentifier })] + #[case("v1betä1", ParseVersionError::InvalidFormat)] + #[case("1beta1", ParseVersionError::InvalidFormat)] + #[case("", ParseVersionError::InvalidFormat)] + fn invalid_version(#[case] input: &str, #[case] error: ParseVersionError) { + let err = Version::from_str(input).expect_err("invalid Kubernetes version"); + assert_eq!(err, error) + } + + #[apply(ord_cases)] + fn ord(input: Version, other: Version, expected: Ordering) { + assert_eq!(input.cmp(&other), expected) + } + + #[apply(ord_cases)] + fn partial_ord(input: Version, other: Version, expected: Ordering) { + assert_eq!(input.partial_cmp(&other), Some(expected)) + } + + #[cfg(feature = "darling")] + #[rstest] + #[case(quote!(ignore = "v1alpha12"), Version { major: 1, level: Some(Level::Alpha(12)) })] + #[case(quote!(ignore = "v1alpha1"), Version { major: 1, level: Some(Level::Alpha(1)) })] + #[case(quote!(ignore = "v1beta1"), Version { major: 1, level: Some(Level::Beta(1)) })] + #[case(quote!(ignore = "v1"), Version { major: 1, level: None })] + fn from_meta(#[case] input: proc_macro2::TokenStream, #[case] expected: Version) { + let meta = parse_meta(input).expect("valid attribute tokens"); + let version = Version::from_meta(&meta).expect("version must parse from attribute"); + assert_eq!(version, expected); + } +} diff --git a/crates/stackable-versioned/CHANGELOG.md b/crates/stackable-versioned/CHANGELOG.md new file mode 100644 index 000000000..6d303b20d --- /dev/null +++ b/crates/stackable-versioned/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] diff --git a/crates/stackable-versioned/Cargo.toml b/crates/stackable-versioned/Cargo.toml new file mode 100644 index 000000000..f19d35557 --- /dev/null +++ b/crates/stackable-versioned/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "stackable-versioned" +version = "0.1.0" +authors.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true + +[lib] +proc-macro = true + +[dependencies] +k8s-version = { path = "../k8s-version", features = ["darling"] } + +darling.workspace = true +proc-macro2.workspace = true +syn.workspace = true +quote.workspace = true diff --git a/crates/stackable-versioned/src/attrs/container.rs b/crates/stackable-versioned/src/attrs/container.rs new file mode 100644 index 000000000..56ef87045 --- /dev/null +++ b/crates/stackable-versioned/src/attrs/container.rs @@ -0,0 +1,106 @@ +use std::{cmp::Ordering, collections::HashSet, ops::Deref}; + +use darling::{ + util::{Flag, SpannedValue}, + Error, FromDeriveInput, FromMeta, Result, +}; +use k8s_version::Version; + +/// This struct contains supported container attributes. +/// +/// Currently supported atttributes are: +/// +/// - `version`, which can occur one or more times. See [`VersionAttributes`]. +/// - `options`, which allow further customization of the generated code. See [`ContainerOptions`]. +#[derive(Clone, Debug, FromDeriveInput)] +#[darling( + attributes(versioned), + supports(struct_named), + forward_attrs(allow, doc, cfg, serde), + and_then = ContainerAttributes::validate +)] +pub(crate) struct ContainerAttributes { + #[darling(multiple, rename = "version")] + pub(crate) versions: SpannedValue>, + + #[darling(default)] + pub(crate) options: ContainerOptions, +} + +impl ContainerAttributes { + fn validate(mut self) -> Result { + // Most of the validation for individual version strings is done by the + // k8s-version crate. That's why the code below only checks that at + // least one version is defined, they are defined in order (to ensure + // code consistency) and that all declared versions are unique. + + // If there are no versions defined, the derive macro errors out. There + // should be at least one version if the derive macro is used. + if self.versions.is_empty() { + return Err(Error::custom( + "attribute `#[versioned()]` must contain at least one `version`", + ) + .with_span(&self.versions.span())); + } + + // NOTE (@Techassi): Do we even want to allow to opt-out of this? + + // Ensure that versions are defined in sorted (ascending) order to keep + // code consistent. + if !self.options.allow_unsorted.is_present() { + let original = self.versions.deref().clone(); + self.versions + .sort_by(|lhs, rhs| lhs.name.partial_cmp(&rhs.name).unwrap_or(Ordering::Equal)); + + for (index, version) in original.iter().enumerate() { + if version.name == self.versions.get(index).unwrap().name { + continue; + } + + return Err(Error::custom(format!( + "versions in `#[versioned()]` must be defined in ascending order (version `{name}` is misplaced)", + name = version.name + ))); + } + } + + // Ensure every version is unique and isn't declared multiple times. This + // is inspired by the itertools all_unique function. + let mut unique = HashSet::new(); + + for version in &*self.versions { + if !unique.insert(version.name) { + return Err(Error::custom(format!( + "attribute `#[versioned()]` contains duplicate version `name`: {name}", + name = version.name + )) + .with_span(&self.versions.span())); + } + } + + Ok(self) + } +} + +/// This struct contains supported version options. +/// +/// Supported options are: +/// +/// - `name` of the version, like `v1alpha1`. +/// - `deprecated` flag to mark that version as deprecated. +#[derive(Clone, Debug, FromMeta)] +pub(crate) struct VersionAttributes { + pub(crate) deprecated: Flag, + pub(crate) name: Version, +} + +/// This struct contains supported container options. +/// +/// Supported options are: +/// +/// - `allow_unsorted`, which allows declaring versions in unsorted order, +/// instead of enforcing ascending order. +#[derive(Clone, Debug, Default, FromMeta)] +pub(crate) struct ContainerOptions { + pub(crate) allow_unsorted: Flag, +} diff --git a/crates/stackable-versioned/src/attrs/field.rs b/crates/stackable-versioned/src/attrs/field.rs new file mode 100644 index 000000000..c42ad0783 --- /dev/null +++ b/crates/stackable-versioned/src/attrs/field.rs @@ -0,0 +1,246 @@ +use darling::{util::SpannedValue, Error, FromField, FromMeta}; +use k8s_version::Version; +use syn::{Field, Ident}; + +use crate::{attrs::container::ContainerAttributes, consts::DEPRECATED_PREFIX}; + +/// This struct describes all available field attributes, as well as the field +/// name to display better diagnostics. +/// +/// Data stored in this struct is validated using darling's `and_then` attribute. +/// During darlings validation, it is not possible to validate that action +/// versions match up with declared versions on the container. This validation +/// can be done using the associated [`FieldAttributes::validate_versions`] +/// function. +/// +/// ### Field Rules +/// +/// - A field can only ever be added once at most. A field not marked as 'added' +/// is part of the struct in every version until renamed or deprecated. +/// - A field can be renamed many times. That's why renames are stored in a +/// [`Vec`]. +/// - A field can only be deprecated once. A field not marked as 'deprecated' +/// will be included up until the latest version. +#[derive(Debug, FromField)] +#[darling( + attributes(versioned), + forward_attrs(allow, doc, cfg, serde), + and_then = FieldAttributes::validate +)] +pub(crate) struct FieldAttributes { + pub(crate) ident: Option, + pub(crate) added: Option, + + #[darling(multiple, rename = "renamed")] + pub(crate) renames: Vec, + + pub(crate) deprecated: Option, +} + +#[derive(Clone, Debug, FromMeta)] +pub(crate) struct AddedAttributes { + pub(crate) since: SpannedValue, +} + +#[derive(Clone, Debug, FromMeta)] +pub(crate) struct RenamedAttributes { + pub(crate) since: SpannedValue, + pub(crate) from: SpannedValue, +} + +#[derive(Clone, Debug, FromMeta)] +pub(crate) struct DeprecatedAttributes { + pub(crate) since: SpannedValue, + pub(crate) _note: SpannedValue, +} + +impl FieldAttributes { + /// This associated function is called by darling (see and_then attribute) + /// after it successfully parsed the attribute. This allows custom + /// validation of the attribute which extends the validation already in + /// place by darling. + /// + /// Internally, it calls out to other specialized validation functions. + fn validate(self) -> Result { + let mut errors = Error::accumulator(); + + errors.handle(self.validate_action_combinations()); + errors.handle(self.validate_action_order()); + errors.handle(self.validate_field_name()); + + // TODO (@Techassi): Add validation for renames so that renamed fields + // match up and form a continous chain (eg. foo -> bar -> baz). + + // TODO (@Techassi): Add hint if a field is added in the first version + // that it might be clever to remove the 'added' attribute. + + errors.finish()?; + Ok(self) + } + + /// This associated function is called by the top-level validation function + /// and validates that each field uses a valid combination of actions. + /// Invalid combinations are: + /// + /// - `added` and `deprecated` using the same version: A field cannot be + /// marked as added in a particular version and then marked as deprecated + /// immediately after. Fields must be included for at least one version + /// before being marked deprecated. + /// - `added` and `renamed` using the same version: The same reasoning from + /// above applies here as well. Fields must be included for at least one + /// version before being renamed. + /// - `renamed` and `deprecated` using the same version: Again, the same + /// rules from above apply here as well. + fn validate_action_combinations(&self) -> Result<(), Error> { + match (&self.added, &self.renames, &self.deprecated) { + (Some(added), _, Some(deprecated)) if *added.since == *deprecated.since => { + Err(Error::custom( + "field cannot be marked as `added` and `deprecated` in the same version", + ) + .with_span(&self.ident)) + } + (Some(added), renamed, _) if renamed.iter().any(|r| *r.since == *added.since) => { + Err(Error::custom( + "field cannot be marked as `added` and `renamed` in the same version", + ) + .with_span(&self.ident)) + } + (_, renamed, Some(deprecated)) + if renamed.iter().any(|r| *r.since == *deprecated.since) => + { + Err(Error::custom( + "field cannot be marked as `deprecated` and `renamed` in the same version", + ) + .with_span(&self.ident)) + } + _ => Ok(()), + } + } + + /// This associated function is called by the top-level validation function + /// and validates that actions use a chronologically sound chain of + /// versions. + /// + /// The following rules apply: + /// + /// - `deprecated` must use a greater version than `added`: This function + /// ensures that these versions are chronologically sound, that means, + /// that the version of the deprecated action must be greater than the + /// version of the added action. + /// - All `renamed` actions must use a greater version than `added` but a + /// lesser version than `deprecated`. + fn validate_action_order(&self) -> Result<(), Error> { + let added_version = self.added.as_ref().map(|a| *a.since); + let deprecated_version = self.deprecated.as_ref().map(|d| *d.since); + + // First, validate that the added version is less than the deprecated + // version. + if let (Some(added_version), Some(deprecated_version)) = (added_version, deprecated_version) + { + if added_version >= deprecated_version { + return Err(Error::custom(format!( + "field was marked as `added` in version `{added_version}` while being marked as `deprecated` in an earlier version `{deprecated_version}`" + )).with_span(&self.ident)); + } + } + + // Now, iterate over all renames and ensure that their versions are + // between the added and deprecated version. + if !self.renames.iter().all(|r| { + added_version.map_or(true, |a| a < *r.since) + && deprecated_version.map_or(true, |d| d > *r.since) + }) { + return Err(Error::custom( + "all renames must use versions higher than `added` and lower than `deprecated`", + ) + .with_span(&self.ident)); + } + + Ok(()) + } + + /// This associated function is called by the top-level validation function + /// and validates that fields use correct names depending on attached + /// actions. + /// + /// The following naming rules apply: + /// + /// - Fields marked as deprecated need to include the 'deprecated_' prefix + /// in their name. The prefix must not be included for fields which are + /// not deprecated. + fn validate_field_name(&self) -> Result<(), Error> { + let starts_with = self + .ident + .as_ref() + .unwrap() + .to_string() + .starts_with(DEPRECATED_PREFIX); + + if self.deprecated.is_some() && !starts_with { + return Err(Error::custom( + "field was marked as `deprecated` and thus must include the `deprecated_` prefix in its name" + ).with_span(&self.ident)); + } + + if self.deprecated.is_none() && starts_with { + return Err(Error::custom( + "field includes the `deprecated_` prefix in its name but is not marked as `deprecated`" + ).with_span(&self.ident)); + } + + Ok(()) + } + + /// Validates that each field action version is present in the declared + /// container versions. + pub(crate) fn validate_versions( + &self, + container_attrs: &ContainerAttributes, + field: &Field, + ) -> Result<(), Error> { + // NOTE (@Techassi): Can we maybe optimize this a little? + let mut errors = Error::accumulator(); + + if let Some(added) = &self.added { + if !container_attrs + .versions + .iter() + .any(|v| v.name == *added.since) + { + errors.push(Error::custom( + "field action `added` uses version which was not declared via #[versioned(version)]") + .with_span(&field.ident) + ); + } + } + + for rename in &self.renames { + if !container_attrs + .versions + .iter() + .any(|v| v.name == *rename.since) + { + errors.push( + Error::custom("field action `renamed` uses version which was not declared via #[versioned(version)]") + .with_span(&field.ident) + ); + } + } + + if let Some(deprecated) = &self.deprecated { + if !container_attrs + .versions + .iter() + .any(|v| v.name == *deprecated.since) + { + errors.push(Error::custom( + "field action `deprecated` uses version which was not declared via #[versioned(version)]") + .with_span(&field.ident) + ); + } + } + + errors.finish()?; + Ok(()) + } +} diff --git a/crates/stackable-versioned/src/attrs/mod.rs b/crates/stackable-versioned/src/attrs/mod.rs new file mode 100644 index 000000000..1231db5d3 --- /dev/null +++ b/crates/stackable-versioned/src/attrs/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod container; +pub(crate) mod field; diff --git a/crates/stackable-versioned/src/consts.rs b/crates/stackable-versioned/src/consts.rs new file mode 100644 index 000000000..bb0ff076b --- /dev/null +++ b/crates/stackable-versioned/src/consts.rs @@ -0,0 +1 @@ +pub(crate) const DEPRECATED_PREFIX: &str = "deprecated_"; diff --git a/crates/stackable-versioned/src/gen/field.rs b/crates/stackable-versioned/src/gen/field.rs new file mode 100644 index 000000000..99ac9ecba --- /dev/null +++ b/crates/stackable-versioned/src/gen/field.rs @@ -0,0 +1,251 @@ +use std::collections::BTreeMap; + +use darling::Error; +use k8s_version::Version; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Field, Ident}; + +use crate::{ + attrs::field::FieldAttributes, + consts::DEPRECATED_PREFIX, + gen::{version::ContainerVersion, ToTokensExt}, +}; + +/// A versioned field, which contains contains common [`Field`] data and a chain +/// of actions. +/// +/// The chain of action maps versions to an action and the appropriate field +/// name. Additionally, the [`Field`] data can be used to forward attributes, +/// generate documention, etc. +#[derive(Debug)] +pub(crate) struct VersionedField { + chain: Option>, + inner: Field, +} + +impl ToTokensExt for VersionedField { + fn to_tokens_for_version(&self, container_version: &ContainerVersion) -> Option { + match &self.chain { + Some(chain) => { + // Check if the provided container version is present in the map + // of actions. If it is, some action occured in exactly that + // version and thus code is generated for that field based on + // the type of action. + // If not, the provided version has no action attached to it. + // The code generation then depends on the relation to other + // versions (with actions). + + // TODO (@Techassi): Make this more robust by also including + // the container versions in the action chain. I'm not happy + // with the follwoing code at all. It serves as a good first + // implementation to get something out of the door. + match chain.get(&container_version.inner) { + Some(action) => match action { + FieldStatus::Added(field_ident) => { + let field_type = &self.inner.ty; + + Some(quote! { + pub #field_ident: #field_type, + }) + } + FieldStatus::Renamed { from: _, to } => { + let field_type = &self.inner.ty; + + Some(quote! { + pub #to: #field_type, + }) + } + FieldStatus::Deprecated(field_ident) => { + let field_type = &self.inner.ty; + + Some(quote! { + #[deprecated] + pub #field_ident: #field_type, + }) + } + }, + None => { + // Generate field if the container version is not + // included in the action chain. First we check the + // earliest field action version. + if let Some((version, action)) = chain.first_key_value() { + if container_version.inner < *version { + match action { + FieldStatus::Added(_) => return None, + FieldStatus::Renamed { from, to: _ } => { + let field_type = &self.inner.ty; + + return Some(quote! { + pub #from: #field_type, + }); + } + FieldStatus::Deprecated(field_ident) => { + let field_type = &self.inner.ty; + + return Some(quote! { + pub #field_ident: #field_type, + }); + } + } + } + } + + // Check the container version against the latest + // field action version. + if let Some((version, action)) = chain.last_key_value() { + if container_version.inner > *version { + match action { + FieldStatus::Added(field_ident) => { + let field_type = &self.inner.ty; + + return Some(quote! { + pub #field_ident: #field_type, + }); + } + FieldStatus::Renamed { from: _, to } => { + let field_type = &self.inner.ty; + + return Some(quote! { + pub #to: #field_type, + }); + } + FieldStatus::Deprecated(field_ident) => { + let field_type = &self.inner.ty; + + return Some(quote! { + #[deprecated] + pub #field_ident: #field_type, + }); + } + } + } + } + + // TODO (@Techassi): Handle versions which are in between + // versions defined in field actions. + None + } + } + } + None => { + // If there is no chain of field actions, the field is not + // versioned and code generation is straight forward. + // Unversioned fields are always included in versioned structs. + let field_ident = &self.inner.ident; + let field_type = &self.inner.ty; + + Some(quote! { + pub #field_ident: #field_type, + }) + } + } + } +} + +impl VersionedField { + pub(crate) fn new(field: Field, attrs: FieldAttributes) -> Result { + // Constructing the change chain requires going through the actions from + // the end, because the base struct always represents the latest (most + // up-to-date) version of that struct. That's why the following code + // needs to go through the changes in reverse order, as otherwise it is + // impossible to extract the field ident for each version. + + // Deprecating a field is always the last state a field can end up in. For + // fields which are not deprecated, the last change is either the latest + // rename or addition, which is handled below. + // The ident of the deprecated field is guaranteed to include the + // 'deprecated_' prefix. The ident can thus be used as is. + if let Some(deprecated) = attrs.deprecated { + let mut actions = BTreeMap::new(); + + let ident = field.ident.as_ref().unwrap(); + actions.insert(*deprecated.since, FieldStatus::Deprecated(ident.clone())); + + // When the field is deprecated, any rename which occured beforehand + // requires access to the field ident to infer the field ident for + // the latest rename. + let mut ident = format_ident!( + "{ident}", + ident = ident.to_string().replace(DEPRECATED_PREFIX, "") + ); + + for rename in attrs.renames.iter().rev() { + let from = format_ident!("{from}", from = *rename.from); + actions.insert( + *rename.since, + FieldStatus::Renamed { + from: from.clone(), + to: ident, + }, + ); + ident = from; + } + + // After the last iteration above (if any) we use the ident for the + // added action if there is any. + if let Some(added) = attrs.added { + actions.insert(*added.since, FieldStatus::Added(ident)); + } + + Ok(Self { + chain: Some(actions), + inner: field, + }) + } else if !attrs.renames.is_empty() { + let mut actions = BTreeMap::new(); + let mut ident = field.ident.clone().unwrap(); + + for rename in attrs.renames.iter().rev() { + let from = format_ident!("{from}", from = *rename.from); + actions.insert( + *rename.since, + FieldStatus::Renamed { + from: from.clone(), + to: ident, + }, + ); + ident = from; + } + + // After the last iteration above (if any) we use the ident for the + // added action if there is any. + if let Some(added) = attrs.added { + actions.insert(*added.since, FieldStatus::Added(ident)); + } + + dbg!(&actions); + + Ok(Self { + chain: Some(actions), + inner: field, + }) + } else { + if let Some(added) = attrs.added { + let mut actions = BTreeMap::new(); + + actions.insert( + *added.since, + FieldStatus::Added(field.ident.clone().unwrap()), + ); + + return Ok(Self { + chain: Some(actions), + inner: field, + }); + } + + Ok(Self { + chain: None, + inner: field, + }) + } + } +} + +#[derive(Debug)] +pub(crate) enum FieldStatus { + Added(Ident), + Renamed { from: Ident, to: Ident }, + Deprecated(Ident), +} diff --git a/crates/stackable-versioned/src/gen/mod.rs b/crates/stackable-versioned/src/gen/mod.rs new file mode 100644 index 000000000..f95892882 --- /dev/null +++ b/crates/stackable-versioned/src/gen/mod.rs @@ -0,0 +1,51 @@ +use darling::FromDeriveInput; +use proc_macro2::TokenStream; +use quote::ToTokens; +use syn::{spanned::Spanned, Data, DeriveInput, Error, Result}; + +use crate::{ + attrs::container::ContainerAttributes, + gen::{venum::VersionedEnum, version::ContainerVersion, vstruct::VersionedStruct}, +}; + +pub(crate) mod field; +pub(crate) mod venum; +pub(crate) mod version; +pub(crate) mod vstruct; + +// NOTE (@Techassi): This derive macro cannot handle multiple structs / enums +// to be versioned within the same file. This is because we cannot declare +// modules more than once (They will not be merged, like impl blocks for +// example). This leads to collisions if there are multiple structs / enums +// which declare the same version. This could maybe be solved by using an +// attribute macro applied to a module with all struct / enums declared in said +// module. This would allow us to generate all versioned structs and enums in +// a single sweep and put them into the appropriate module. + +// TODO (@Techassi): Think about how we can handle nested structs / enums which +// are also versioned. + +pub(crate) fn expand(input: DeriveInput) -> Result { + // Extract container attributes + let attributes = ContainerAttributes::from_derive_input(&input)?; + + // Validate container shape and generate code + let expanded = match input.data { + Data::Struct(data) => { + VersionedStruct::new(input.ident, data, attributes)?.to_token_stream() + } + Data::Enum(data) => VersionedEnum::new(input.ident, data, attributes)?.to_token_stream(), + Data::Union(_) => { + return Err(Error::new( + input.span(), + "derive macro `Versioned` only supports structs and enums", + )) + } + }; + + Ok(expanded) +} + +pub(crate) trait ToTokensExt { + fn to_tokens_for_version(&self, version: &ContainerVersion) -> Option; +} diff --git a/crates/stackable-versioned/src/gen/venum.rs b/crates/stackable-versioned/src/gen/venum.rs new file mode 100644 index 000000000..9297cbcf6 --- /dev/null +++ b/crates/stackable-versioned/src/gen/venum.rs @@ -0,0 +1,23 @@ +use proc_macro2::TokenStream; +use quote::ToTokens; +use syn::{DataEnum, Ident, Result}; + +use crate::attrs::container::ContainerAttributes; + +pub(crate) struct VersionedEnum {} + +impl VersionedEnum { + pub(crate) fn new( + _ident: Ident, + _data: DataEnum, + _attributes: ContainerAttributes, + ) -> Result { + todo!() + } +} + +impl ToTokens for VersionedEnum { + fn to_tokens(&self, _tokens: &mut TokenStream) { + todo!() + } +} diff --git a/crates/stackable-versioned/src/gen/version.rs b/crates/stackable-versioned/src/gen/version.rs new file mode 100644 index 000000000..ca5d7f0f5 --- /dev/null +++ b/crates/stackable-versioned/src/gen/version.rs @@ -0,0 +1,7 @@ +use k8s_version::Version; + +#[derive(Debug)] +pub(crate) struct ContainerVersion { + pub(crate) deprecated: bool, + pub(crate) inner: Version, +} diff --git a/crates/stackable-versioned/src/gen/vstruct.rs b/crates/stackable-versioned/src/gen/vstruct.rs new file mode 100644 index 000000000..7f829d323 --- /dev/null +++ b/crates/stackable-versioned/src/gen/vstruct.rs @@ -0,0 +1,102 @@ +use darling::FromField; +use proc_macro2::TokenStream; +use quote::{format_ident, quote, ToTokens}; +use syn::{DataStruct, Ident, Result}; + +use crate::{ + attrs::{container::ContainerAttributes, field::FieldAttributes}, + gen::{field::VersionedField, version::ContainerVersion, ToTokensExt}, +}; + +/// Stores individual versions of a single struct. Each version tracks field +/// actions, which describe if the field was added, renamed or deprecated in +/// that version. Fields which are not versioned, are included in every +/// version of the struct. +#[derive(Debug)] +pub(crate) struct VersionedStruct { + /// The ident, or name, of the versioned struct. + pub(crate) ident: Ident, + + /// List of declared versions for this struct. Each version, except the + /// latest, generates a definition with appropriate fields. + pub(crate) versions: Vec, + + /// List of fields defined in the base struct. How, and if, a field should + /// generate code, is decided by the currently generated version. + pub(crate) fields: Vec, +} + +impl ToTokens for VersionedStruct { + fn to_tokens(&self, _tokens: &mut TokenStream) { + let mut versions = self.versions.iter().peekable(); + + while let Some(version) = versions.next() { + let mut fields = TokenStream::new(); + + for field in &self.fields { + fields.extend(field.to_tokens_for_version(version)); + } + + // TODO (@Techassi): Make the generation of the module optional to + // enable the attribute macro to be applied to a module which + // generates versioned versions of all contained containers. + + let deprecated_attr = version.deprecated.then_some(quote! {#[deprecated]}); + let module_name = format_ident!("{version}", version = version.inner.to_string()); + let struct_name = &self.ident; + + // Only generate a module when there is at least one more version. + // This skips generating a module for the latest version, because + // the base struct always represents the latest version. + if versions.peek().is_some() { + _tokens.extend(quote! { + #[automatically_derived] + #deprecated_attr + pub mod #module_name { + + pub struct #struct_name { + #fields + } + } + }); + } + } + } +} + +impl VersionedStruct { + pub(crate) fn new( + ident: Ident, + data: DataStruct, + attributes: ContainerAttributes, + ) -> Result { + let mut fields = Vec::new(); + + // Extract the field attributes for every field from the raw token + // stream and also validate that each field action version uses a + // version declared by the container attribute. + for field in data.fields { + let attrs = FieldAttributes::from_field(&field)?; + attrs.validate_versions(&attributes, &field)?; + + let versioned_field = VersionedField::new(field, attrs)?; + fields.push(versioned_field); + } + + // Convert the raw version attributes into a container version. + let versions = attributes + .versions + .iter() + .map(|v| ContainerVersion { + deprecated: v.deprecated.is_present(), + inner: v.name, + }) + .collect(); + + Ok(Self { + ident, + versions, + fields, + }) + } +} diff --git a/crates/stackable-versioned/src/lib.rs b/crates/stackable-versioned/src/lib.rs new file mode 100644 index 000000000..33aeecf67 --- /dev/null +++ b/crates/stackable-versioned/src/lib.rs @@ -0,0 +1,15 @@ +use proc_macro::TokenStream; +use syn::{DeriveInput, Error}; + +mod attrs; +mod consts; +mod gen; + +#[proc_macro_derive(Versioned, attributes(versioned))] +pub fn versioned_macro_derive(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as DeriveInput); + + gen::expand(input) + .unwrap_or_else(Error::into_compile_error) + .into() +} diff --git a/crates/stackable-versioned/tests/basic.rs b/crates/stackable-versioned/tests/basic.rs new file mode 100644 index 000000000..6cfa11c23 --- /dev/null +++ b/crates/stackable-versioned/tests/basic.rs @@ -0,0 +1,29 @@ +use stackable_versioned::Versioned; + +#[derive(Versioned)] +#[allow(dead_code)] +#[versioned( + version(name = "v1alpha1"), + version(name = "v1beta1"), + version(name = "v1") +)] +struct Foo { + /// My docs + #[versioned( + added(since = "v1alpha1"), + renamed(since = "v1beta1", from = "jjj"), + deprecated(since = "v1", _note = "") + )] + deprecated_bar: usize, + baz: bool, +} + +#[test] +fn basic() { + let _ = v1alpha1::Foo { jjj: 0, baz: false }; + let _ = v1beta1::Foo { bar: 0, baz: false }; + let _ = Foo { + deprecated_bar: 0, + baz: false, + }; +}