public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Stefan Hanreich <s.hanreich@proxmox.com>
To: Wolfgang Bumiller <w.bumiller@proxmox.com>
Cc: pdm-devel@lists.proxmox.com
Subject: Re: [pdm-devel] [PATCH proxmox 1/3] pve-api-types: add FixedString type
Date: Wed, 12 Nov 2025 17:39:46 +0100	[thread overview]
Message-ID: <0786d417-db35-4c7e-96e2-66620ad26e9c@proxmox.com> (raw)
In-Reply-To: <azh3ifpvho4xs4qqti6wbmkjn5atenshwbap3ycijv3mx3badm@uxj5bjp33xx3>

thanks for the review! Incorporated all your suggestions, waiting with
the v2 til tomorrow, in case something else comes up.

On 11/12/25 11:49 AM, Wolfgang Bumiller wrote:
> On Wed, Nov 12, 2025 at 10:22:05AM +0100, Stefan Hanreich wrote:
>> A custom, immutable, string type, that is copiable, which can hold up
>> to 23 characters. It will be used later for storing the name of
>> unknown enum variants when parsing the return value of enum
>> properties. The main reason for introducing this type is that its copy
>> as opposed to String, which lets us keep the Copy implementation for
>> the existing enums.
>>
>> Main reason for choosing 23 characters is the fact that String is 24
>> bytes large as well (ptr, len, capacity).
>>
>> Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
>> ---
>>  pve-api-types/src/types/fixed_string.rs | 331 ++++++++++++++++++++++++
>>  pve-api-types/src/types/mod.rs          |   3 +
>>  2 files changed, 334 insertions(+)
>>  create mode 100644 pve-api-types/src/types/fixed_string.rs
>>
>> diff --git a/pve-api-types/src/types/fixed_string.rs b/pve-api-types/src/types/fixed_string.rs
>> new file mode 100644
>> index 00000000..f1692227
>> --- /dev/null
>> +++ b/pve-api-types/src/types/fixed_string.rs
>> @@ -0,0 +1,331 @@
>> +use std::cmp::Ordering;
>> +use std::error::Error;
>> +use std::fmt::{Debug, Display, Formatter};
>> +use std::ops::Deref;
>> +use std::str::FromStr;
>> +
>> +use serde::{Deserialize, Serialize};
>> +
>> +/// Error type used by constructors of [`FixedString`]
>> +#[derive(Clone, Copy, Debug)]
>> +pub enum FixedStringError {
>> +    TooLong,
>> +}
> 
> IMO this does not need to be an enum. `pub struct TooLongError` should
> be enough.

also thought about it, wanted it to be potentially extendable without
too much hassle - but works for me as well. Additional error variants
are unlikely anyway..

>> +
>> +impl Error for FixedStringError {}
>> +
>> +impl Display for FixedStringError {
>> +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
> 
> ^ you're importing `Formatter`.
> And on that note: personally I prefer to import `std::fmt` and prefix
> its items with `fmt::*`.
> 
>> +        f.write_str(match self {
>> +            Self::TooLong => "string is longer than 23 characters",
>> +        })
>> +    }
>> +}
>> +
>> +/// An immutable string type with a maximum size of 23 bytes.
>> +///
>> +/// After construction it is guaranteed that its contents are:
>> +/// * valid utf-8
>> +/// * not longer than 23 characters
>> +///
>> +/// FixedString is immutable, therefore it is sufficient to validate the invariants only at
>> +/// construction time to guarantee that they will always hold during the lifecycle of the
>> +/// struct.
>> +#[derive(Clone, Copy)]
>> +pub struct FixedString {
>> +    buf: [u8; 23],
>> +    len: u8,
>> +}
>> +
>> +impl FixedString {
>> +    /// Creates a new FixedString instance from a str reference.
>> +    ///
>> +    /// # Errors
>> +    /// This function will return an error if:
>> +    /// * The passed string is longer than 23 bytes
>> +    pub fn new(value: &str) -> Result<Self, FixedStringError> {
>> +        if value.len() > 23 {
>> +            return Err(FixedStringError::TooLong);
>> +        }
>> +
>> +        let mut buf = [0; 23];
>> +        buf[..value.len()].copy_from_slice(value.as_bytes());
>> +
>> +        Ok(Self {
>> +            buf,
>> +            // SAFETY: self.len is at least 0 and at most 23, which fits into u8
>> +            len: value.len() as u8,
>> +        })
>> +    }
>> +
>> +    /// Creates a new FixedString instance from a str reference, truncating if required
>> +    ///
>> +    /// It works by copying the bytes from the supplied value into its own local buffer. If the
>> +    /// string is longer than 23 bytes, then this methods will truncate the string at the nearest
>> +    /// utf-8 boundary, so even if a string longer than 23 characters is passed in, it is possible
>> +    /// that it gets truncated to a length less than 23 if the utf-8 boundary doesn't perfectly
>> +    /// align with the 23 bytes limit.
>> +    pub fn new_truncated(value: &str) -> Self {
> 
> I don't think we should keep this unless we also have a way of checking
> for this.
> In case we do want to use this: since values up to 31 (a potential
> still-cheap-enough extension to this `FixedString`'s length (since it
> would then just be 4 pointers in size)) only require 5 bits, we have 3
> bits remaining in the length byte which could be used to signal that
> this was a truncated string, which could be returned via a
> `.is_truncated()` method, and we could refuse to serialize such a string
> without calling some extra method first (and include some marker in the
> Debug impl)?
> 
> But IMO: for now, we just don't need it.
> 

dropped it now then

>> +        // TODO: replace, once [`str::floor_char_boundary`] has been stabilized
>> +        let len = if value.len() > 23 {
>> +            let mut index = 23;
>> +
>> +            while index > 0 && !value.is_char_boundary(index) {
>> +                index = index - 1;
> 
> index -= 1; ;-)
> 
> or:
>     panic!("Who let Dominik add emojis to enums?")
> 
>> +            }
>> +
>> +            index
>> +        } else {
>> +            value.len()
>> +        };
> 
> ^ Actually the above could just be shortened to
> 
> let mut len = value.len();
> if len > 23 {
>     len = 23;
>     while ... { len -= 1 }
> }
> 
>> +
>> +        let mut buf = [0; 23];
>> +        buf[..len].copy_from_slice(&value.as_bytes()[..len]);
>> +
>> +        Self {
>> +            buf,
>> +            // SAFETY: self.len is at least 0 and at most 23, which fits into u8
>> +            len: len as u8,
>> +        }
>> +    }
>> +
>> +    /// Returns a str reference to the stored data
>> +    #[inline]
>> +    pub fn as_str(&self) -> &str {
>> +        // SAFETY: self.buf must be a valid utf-8 string by construction
>> +        unsafe { str::from_utf8_unchecked(self.as_bytes()) }
>> +    }
>> +
>> +    /// Returns a reference to the set bytes in the stored buffer
>> +    #[inline]
>> +    pub fn as_bytes(&self) -> &[u8] {
>> +        // SAFETY: self.len >= 0 and self.len <= 23 by construction
>> +        unsafe { self.buf.get_unchecked(..self.len as usize) }
>> +    }
>> +}
>> +
>> +impl PartialEq for FixedString {
> 
> IMO - regarding ordering where it matters - it would be easier to read
> such impls when they're purely forwarded, eg.:
> 
>     PartialEq::eq(self.as_str(), other)
> 
> since then all implementations look the same (no need for `Some()`
> wrapping in the `PartialOrd` impl, for instance.

yeah, that's fair - idk why I didn't do it in the first place...

> Also, it could be generated with a simple forwarding macro which would
> IMO also make review easier than having to go through each of them
> individually:
> 
>     macro_rules! forward_impl_to_bytes {
>         ($($trait:ident {$fn:ident -> $out:ty })+) => {
>             $(
>                 impl $trait for FixedString {
>                     fn $fn(&self, other: &Self) -> $out {
>                         <[u8] as $trait>::$fn(self.as_bytes(), other.as_bytes())
>                     }
>                 }
>             )+
>         };
>     }
> 
>     forward_impl_to_bytes! {
>         PartialEq { eq -> bool }
>         PartialOrd { partial_cmp -> Option<Ordering> }
>         Ord { cmp -> Ordering }
>     }
> 
> 
> And for the ones below:
> 
>     macro_rules! forward_impl_to_str_bidir {
>         ($($trait:ident {$fn:ident -> $out:ty })+) => {
>             $(
>                 impl $trait<str> for FixedString {
>                     fn $fn(&self, other: &str) -> $out {
>                         <str as $trait>::$fn(self.as_str(), other)
>                     }
>                 }
> 
>                 impl $trait<FixedString> for &str {
>                     fn $fn(&self, other: &FixedString) -> $out {
>                         <str as $trait>::$fn(self, other.as_str())
>                     }
>                 }
>             )+
>         };
>     }
> 
>     forward_impl_to_str_bidir! {
>         PartialEq { eq -> bool }
>         PartialOrd { partial_cmp -> Option<Ordering> }
>     }
> 
> Yes I realize that for 2 traits this doesn't save much, but it would
> make the intention clearer and IMO is still easier to review.

still, this makes adding additional traits a breeze and I'm sure we
might need to do that at some point. I kept the inline for both cases
though - what do you think? makes sense imo.

>> +    #[inline]
>> +    fn eq(&self, other: &FixedString) -> bool {
>> +        self.as_bytes() == other.as_bytes()
>> +    }
>> +}
>> +
>> +impl Eq for FixedString {}
>> +
>> +impl PartialOrd for FixedString {
>> +    fn partial_cmp(&self, other: &FixedString) -> Option<Ordering> {
>> +        Some(self.cmp(other))
>> +    }
>> +}
>> +
>> +impl Ord for FixedString {
>> +    #[inline]
>> +    fn cmp(&self, other: &FixedString) -> Ordering {
>> +        self.as_bytes().cmp(other.as_bytes())
>> +    }
>> +}
>> +
>> +impl Display for FixedString {
>> +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
>> +        f.write_str(self.as_str())
> 
> Consider forwarding to &str's fmt instead so that things such as
> alignment etc. work:
> 
>     Display::fmt(self.as_str(), f)
> 
> so that `println!("{the_enum:>8}")` will print "     foo" instead of
> just "foo", and `{x:0^8}` produces "00foo000" ;-)
> 

done

>> +    }
>> +}
>> +
>> +impl Debug for FixedString {
>> +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
>> +        f.write_str(self.as_str())
> 
> Same here with `Debug::fmt(self.as_str(), f)`.
> 

done

>> +    }
>> +}
>> +
>> +impl Deref for FixedString {
>> +    type Target = str;
>> +
>> +    fn deref(&self) -> &str {
>> +        self.as_str()
>> +    }
>> +}
>> +
>> +impl AsRef<str> for FixedString {
>> +    fn as_ref(&self) -> &str {
>> +        self.as_str()
>> +    }
>> +}
> 
> I'd also add `AsRef<[u8]>`, and `Borrow<str>`. (The latter would be
> required to allow a `HashMap<FixedString, T>` to use `.get("key")` with
> `&str` for a key - not that that's something we'd expect to see...
> 

done

>> +
>> +impl TryFrom<String> for FixedString {
>> +    type Error = FixedStringError;
>> +
>> +    fn try_from(value: String) -> Result<Self, Self::Error> {
>> +        FixedString::new(value.as_str())
>> +    }
>> +}
>> +
>> +impl FromStr for FixedString {
>> +    type Err = FixedStringError;
>> +
>> +    fn from_str(value: &str) -> Result<Self, Self::Err> {
>> +        FixedString::new(value)
>> +    }
>> +}
>> +
>> +impl TryFrom<&str> for FixedString {
>> +    type Error = FixedStringError;
>> +
>> +    fn try_from(value: &str) -> Result<Self, Self::Error> {
>> +        FixedString::new(value)
>> +    }
>> +}
>> +
>> +impl PartialEq<str> for FixedString {
>> +    fn eq(&self, other: &str) -> bool {
>> +        self.as_str().eq(other)
>> +    }
>> +}
>> +
>> +impl PartialEq<FixedString> for &str {
>> +    fn eq(&self, other: &FixedString) -> bool {
>> +        self.eq(&other.as_str())
>> +    }
>> +}
>> +
>> +impl PartialOrd<str> for FixedString {
>> +    fn partial_cmp(&self, other: &str) -> Option<Ordering> {
>> +        Some(self.as_str().cmp(&other))
>> +    }
>> +}
>> +
>> +impl PartialOrd<FixedString> for &str {
>> +    fn partial_cmp(&self, other: &FixedString) -> Option<Ordering> {
>> +        Some(self.cmp(&other.as_str()))
>> +    }
>> +}
>> +
>> +impl Serialize for FixedString {
>> +    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
>> +    where
>> +        S: serde::Serializer,
>> +    {
>> +        serializer.serialize_str(self.as_str())
>> +    }
>> +}
>> +
>> +impl<'de> Deserialize<'de> for FixedString {
>> +    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
>> +    where
>> +        D: serde::Deserializer<'de>,
>> +    {
>> +        deserializer.deserialize_str(FixedStringVisitor)
>> +    }
>> +}
>> +
>> +struct FixedStringVisitor;
> 
> ^ I'd prefer to move the type and Visitor impl into the `fn deserialize`.
> 

done

>> +
>> +impl<'de> serde::de::Visitor<'de> for FixedStringVisitor {
>> +    type Value = FixedString;
>> +
>> +    fn expecting(&self, f: &mut Formatter) -> std::fmt::Result {
>> +        f.write_str("a string that is at most 23 bytes long")
>> +    }
>> +
>> +    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
>> +    where
>> +        E: serde::de::Error,
>> +    {
>> +        v.try_into().map_err(E::custom)
>> +    }
>> +
>> +    fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
>> +    where
>> +        E: serde::de::Error,
>> +    {
> 
> ^ You can drop this. `visit_string` and `visit_borrowed_str` by default
> forward to `visit_str`.
> 

done

>> +        v.try_into().map_err(E::custom)
>> +    }
>> +}
>> +
>> +#[cfg(test)]
>> +mod tests {
>> +    use super::*;
>> +
>> +    use serde_plain;
>> +
>> +    #[test]
>> +    fn test_construct() {
>> +        let fixed_string = FixedString::new("").expect("empty string is valid");
>> +        assert_eq!("", fixed_string);
>> +
>> +        let fixed_string = FixedString::new("a").expect("valid string");
>> +        assert_eq!("a", fixed_string);
>> +
>> +        let fixed_string = FixedString::new("🌏🌏🌏🌏🌏").expect("valid string");
>> +        assert_eq!("🌏🌏🌏🌏🌏", fixed_string);
>> +
>> +        let fixed_string =
>> +            FixedString::new("aaaaaaaaaaaaaaaaaaaaaaa").expect("23 characters are allowed");
>> +        assert_eq!("aaaaaaaaaaaaaaaaaaaaaaa", fixed_string);
>> +
>> +        FixedString::new("🌏🌏🌏🌏🌏🌏").expect_err("string too long");
>> +        FixedString::new("aaaaaaaaaaaaaaaaaaaaaaaa").expect_err("string too long");
>> +    }
>> +
>> +    #[test]
>> +    fn test_serialize_deserialize() {
>> +        let valid_string = "aaaaaaaaaaaaaaaaaaaaaaa";
>> +
>> +        let fixed_string: FixedString =
>> +            serde_plain::from_str("aaaaaaaaaaaaaaaaaaaaaaa").expect("deserialization works");
>> +        assert_eq!(valid_string, fixed_string);
>> +
>> +        let serialized_string =
>> +            serde_plain::to_string(&fixed_string).expect("can be serialized into a string");
>> +        assert_eq!(valid_string, serialized_string);
>> +
>> +        serde_plain::from_str::<FixedString>("aaaaaaaaaaaaaaaaaaaaaaaa")
>> +            .expect_err("cannot deserialize string that is too long");
>> +    }
>> +
>> +    #[test]
>> +    fn test_ord() {
>> +        let fixed_string = FixedString::new("abc").expect("valid string");
>> +
>> +        assert!(fixed_string == fixed_string);
>> +        assert!(fixed_string >= fixed_string);
>> +        assert!(fixed_string <= fixed_string);
>> +
>> +        assert!("ab" < fixed_string);
>> +        assert!("abc" == fixed_string);
>> +        assert!("abcd" > fixed_string);
>> +
>> +        let larger_fixed_string = FixedString::new("abcde").expect("valid string");
>> +
>> +        assert!(larger_fixed_string > fixed_string);
>> +        assert!(fixed_string < larger_fixed_string);
>> +    }
>> +
>> +    #[test]
>> +    fn test_truncate() {
>> +        let fixed_string = FixedString::new_truncated("aaaaaaaaaaaaaaaaaaaaaaaaaaaa");
>> +        assert_eq!("aaaaaaaaaaaaaaaaaaaaaaa", fixed_string);
>> +
>> +        let fixed_string = FixedString::new_truncated("aaaaaaaaaaaaaaaaaa💯");
>> +        assert_eq!("aaaaaaaaaaaaaaaaaa💯", fixed_string);
>> +
>> +        // '💯' is 4 bytes long
>> +        // first byte of '💯' is inside the 23 byte limit, but it's not a valid utf-8 boundary so
>> +        // it should get truncated
>> +        let fixed_string = FixedString::new_truncated("aaaaaaaaaaaaaaaaaaaaa💯");
>> +        assert_eq!("aaaaaaaaaaaaaaaaaaaaa", fixed_string);
>> +
>> +        let fixed_string = FixedString::new_truncated("aaaaaaaaaaaaaaaaaaaaaa💯");
>> +        assert_eq!("aaaaaaaaaaaaaaaaaaaaaa", fixed_string);
>> +
>> +        let fixed_string = FixedString::new_truncated("aaaaaaaaaaaaaaaaaaaaaaa💯");
>> +        assert_eq!("aaaaaaaaaaaaaaaaaaaaaaa", fixed_string);
>> +
>> +        // '🌏' is 4 bytes long
>> +        let fixed_string = FixedString::new_truncated("🌏🌏🌏🌏🌏");
>> +        assert_eq!("🌏🌏🌏🌏🌏", fixed_string);
>> +
>> +        let fixed_string = FixedString::new_truncated("🌏🌏🌏🌏🌏🌏");
>> +        assert_eq!("🌏🌏🌏🌏🌏", fixed_string);
>> +    }
>> +}
>> diff --git a/pve-api-types/src/types/mod.rs b/pve-api-types/src/types/mod.rs
>> index fe52a169..9653dd46 100644
>> --- a/pve-api-types/src/types/mod.rs
>> +++ b/pve-api-types/src/types/mod.rs
>> @@ -17,6 +17,9 @@ pub mod array;
>>  pub mod stringlist;
>>  pub mod verifiers;
>>  
>> +mod fixed_string;
>> +pub use fixed_string::{FixedString, FixedStringError};
>> +
>>  include!("../generated/types.rs");
>>  
>>  /// A PVE Upid, contrary to a PBS Upid, contains no 'task-id' number.
>> -- 
>> 2.47.3



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel

  reply	other threads:[~2025-11-12 16:39 UTC|newest]

Thread overview: 10+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-11-12  9:22 [pdm-devel] [RFC proxmox{, -yew-comp, -datacenter-manager}/yew-mobile-gui 0/6] Add fallback variant to enum properties in Proxmox VE Rust API types Stefan Hanreich
2025-11-12  9:22 ` [pdm-devel] [PATCH proxmox 1/3] pve-api-types: add FixedString type Stefan Hanreich
2025-11-12 10:50   ` Wolfgang Bumiller
2025-11-12 16:39     ` Stefan Hanreich [this message]
2025-11-12  9:22 ` [pdm-devel] [PATCH proxmox 2/3] pve-api-types: generate fallback variant for enums Stefan Hanreich
2025-11-12  9:22 ` [pdm-devel] [PATCH proxmox 3/3] pve-api-types: regenerate Stefan Hanreich
2025-11-12  9:22 ` [pdm-devel] [PATCH proxmox-yew-comp 1/1] pve: qemu: handle fallback enum variants Stefan Hanreich
2025-11-12  9:22 ` [pdm-devel] [PATCH proxmox-datacenter-manager 1/1] tree-wide: handle new unknown " Stefan Hanreich
2025-11-12 12:25   ` Lukas Wagner
2025-11-12  9:22 ` [pdm-devel] [PATCH pve-yew-mobile-gui 1/1] tree-wide: handle fallback enum values Stefan Hanreich

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=0786d417-db35-4c7e-96e2-66620ad26e9c@proxmox.com \
    --to=s.hanreich@proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    --cc=w.bumiller@proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal