Skip to main content

arkhe_forge_core/
component.rs

1//! `ArkheComponent` sealed trait + `BoundedString<N>`.
2//!
3//! Components are ECS storage units. Each impl carries a stable `TYPE_CODE`
4//! (runtime registry pin, A15) and `SCHEMA_VERSION` (monotone increment on
5//! field addition; removal / reorder forbidden — Enum WAL compat).
6//!
7//! `BoundedString<N>` wraps `arrayvec::ArrayString<N>` so `N` is a compile-time
8//! capacity bound. The wrapper is sealed — downstream code cannot see the
9//! internal representation, letting us swap `ArrayString` for another backend
10//! without breaking wire format.
11
12use arkhe_kernel::abi::TypeCode;
13use arrayvec::ArrayString;
14use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializer};
15
16/// Sealed marker trait for ECS Component types. Implementations are produced
17/// only by `#[derive(ArkheComponent)]` — manual downstream impls are rejected
18/// by the `Sealed` supertrait and the Runtime dylint gate.
19///
20/// Canonical bytes = `postcard::to_stdvec(&value)` (A17 succession).
21pub trait ArkheComponent:
22    crate::__sealed::__Sealed + Serialize + for<'de> Deserialize<'de> + 'static
23{
24    /// Globally stable dispatch code within the runtime `TypeCode` registry.
25    const TYPE_CODE: u32;
26
27    /// Monotone schema version. Bump on field addition (`#[serde(default)]`
28    /// paired); field removal / reorder forbidden.
29    const SCHEMA_VERSION: u16;
30
31    /// `TypeCode` wrapper convenience.
32    fn type_code() -> TypeCode {
33        TypeCode(Self::TYPE_CODE)
34    }
35}
36
37/// Fixed-capacity UTF-8 string — bounded at compile time by const generic `N`.
38///
39/// Canonical wire = `postcard::serialize_str` (varint length + `N`-bounded
40/// UTF-8 bytes). `N` is not on the wire — decode checks against the const.
41/// Expanding `N` requires a `SCHEMA_VERSION` bump on the enclosing Component;
42/// shrinking is forbidden (existing records might exceed the new cap).
43#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
44pub struct BoundedString<const N: usize>(ArrayString<N>);
45
46impl<const N: usize> BoundedString<N> {
47    /// Construct from a borrowed `&str`. Rejects over-length input.
48    pub fn new(s: &str) -> Result<Self, BoundedStringError> {
49        ArrayString::from(s)
50            .map(Self)
51            .map_err(|_| BoundedStringError::Overflow {
52                len: s.len(),
53                cap: N,
54            })
55    }
56
57    /// Borrow as `&str`.
58    #[inline]
59    #[must_use]
60    pub fn as_str(&self) -> &str {
61        self.0.as_str()
62    }
63
64    /// Compile-time capacity.
65    pub const CAP: usize = N;
66}
67
68impl<const N: usize> Serialize for BoundedString<N> {
69    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
70        s.serialize_str(self.0.as_str())
71    }
72}
73
74impl<'de, const N: usize> Deserialize<'de> for BoundedString<N> {
75    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
76        let s: &str = <&str>::deserialize(d)?;
77        Self::new(s).map_err(D::Error::custom)
78    }
79}
80
81/// Failure variants for [`BoundedString`].
82#[non_exhaustive]
83#[derive(Debug, Clone, thiserror::Error, Eq, PartialEq)]
84pub enum BoundedStringError {
85    /// Input length exceeded the compile-time capacity.
86    #[error("BoundedString overflow: len {len} > cap {cap}")]
87    Overflow {
88        /// Attempted length in bytes.
89        len: usize,
90        /// Compile-time capacity.
91        cap: usize,
92    },
93}
94
95#[cfg(test)]
96#[allow(clippy::unwrap_used, clippy::expect_used)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn bounded_string_accepts_within_cap() {
102        let s = BoundedString::<8>::new("abcd").unwrap();
103        assert_eq!(s.as_str(), "abcd");
104        assert_eq!(BoundedString::<8>::CAP, 8);
105    }
106
107    #[test]
108    fn bounded_string_rejects_over_cap() {
109        let e = BoundedString::<4>::new("hello").unwrap_err();
110        match e {
111            BoundedStringError::Overflow { len, cap } => {
112                assert_eq!(len, 5);
113                assert_eq!(cap, 4);
114            }
115        }
116    }
117
118    #[test]
119    fn bounded_string_serde_roundtrip_postcard() {
120        let s = BoundedString::<16>::new("hello").unwrap();
121        let bytes = postcard::to_stdvec(&s).unwrap();
122        let back: BoundedString<16> = postcard::from_bytes(&bytes).unwrap();
123        assert_eq!(s, back);
124    }
125
126    #[test]
127    fn bounded_string_deserialize_rejects_over_cap_at_runtime() {
128        let big = BoundedString::<16>::new("0123456789abcdef").unwrap();
129        let bytes = postcard::to_stdvec(&big).unwrap();
130        let res: Result<BoundedString<8>, _> = postcard::from_bytes(&bytes);
131        assert!(res.is_err());
132    }
133}