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    /// Approximate payload size for quota tracking. Default returns
37    /// `size_of::<Self>()`; override for `bytes::Bytes`-carrying Components.
38    fn approx_size(&self) -> usize {
39        core::mem::size_of::<Self>()
40    }
41}
42
43/// Fixed-capacity UTF-8 string — bounded at compile time by const generic `N`.
44///
45/// Canonical wire = `postcard::serialize_str` (varint length + `N`-bounded
46/// UTF-8 bytes). `N` is not on the wire — decode checks against the const.
47/// Expanding `N` requires a `SCHEMA_VERSION` bump on the enclosing Component;
48/// shrinking is forbidden (existing records might exceed the new cap).
49#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
50pub struct BoundedString<const N: usize>(ArrayString<N>);
51
52impl<const N: usize> BoundedString<N> {
53    /// Construct from a borrowed `&str`. Rejects over-length input.
54    pub fn new(s: &str) -> Result<Self, BoundedStringError> {
55        ArrayString::from(s)
56            .map(Self)
57            .map_err(|_| BoundedStringError::Overflow {
58                len: s.len(),
59                cap: N,
60            })
61    }
62
63    /// Borrow as `&str`.
64    #[inline]
65    #[must_use]
66    pub fn as_str(&self) -> &str {
67        self.0.as_str()
68    }
69
70    /// Compile-time capacity.
71    pub const CAP: usize = N;
72}
73
74impl<const N: usize> Serialize for BoundedString<N> {
75    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
76        s.serialize_str(self.0.as_str())
77    }
78}
79
80impl<'de, const N: usize> Deserialize<'de> for BoundedString<N> {
81    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
82        let s: &str = <&str>::deserialize(d)?;
83        Self::new(s).map_err(D::Error::custom)
84    }
85}
86
87/// Failure variants for [`BoundedString`].
88#[non_exhaustive]
89#[derive(Debug, Clone, thiserror::Error, Eq, PartialEq)]
90pub enum BoundedStringError {
91    /// Input length exceeded the compile-time capacity.
92    #[error("BoundedString overflow: len {len} > cap {cap}")]
93    Overflow {
94        /// Attempted length in bytes.
95        len: usize,
96        /// Compile-time capacity.
97        cap: usize,
98    },
99}
100
101#[cfg(test)]
102#[allow(clippy::unwrap_used, clippy::expect_used)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn bounded_string_accepts_within_cap() {
108        let s = BoundedString::<8>::new("abcd").unwrap();
109        assert_eq!(s.as_str(), "abcd");
110        assert_eq!(BoundedString::<8>::CAP, 8);
111    }
112
113    #[test]
114    fn bounded_string_rejects_over_cap() {
115        let e = BoundedString::<4>::new("hello").unwrap_err();
116        match e {
117            BoundedStringError::Overflow { len, cap } => {
118                assert_eq!(len, 5);
119                assert_eq!(cap, 4);
120            }
121        }
122    }
123
124    #[test]
125    fn bounded_string_serde_roundtrip_postcard() {
126        let s = BoundedString::<16>::new("hello").unwrap();
127        let bytes = postcard::to_stdvec(&s).unwrap();
128        let back: BoundedString<16> = postcard::from_bytes(&bytes).unwrap();
129        assert_eq!(s, back);
130    }
131
132    #[test]
133    fn bounded_string_deserialize_rejects_over_cap_at_runtime() {
134        let big = BoundedString::<16>::new("0123456789abcdef").unwrap();
135        let bytes = postcard::to_stdvec(&big).unwrap();
136        let res: Result<BoundedString<8>, _> = postcard::from_bytes(&bytes);
137        assert!(res.is_err());
138    }
139}