canic_types/
string.rs

1//!
2//! Bounded string wrappers that integrate with stable structures and enforce
3//! maximum lengths at construction time. These appear in configs and memory
4//! tables where size caps matter.
5//!
6
7use candid::CandidType;
8use canic_cdk::structures::{Storable, storable::Bound};
9use derive_more::{Deref, DerefMut, Display};
10use serde::{Deserialize, Serialize};
11use std::{borrow::Cow, convert::TryFrom};
12
13///
14/// BoundedString
15/// String wrapper enforcing a compile-time maximum length, with serde and
16/// storage trait implementations.
17///
18
19#[derive(
20    CandidType,
21    Clone,
22    Debug,
23    Deref,
24    DerefMut,
25    Deserialize,
26    Display,
27    Eq,
28    Ord,
29    PartialEq,
30    PartialOrd,
31    Serialize,
32)]
33pub struct BoundedString<const N: u32>(pub String);
34
35#[allow(clippy::cast_possible_truncation)]
36impl<const N: u32> BoundedString<N> {
37    pub fn try_new(s: impl Into<String>) -> Result<Self, String> {
38        let s: String = s.into();
39
40        #[allow(clippy::cast_possible_truncation)]
41        if s.len() as u32 <= N {
42            Ok(Self(s))
43        } else {
44            Err(format!("String too long for BoundedString<{N}>"))
45        }
46    }
47
48    #[must_use]
49    pub fn new(s: impl Into<String>) -> Self {
50        let s: String = s.into();
51        let slen = s.len();
52
53        assert!(
54            slen as u32 <= N,
55            "String '{s}' too long for BoundedString<{N}> ({slen} bytes)",
56        );
57
58        Self(s)
59    }
60}
61
62impl<const N: u32> AsRef<str> for BoundedString<N> {
63    fn as_ref(&self) -> &str {
64        &self.0
65    }
66}
67
68pub type BoundedString8 = BoundedString<8>;
69pub type BoundedString16 = BoundedString<16>;
70pub type BoundedString32 = BoundedString<32>;
71pub type BoundedString64 = BoundedString<64>;
72pub type BoundedString128 = BoundedString<128>;
73pub type BoundedString256 = BoundedString<256>;
74
75// Fallible Into<String> back
76impl<const N: u32> From<BoundedString<N>> for String {
77    fn from(b: BoundedString<N>) -> Self {
78        b.0
79    }
80}
81
82impl<const N: u32> TryFrom<String> for BoundedString<N> {
83    type Error = String;
84
85    #[allow(clippy::cast_possible_truncation)]
86    fn try_from(value: String) -> Result<Self, Self::Error> {
87        Self::try_new(value)
88    }
89}
90
91impl<const N: u32> TryFrom<&str> for BoundedString<N> {
92    type Error = String;
93
94    #[allow(clippy::cast_possible_truncation)]
95    fn try_from(value: &str) -> Result<Self, Self::Error> {
96        Self::try_new(value)
97    }
98}
99
100impl<const N: u32> Storable for BoundedString<N> {
101    const BOUND: Bound = Bound::Bounded {
102        max_size: N,
103        is_fixed_size: false,
104    };
105
106    fn to_bytes(&self) -> Cow<'_, [u8]> {
107        Cow::Borrowed(self.0.as_bytes())
108    }
109
110    fn into_bytes(self) -> Vec<u8> {
111        self.0.into_bytes()
112    }
113
114    fn from_bytes(bytes: Cow<[u8]>) -> Self {
115        let bytes = bytes.as_ref();
116        let bytes = if bytes.len() > N as usize {
117            &bytes[..N as usize]
118        } else {
119            bytes
120        };
121
122        // Best-effort decode to avoid trapping on corrupted or migrated data.
123        let s = String::from_utf8_lossy(bytes).into_owned();
124
125        Self(s)
126    }
127}
128
129///
130/// TESTS
131///
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn create_within_bounds() {
139        let s = "hello".to_string();
140        let b = BoundedString16::new(s.clone());
141        assert_eq!(b.0, s);
142    }
143
144    #[test]
145    fn create_at_exact_limit() {
146        let s = "a".repeat(16);
147        let b = BoundedString16::new(s.clone());
148        assert_eq!(b.0, s);
149    }
150
151    #[test]
152    fn ordering_and_equality() {
153        let a = BoundedString16::new("abc".to_string());
154        let b = BoundedString16::new("abc".to_string());
155        let c = BoundedString16::new("def".to_string());
156
157        assert_eq!(a, b);
158        assert_ne!(a, c);
159        assert!(a < c); // "abc" < "def"
160    }
161
162    #[test]
163    fn try_new_is_fallible() {
164        let err = BoundedString16::try_new("a".repeat(17)).unwrap_err();
165        assert!(err.contains("BoundedString<16>"));
166    }
167}