avmnif_rs/
tagged.rs

1//! Tagged Map serialization for creating Erlang-compatible ADTs
2//!
3//! This module provides automatic serialization of Rust types into
4//! Erlang maps with type discriminators, enabling type-safe communication
5//! between Rust ports/NIFs and Erlang processes.
6//!
7//! # Design Philosophy
8//!
9//! All operations are generic and work with any AtomTableOps implementation.
10//! No global state, no hardcoded dependencies - pure dependency injection.
11//!
12//! # Examples
13//!
14//! ```rust,ignore
15//! use avmnif_rs::tagged::{TaggedMap, TaggedError};
16//! use avmnif_rs::testing::mocks::MockAtomTable;
17//!
18//! #[derive(TaggedMap)]
19//! struct SensorReading {
20//!     temperature: f32,
21//!     humidity: f32,
22//!     timestamp: u64,
23//! }
24//!
25//! // In tests:
26//! let table = MockAtomTable::new();
27//! let reading = SensorReading { temperature: 23.5, humidity: 45.2, timestamp: 1634567890 };
28//! let term = reading.to_tagged_map(&table)?;
29//! let parsed = SensorReading::from_tagged_map(term, &table)?;
30//!
31//! // In production:
32//! let table = AtomTable::from_global();
33//! let term = reading.to_tagged_map(&table)?;
34//! ```
35
36extern crate alloc;
37
38use crate::atom::{AtomTableOps, AtomError, atoms};
39use crate::term::{AtomIndex, TermValue};
40use alloc::{string::String, string::ToString, vec, vec::Vec, format};
41use core::fmt;
42
43// ── Error Handling ──────────────────────────────────────────────────────────
44
45/// Errors that can occur during tagged map operations
46#[derive(Debug, Clone, PartialEq)]
47pub enum TaggedError {
48    /// Atom-related error (atom creation, lookup, etc.)
49    AtomError(AtomError),
50    /// Wrong type for operation
51    WrongType { expected: &'static str, found: &'static str },
52    /// Index/key out of bounds  
53    OutOfBounds { index: usize, max: usize },
54    /// Required field missing from map
55    MissingField(String),
56    /// Type discriminator doesn't match expected type
57    TypeMismatch { expected: String, found: String },
58    /// Invalid enum variant
59    InvalidVariant { enum_name: String, variant: String },
60    /// Memory allocation failed
61    OutOfMemory,
62    /// Invalid UTF-8 in binary
63    InvalidUtf8,
64    /// Nested error with path context
65    NestedError { path: String, source: alloc::boxed::Box<TaggedError> },
66    /// Generic error with message
67    Other(String),
68}
69
70impl TaggedError {
71    /// Create a nested error with path context
72    pub fn nested(path: impl Into<String>, source: TaggedError) -> Self {
73        TaggedError::NestedError {
74            path: path.into(),
75            source: alloc::boxed::Box::new(source),
76        }
77    }
78    
79    /// Create a type mismatch error
80    pub fn type_mismatch(expected: impl Into<String>, found: impl Into<String>) -> Self {
81        TaggedError::TypeMismatch {
82            expected: expected.into(),
83            found: found.into(),
84        }
85    }
86    
87    /// Create a missing field error
88    pub fn missing_field(field: impl Into<String>) -> Self {
89        TaggedError::MissingField(field.into())
90    }
91    
92    /// Create an invalid variant error
93    pub fn invalid_variant(enum_name: impl Into<String>, variant: impl Into<String>) -> Self {
94        TaggedError::InvalidVariant {
95            enum_name: enum_name.into(),
96            variant: variant.into(),
97        }
98    }
99}
100
101impl fmt::Display for TaggedError {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        match self {
104            TaggedError::AtomError(e) => write!(f, "atom error: {}", e),
105            TaggedError::WrongType { expected, found } => 
106                write!(f, "wrong type: expected {}, found {}", expected, found),
107            TaggedError::OutOfBounds { index, max } => 
108                write!(f, "index {} out of bounds (max: {})", index, max),
109            TaggedError::MissingField(field) => 
110                write!(f, "missing required field: {}", field),
111            TaggedError::TypeMismatch { expected, found } => 
112                write!(f, "type mismatch: expected {}, found {}", expected, found),
113            TaggedError::InvalidVariant { enum_name, variant } => 
114                write!(f, "invalid variant '{}' for enum {}", variant, enum_name),
115            TaggedError::OutOfMemory => write!(f, "out of memory"),
116            TaggedError::InvalidUtf8 => write!(f, "invalid UTF-8"),
117            TaggedError::NestedError { path, source } => 
118                write!(f, "error at {}: {}", path, source),
119            TaggedError::Other(msg) => write!(f, "{}", msg),
120        }
121    }
122}
123
124impl From<AtomError> for TaggedError {
125    fn from(error: AtomError) -> Self {
126        TaggedError::AtomError(error)
127    }
128}
129
130/// Result type for tagged map operations
131pub type TaggedResult<T> = core::result::Result<T, TaggedError>;
132
133// ── Core Trait ──────────────────────────────────────────────────────────────
134
135/// Trait for types that can be converted to/from tagged Erlang maps
136/// 
137/// All operations are generic and work with any AtomTableOps implementation.
138pub trait TaggedMap: Sized {
139    /// Convert this type to a tagged Erlang map using any atom table
140    /// 
141    /// The resulting map will have a `type` field with the type discriminator
142    /// and additional fields for the struct/enum data.
143    fn to_tagged_map<T: AtomTableOps>(&self, table: &T) -> TaggedResult<TermValue>;
144    
145    /// Create this type from a tagged Erlang map using any atom table
146    /// 
147    /// Validates the `type` field matches the expected type and extracts
148    /// the remaining fields to reconstruct the Rust type.
149    fn from_tagged_map<T: AtomTableOps>(map: TermValue, table: &T) -> TaggedResult<Self>;
150    
151    /// Get the type atom name for this type (used for discriminator)
152    fn type_name() -> &'static str;
153}
154
155// ── Helper Functions ────────────────────────────────────────────────────────
156
157/// Convert Rust identifier to snake_case atom name
158/// 
159/// Examples:
160/// - `SensorReading` -> `"sensor_reading"`
161/// - `HTTPClient` -> `"httpclient"`
162/// - `XMLParser` -> `"xmlparser"`
163pub fn to_snake_case(name: &str) -> String {
164    let mut result = String::new();
165    let chars: Vec<char> = name.chars().collect();
166    
167    for (i, &ch) in chars.iter().enumerate() {
168        if ch.is_uppercase() {
169            // Check if we should add an underscore
170            let should_add_underscore = if i == 0 {
171                false // Never add underscore at start
172            } else {
173                let prev_char = chars[i - 1];
174                // Add underscore if previous char was lowercase (camelCase boundary)
175                prev_char.is_lowercase()
176            };
177            
178            if should_add_underscore {
179                result.push('_');
180            }
181            
182            result.push(ch.to_lowercase().next().unwrap());
183        } else {
184            result.push(ch);
185        }
186    }
187    
188    result
189}
190
191/// Get atom index for a type name, creating it if necessary
192pub fn get_type_atom<T: AtomTableOps>(type_name: &str, table: &T) -> TaggedResult<AtomIndex> {
193    let atom_index = table.ensure_atom_str(type_name).map_err(TaggedError::from)?;
194    Ok(atom_index)
195}
196
197/// Get the standard "type" field atom
198pub fn type_field_atom<T: AtomTableOps>(table: &T) -> TaggedResult<AtomIndex> {
199    let atom_index = table.ensure_atom_str("type").map_err(TaggedError::from)?;
200    Ok(atom_index)
201}
202
203/// Get the standard "variant" field atom (for enums)
204pub fn variant_field_atom<T: AtomTableOps>(table: &T) -> TaggedResult<AtomIndex> {
205    let atom_index = table.ensure_atom_str("variant").map_err(TaggedError::from)?;
206    Ok(atom_index)
207}
208
209/// Extract map value by atom key
210pub fn get_map_value(map: &TermValue, key_atom: AtomIndex) -> TaggedResult<&TermValue> {
211    match map {
212        TermValue::Map(pairs) => {
213            let key = TermValue::Atom(key_atom);
214            pairs.iter()
215                .find(|(k, _)| k == &key)
216                .map(|(_, v)| v)
217                .ok_or_else(|| TaggedError::Other(format!("key not found in map")))
218        }
219        _ => Err(TaggedError::WrongType { expected: "map", found: "other" }),
220    }
221}
222
223/// Extract required string field from map
224pub fn extract_string_field<T: AtomTableOps>(map: &TermValue, field_name: &str, table: &T) -> TaggedResult<String> {
225    let field_atom = get_type_atom(field_name, table)?;
226    let value = get_map_value(map, field_atom)?;
227    
228    match value {
229        TermValue::Binary(bytes) => {
230            String::from_utf8(bytes.clone()).map_err(|_| TaggedError::InvalidUtf8)
231        }
232        _ => Err(TaggedError::WrongType { expected: "binary/string", found: "other" }),
233    }
234}
235
236/// Extract required integer field from map
237pub fn extract_int_field<T: AtomTableOps>(map: &TermValue, field_name: &str, table: &T) -> TaggedResult<i32> {
238    let field_atom = get_type_atom(field_name, table)?;
239    let value = get_map_value(map, field_atom)?;
240    
241    match value {
242        TermValue::SmallInt(i) => Ok(*i),
243        _ => Err(TaggedError::WrongType { expected: "integer", found: "other" }),
244    }
245}
246
247/// Extract required float field from map  
248pub fn extract_float_field<T: AtomTableOps>(map: &TermValue, field_name: &str, table: &T) -> TaggedResult<f64> {
249    let field_atom = get_type_atom(field_name, table)?;
250    let value = get_map_value(map, field_atom)?;
251    
252    match value {
253        TermValue::Float(f) => Ok(*f),
254        TermValue::SmallInt(i) => Ok(*i as f64), // Allow integer to float conversion
255        _ => Err(TaggedError::WrongType { expected: "float", found: "other" }),
256    }
257}
258
259/// Extract required boolean field from map
260pub fn extract_bool_field<T: AtomTableOps>(map: &TermValue, field_name: &str, table: &T) -> TaggedResult<bool> {
261    let field_atom = get_type_atom(field_name, table)?;
262    let value = get_map_value(map, field_atom)?;
263    
264    let true_atom = atoms::true_atom(table).map_err(TaggedError::from)?;
265    let false_atom = atoms::false_atom(table).map_err(TaggedError::from)?;
266    
267    match value {
268        TermValue::Atom(atom_idx) => {
269            if *atom_idx == true_atom {
270                Ok(true)
271            } else if *atom_idx == false_atom {
272                Ok(false)
273            } else {
274                Err(TaggedError::WrongType { expected: "boolean", found: "other atom" })
275            }
276        }
277        _ => Err(TaggedError::WrongType { expected: "boolean", found: "other" }),
278    }
279}
280
281/// Extract optional field from map
282pub fn extract_optional_field<R, F, A>(
283    map: &TermValue, 
284    field_name: &str, 
285    table: &A,
286    extractor: F
287) -> TaggedResult<Option<R>>
288where
289    F: FnOnce(&TermValue, &A) -> TaggedResult<R>,
290    A: AtomTableOps,
291{
292    let field_atom = get_type_atom(field_name, table)?;
293    
294    match get_map_value(map, field_atom) {
295        Ok(value) => {
296            let nil_atom = atoms::nil(table).map_err(TaggedError::from)?;
297            match value {
298                TermValue::Atom(atom_idx) if *atom_idx == nil_atom => Ok(None),
299                _ => extractor(value, table).map(Some),
300            }
301        }
302        Err(_) => Ok(None), // Field not present
303    }
304}
305
306/// Validate map has expected type discriminator
307pub fn validate_type_discriminator<T: AtomTableOps>(map: &TermValue, expected_type: &str, table: &T) -> TaggedResult<()> {
308    let type_atom = type_field_atom(table)?;
309    let expected_type_atom = get_type_atom(expected_type, table)?;
310    
311    let type_value = get_map_value(map, type_atom)?;
312    
313    match type_value {
314        TermValue::Atom(actual_type_atom) => {
315            if *actual_type_atom == expected_type_atom {
316                Ok(())
317            } else {
318                // Try to get readable atom name for error
319                let actual_name = match table.get_atom_string(*actual_type_atom) {
320                    Ok(atom_ref) => atom_ref.as_str().unwrap_or("unknown").to_string(),
321                    Err(_) => "unknown".to_string(),
322                };
323                Err(TaggedError::type_mismatch(expected_type, actual_name))
324            }
325        }
326        _ => Err(TaggedError::WrongType { expected: "atom", found: "other" }),
327    }
328}
329
330// ── Generic Primitive Type Implementations ─────────────────────────────────
331
332// These allow primitive types to be used directly in tagged structs
333
334impl TaggedMap for i32 {
335    fn to_tagged_map<T: AtomTableOps>(&self, table: &T) -> TaggedResult<TermValue> {
336        let type_atom = get_type_atom("i32", table)?;
337        let value_atom = get_type_atom("value", table)?;
338        
339        let pairs = alloc::vec![
340            (TermValue::Atom(type_field_atom(table)?), TermValue::Atom(type_atom)),
341            (TermValue::Atom(value_atom), TermValue::SmallInt(*self)),
342        ];
343        
344        Ok(TermValue::Map(pairs))
345    }
346    
347    fn from_tagged_map<T: AtomTableOps>(map: TermValue, table: &T) -> TaggedResult<Self> {
348        validate_type_discriminator(&map, "i32", table)?;
349        extract_int_field(&map, "value", table)
350    }
351    
352    fn type_name() -> &'static str {
353        "i32"
354    }
355}
356
357impl TaggedMap for String {
358    fn to_tagged_map<T: AtomTableOps>(&self, table: &T) -> TaggedResult<TermValue> {
359        let type_atom = get_type_atom("string", table)?;
360        let value_atom = get_type_atom("value", table)?;
361        
362        let pairs = alloc::vec![
363            (TermValue::Atom(type_field_atom(table)?), TermValue::Atom(type_atom)),
364            (TermValue::Atom(value_atom), TermValue::Binary(self.as_bytes().to_vec())),
365        ];
366        
367        Ok(TermValue::Map(pairs))
368    }
369    
370    fn from_tagged_map<T: AtomTableOps>(map: TermValue, table: &T) -> TaggedResult<Self> {
371        validate_type_discriminator(&map, "string", table)?;
372        extract_string_field(&map, "value", table)
373    }
374    
375    fn type_name() -> &'static str {
376        "string"
377    }
378}
379
380impl TaggedMap for bool {
381    fn to_tagged_map<T: AtomTableOps>(&self, table: &T) -> TaggedResult<TermValue> {
382        let type_atom = get_type_atom("bool", table)?;
383        let value_atom = get_type_atom("value", table)?;
384        let bool_atom = if *self { 
385            atoms::true_atom(table).map_err(TaggedError::from)? 
386        } else { 
387            atoms::false_atom(table).map_err(TaggedError::from)? 
388        };
389        
390        let pairs = alloc::vec![
391            (TermValue::Atom(type_field_atom(table)?), TermValue::Atom(type_atom)),
392            (TermValue::Atom(value_atom), TermValue::Atom(bool_atom)),
393        ];
394        
395        Ok(TermValue::Map(pairs))
396    }
397    
398    fn from_tagged_map<T: AtomTableOps>(map: TermValue, table: &T) -> TaggedResult<Self> {
399        validate_type_discriminator(&map, "bool", table)?;
400        extract_bool_field(&map, "value", table)
401    }
402    
403    fn type_name() -> &'static str {
404        "bool"
405    }
406}
407
408impl<U: TaggedMap> TaggedMap for Option<U> {
409    fn to_tagged_map<T: AtomTableOps>(&self, table: &T) -> TaggedResult<TermValue> {
410        match self {
411            Some(value) => {
412                let inner_map = value.to_tagged_map(table)?;
413                let type_atom = get_type_atom("option", table)?;
414                let variant_atom = variant_field_atom(table)?;
415                let some_atom = get_type_atom("some", table)?;
416                let value_atom = get_type_atom("value", table)?;
417                
418                let pairs = alloc::vec![
419                    (TermValue::Atom(type_field_atom(table)?), TermValue::Atom(type_atom)),
420                    (TermValue::Atom(variant_atom), TermValue::Atom(some_atom)),
421                    (TermValue::Atom(value_atom), inner_map),
422                ];
423                
424                Ok(TermValue::Map(pairs))
425            }
426            None => {
427                let type_atom = get_type_atom("option", table)?;
428                let variant_atom = variant_field_atom(table)?;
429                let none_atom = atoms::nil(table).map_err(TaggedError::from)?;
430                
431                let pairs = alloc::vec![
432                    (TermValue::Atom(type_field_atom(table)?), TermValue::Atom(type_atom)),
433                    (TermValue::Atom(variant_atom), TermValue::Atom(none_atom)),
434                ];
435                
436                Ok(TermValue::Map(pairs))
437            }
438        }
439    }
440    
441    fn from_tagged_map<T: AtomTableOps>(map: TermValue, table: &T) -> TaggedResult<Self> {
442        validate_type_discriminator(&map, "option", table)?;
443        
444        let variant_atom = variant_field_atom(table)?;
445        let variant_value = get_map_value(&map, variant_atom)?;
446        
447        let some_atom = get_type_atom("some", table)?;
448        let none_atom = atoms::nil(table).map_err(TaggedError::from)?;
449        
450        match variant_value {
451            TermValue::Atom(atom_idx) if *atom_idx == some_atom => {
452                let value_atom = get_type_atom("value", table)?;
453                let inner_map = get_map_value(&map, value_atom)?;
454                let inner_value = U::from_tagged_map(inner_map.clone(), table)?;
455                Ok(Some(inner_value))
456            }
457            TermValue::Atom(atom_idx) if *atom_idx == none_atom => {
458                Ok(None)
459            }
460            _ => Err(TaggedError::invalid_variant("Option", "unknown")),
461        }
462    }
463    
464    fn type_name() -> &'static str {
465        "option"
466    }
467}
468
469impl<U: TaggedMap> TaggedMap for Vec<U> {
470    fn to_tagged_map<T: AtomTableOps>(&self, table: &T) -> TaggedResult<TermValue> {
471        let type_atom = get_type_atom("vec", table)?;
472        let elements_atom = get_type_atom("elements", table)?;
473        
474        // Convert each element to tagged map
475        let mut element_maps = Vec::new();
476        for item in self {
477            element_maps.push(item.to_tagged_map(table)?);
478        }
479        
480        let elements_list = TermValue::from_vec(element_maps);
481        
482        let pairs = alloc::vec![
483            (TermValue::Atom(type_field_atom(table)?), TermValue::Atom(type_atom)),
484            (TermValue::Atom(elements_atom), elements_list),
485        ];
486        
487        Ok(TermValue::Map(pairs))
488    }
489    
490    fn from_tagged_map<T: AtomTableOps>(map: TermValue, table: &T) -> TaggedResult<Self> {
491        validate_type_discriminator(&map, "vec", table)?;
492        
493        let elements_atom = get_type_atom("elements", table)?;
494        let elements_value = get_map_value(&map, elements_atom)?;
495        
496        let elements_vec = elements_value.list_to_vec();
497        let mut result = Vec::new();
498        
499        for element_map in elements_vec {
500            let item = U::from_tagged_map(element_map, table)?;
501            result.push(item);
502        }
503        
504        Ok(result)
505    }
506    
507    fn type_name() -> &'static str {
508        "vec"
509    }
510}
511
512// ── Re-exports ──────────────────────────────────────────────────────────────
513
514// Re-export the derive macro when available
515#[cfg(feature = "derive")]
516pub use avmnif_derive::TaggedMap;