Skip to main content

cu29_traits/
lib.rs

1//! Common copper traits and types for robotics systems.
2//!
3//! This crate is no_std compatible by default. Enable the "std" feature for additional
4//! functionality like implementing `std::error::Error` for `CuError` and the
5//! `new_with_cause` method that accepts types implementing `std::error::Error`.
6//!
7//! # Features
8//!
9//! - `std` (default): Enables standard library support
10//!   - Implements `std::error::Error` for `CuError`
11//!   - Adds `CuError::new_with_cause()` method for interop with std error types
12//!
13//! # no_std Usage
14//!
15//! To use without the standard library:
16//!
17//! ```toml
18//! [dependencies]
19//! cu29-traits = { version = "0.9", default-features = false }
20//! ```
21
22#![cfg_attr(not(feature = "std"), no_std)]
23extern crate alloc;
24
25#[cfg(feature = "reflect")]
26pub use bevy_reflect::Reflect;
27use bincode::de::{BorrowDecoder, Decoder};
28use bincode::enc::Encoder;
29use bincode::enc::write::Writer;
30use bincode::error::{DecodeError, EncodeError};
31use bincode::{BorrowDecode, Decode as dDecode, Decode, Encode, Encode as dEncode};
32use compact_str::CompactString;
33use cu29_clock::{PartialCuTimeRange, Tov};
34use serde::{Deserialize, Serialize};
35
36use alloc::boxed::Box;
37use alloc::format;
38use alloc::string::{String, ToString};
39use alloc::vec::Vec;
40#[cfg(feature = "std")]
41use core::cell::Cell;
42#[cfg(not(feature = "std"))]
43use core::error::Error as CoreError;
44use core::fmt::{Debug, Display, Formatter};
45#[cfg(feature = "std")]
46use std::error::Error;
47
48#[cfg(not(feature = "std"))]
49use spin::Mutex as SpinMutex;
50
51// Type alias for the boxed error type to simplify conditional compilation
52#[cfg(feature = "std")]
53type DynError = dyn std::error::Error + Send + Sync + 'static;
54#[cfg(not(feature = "std"))]
55type DynError = dyn core::error::Error + Send + Sync + 'static;
56
57/// A simple wrapper around String that implements Error trait.
58/// Used for cloning and deserializing CuError causes.
59#[derive(Debug)]
60struct StringError(String);
61
62impl Display for StringError {
63    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
64        write!(f, "{}", self.0)
65    }
66}
67
68#[cfg(feature = "std")]
69impl std::error::Error for StringError {}
70
71#[cfg(not(feature = "std"))]
72impl core::error::Error for StringError {}
73
74/// Common copper Error type.
75///
76/// This error type stores an optional cause as a boxed dynamic error,
77/// allowing for proper error chaining while maintaining Clone and
78/// Serialize/Deserialize support through custom implementations.
79pub struct CuError {
80    message: String,
81    cause: Option<Box<DynError>>,
82}
83
84// Custom Debug implementation that formats cause as string
85impl Debug for CuError {
86    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
87        f.debug_struct("CuError")
88            .field("message", &self.message)
89            .field("cause", &self.cause.as_ref().map(|e| e.to_string()))
90            .finish()
91    }
92}
93
94// Custom Clone implementation - clones cause as StringError wrapper
95impl Clone for CuError {
96    fn clone(&self) -> Self {
97        CuError {
98            message: self.message.clone(),
99            cause: self
100                .cause
101                .as_ref()
102                .map(|e| Box::new(StringError(e.to_string())) as Box<DynError>),
103        }
104    }
105}
106
107// Custom Serialize - serializes cause as Option<String>
108impl Serialize for CuError {
109    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
110    where
111        S: serde::Serializer,
112    {
113        use serde::ser::SerializeStruct;
114        let mut state = serializer.serialize_struct("CuError", 2)?;
115        state.serialize_field("message", &self.message)?;
116        state.serialize_field("cause", &self.cause.as_ref().map(|e| e.to_string()))?;
117        state.end()
118    }
119}
120
121// Custom Deserialize - deserializes cause as StringError wrapper
122impl<'de> Deserialize<'de> for CuError {
123    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
124    where
125        D: serde::Deserializer<'de>,
126    {
127        #[derive(Deserialize)]
128        struct CuErrorHelper {
129            message: String,
130            cause: Option<String>,
131        }
132
133        let helper = CuErrorHelper::deserialize(deserializer)?;
134        Ok(CuError {
135            message: helper.message,
136            cause: helper
137                .cause
138                .map(|s| Box::new(StringError(s)) as Box<DynError>),
139        })
140    }
141}
142
143impl Display for CuError {
144    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
145        let context_str = match &self.cause {
146            Some(c) => c.to_string(),
147            None => "None".to_string(),
148        };
149        write!(f, "{}\n   context:{}", self.message, context_str)?;
150        Ok(())
151    }
152}
153
154#[cfg(not(feature = "std"))]
155impl CoreError for CuError {
156    fn source(&self) -> Option<&(dyn CoreError + 'static)> {
157        self.cause
158            .as_deref()
159            .map(|e| e as &(dyn CoreError + 'static))
160    }
161}
162
163#[cfg(feature = "std")]
164impl Error for CuError {
165    fn source(&self) -> Option<&(dyn Error + 'static)> {
166        self.cause.as_deref().map(|e| e as &(dyn Error + 'static))
167    }
168}
169
170impl From<&str> for CuError {
171    fn from(s: &str) -> CuError {
172        CuError {
173            message: s.to_string(),
174            cause: None,
175        }
176    }
177}
178
179impl From<String> for CuError {
180    fn from(s: String) -> CuError {
181        CuError {
182            message: s,
183            cause: None,
184        }
185    }
186}
187
188impl CuError {
189    /// Creates a new CuError from an interned string index.
190    /// Used by the cu_error! macro.
191    ///
192    /// The index is stored as a placeholder string `[interned:{index}]`.
193    /// Actual string resolution happens at logging time via the unified logger.
194    pub fn new(message_index: usize) -> CuError {
195        CuError {
196            message: format!("[interned:{}]", message_index),
197            cause: None,
198        }
199    }
200
201    /// Creates a new CuError with a message and an underlying cause.
202    ///
203    /// # Example
204    /// ```
205    /// use cu29_traits::CuError;
206    ///
207    /// let io_err = std::io::Error::other("io error");
208    /// let err = CuError::new_with_cause("Failed to read file", io_err);
209    /// ```
210    #[cfg(feature = "std")]
211    pub fn new_with_cause<E>(message: &str, cause: E) -> CuError
212    where
213        E: std::error::Error + Send + Sync + 'static,
214    {
215        CuError {
216            message: message.to_string(),
217            cause: Some(Box::new(cause)),
218        }
219    }
220
221    /// Creates a new CuError with a message and an underlying cause.
222    #[cfg(not(feature = "std"))]
223    pub fn new_with_cause<E>(message: &str, cause: E) -> CuError
224    where
225        E: core::error::Error + Send + Sync + 'static,
226    {
227        CuError {
228            message: message.to_string(),
229            cause: Some(Box::new(cause)),
230        }
231    }
232
233    /// Adds or replaces the cause with a context string.
234    ///
235    /// This is useful for adding context to errors during propagation.
236    ///
237    /// # Example
238    /// ```
239    /// use cu29_traits::CuError;
240    ///
241    /// let err = CuError::from("base error").add_cause("additional context");
242    /// ```
243    pub fn add_cause(mut self, context: &str) -> CuError {
244        self.cause = Some(Box::new(StringError(context.to_string())));
245        self
246    }
247
248    /// Adds a cause error to this CuError (builder pattern).
249    ///
250    /// # Example
251    /// ```
252    /// use cu29_traits::CuError;
253    ///
254    /// let io_err = std::io::Error::other("io error");
255    /// let err = CuError::from("Operation failed").with_cause(io_err);
256    /// ```
257    #[cfg(feature = "std")]
258    pub fn with_cause<E>(mut self, cause: E) -> CuError
259    where
260        E: std::error::Error + Send + Sync + 'static,
261    {
262        self.cause = Some(Box::new(cause));
263        self
264    }
265
266    /// Adds a cause error to this CuError (builder pattern).
267    #[cfg(not(feature = "std"))]
268    pub fn with_cause<E>(mut self, cause: E) -> CuError
269    where
270        E: core::error::Error + Send + Sync + 'static,
271    {
272        self.cause = Some(Box::new(cause));
273        self
274    }
275
276    /// Returns a reference to the underlying cause, if any.
277    pub fn cause(&self) -> Option<&(dyn core::error::Error + Send + Sync + 'static)> {
278        self.cause.as_deref()
279    }
280
281    /// Returns the error message.
282    pub fn message(&self) -> &str {
283        &self.message
284    }
285}
286
287/// Creates a CuError with a message and cause in a single call.
288///
289/// This is a convenience function for use with `.map_err()`.
290///
291/// # Example
292/// ```
293/// use cu29_traits::with_cause;
294///
295/// let result: Result<(), std::io::Error> = Err(std::io::Error::other("io error"));
296/// let cu_result = result.map_err(|e| with_cause("Failed to read file", e));
297/// ```
298#[cfg(feature = "std")]
299pub fn with_cause<E>(message: &str, cause: E) -> CuError
300where
301    E: std::error::Error + Send + Sync + 'static,
302{
303    CuError::new_with_cause(message, cause)
304}
305
306/// Creates a CuError with a message and cause in a single call.
307#[cfg(not(feature = "std"))]
308pub fn with_cause<E>(message: &str, cause: E) -> CuError
309where
310    E: core::error::Error + Send + Sync + 'static,
311{
312    CuError::new_with_cause(message, cause)
313}
314
315// Generic Result type for copper.
316pub type CuResult<T> = Result<T, CuError>;
317
318#[cfg(feature = "std")]
319thread_local! {
320    static OBSERVED_ENCODE_BYTES: Cell<Option<usize>> = const { Cell::new(None) };
321}
322
323#[cfg(not(feature = "std"))]
324static OBSERVED_ENCODE_BYTES: SpinMutex<Option<usize>> = SpinMutex::new(None);
325
326/// Starts observed byte counting for the current encode pass.
327pub fn begin_observed_encode() {
328    #[cfg(feature = "std")]
329    OBSERVED_ENCODE_BYTES.with(|bytes| {
330        debug_assert!(
331            bytes.get().is_none(),
332            "observed encode measurement must not be nested"
333        );
334        bytes.set(Some(0));
335    });
336
337    #[cfg(not(feature = "std"))]
338    {
339        let mut bytes = OBSERVED_ENCODE_BYTES.lock();
340        debug_assert!(
341            bytes.is_none(),
342            "observed encode measurement must not be nested"
343        );
344        *bytes = Some(0);
345    }
346}
347
348/// Ends observed byte counting and returns the total bytes written.
349pub fn finish_observed_encode() -> usize {
350    #[cfg(feature = "std")]
351    {
352        OBSERVED_ENCODE_BYTES.with(|bytes| bytes.replace(None).unwrap_or(0))
353    }
354
355    #[cfg(not(feature = "std"))]
356    {
357        OBSERVED_ENCODE_BYTES.lock().take().unwrap_or(0)
358    }
359}
360
361/// Aborts any active observed byte counting session.
362pub fn abort_observed_encode() {
363    #[cfg(feature = "std")]
364    OBSERVED_ENCODE_BYTES.with(|bytes| bytes.set(None));
365
366    #[cfg(not(feature = "std"))]
367    {
368        *OBSERVED_ENCODE_BYTES.lock() = None;
369    }
370}
371
372/// Returns the number of bytes written so far in the current observed encode pass.
373pub fn observed_encode_bytes() -> usize {
374    #[cfg(feature = "std")]
375    {
376        OBSERVED_ENCODE_BYTES.with(|bytes| bytes.get().unwrap_or(0))
377    }
378
379    #[cfg(not(feature = "std"))]
380    {
381        OBSERVED_ENCODE_BYTES.lock().as_ref().copied().unwrap_or(0)
382    }
383}
384
385/// Records bytes written by an observed writer.
386pub fn record_observed_encode_bytes(bytes: usize) {
387    #[cfg(feature = "std")]
388    OBSERVED_ENCODE_BYTES.with(|total| {
389        if let Some(current) = total.get() {
390            total.set(Some(current.saturating_add(bytes)));
391        }
392    });
393
394    #[cfg(not(feature = "std"))]
395    {
396        let mut total = OBSERVED_ENCODE_BYTES.lock();
397        if let Some(current) = *total {
398            *total = Some(current.saturating_add(bytes));
399        }
400    }
401}
402
403/// A bincode writer wrapper that reports every encoded byte to Copper's
404/// observation counters.
405pub struct ObservedWriter<W> {
406    inner: W,
407}
408
409impl<W> ObservedWriter<W> {
410    pub const fn new(inner: W) -> Self {
411        Self { inner }
412    }
413
414    pub fn into_inner(self) -> W {
415        self.inner
416    }
417
418    pub fn inner(&self) -> &W {
419        &self.inner
420    }
421
422    pub fn inner_mut(&mut self) -> &mut W {
423        &mut self.inner
424    }
425}
426
427impl<W: Writer> Writer for ObservedWriter<W> {
428    #[inline(always)]
429    fn write(&mut self, bytes: &[u8]) -> Result<(), EncodeError> {
430        self.inner.write(bytes)?;
431        record_observed_encode_bytes(bytes.len());
432        Ok(())
433    }
434}
435
436/// Defines a basic write, append only stream trait to be able to log or send serializable objects.
437pub trait WriteStream<E: Encode>: Debug + Send + Sync {
438    fn log(&mut self, obj: &E) -> CuResult<()>;
439    fn flush(&mut self) -> CuResult<()> {
440        Ok(())
441    }
442    /// Optional byte count of the last successful `log` call, if the implementation can report it.
443    fn last_log_bytes(&self) -> Option<usize> {
444        None
445    }
446}
447
448/// Defines the types of what can be logged in the unified logger.
449#[derive(dEncode, dDecode, Copy, Clone, Debug, PartialEq)]
450pub enum UnifiedLogType {
451    Empty,             // Dummy default used as a debug marker
452    StructuredLogLine, // This is for the structured logs (ie. debug! etc..)
453    CopperList,        // This is the actual data log storing activities between tasks.
454    FrozenTasks,       // Log of all frozen state of the tasks.
455    LastEntry,         // This is a special entry that is used to signal the end of the log.
456    RuntimeLifecycle,  // Runtime lifecycle events (mission/config/stack context).
457}
458/// Represent the minimum set of traits to be usable as Metadata in Copper.
459pub trait Metadata: Default + Debug + Clone + Encode + Decode<()> + Serialize {}
460
461impl Metadata for () {}
462
463/// Origin metadata captured when a Copper-aware transport receives a remote message.
464#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode, Serialize, Deserialize)]
465#[cfg_attr(feature = "reflect", derive(Reflect))]
466pub struct CuMsgOrigin {
467    pub subsystem_code: u16,
468    pub instance_id: u32,
469    pub cl_id: u64,
470}
471
472/// Key metadata piece attached to every message in Copper.
473pub trait CuMsgMetadataTrait {
474    /// The time range used for the processing of this message
475    fn process_time(&self) -> PartialCuTimeRange;
476
477    /// Small status text for user UI to get the realtime state of task (max 24 chrs)
478    fn status_txt(&self) -> &CuCompactString;
479
480    /// Remote Copper provenance captured on receive.
481    fn origin(&self) -> Option<&CuMsgOrigin> {
482        None
483    }
484}
485
486/// A generic trait to expose the generated CuStampedDataSet from the task graph.
487pub trait ErasedCuStampedData {
488    fn payload(&self) -> Option<&dyn erased_serde::Serialize>;
489    #[cfg(feature = "reflect")]
490    fn payload_reflect(&self) -> Option<&dyn Reflect>;
491    fn tov(&self) -> Tov;
492    fn metadata(&self) -> &dyn CuMsgMetadataTrait;
493}
494
495/// Trait to get a vector of type-erased CuStampedDataSet
496/// This is used for generic serialization of the copperlists
497pub trait ErasedCuStampedDataSet {
498    fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData>;
499}
500
501/// Provides per-output raw payload sizes aligned with `ErasedCuStampedDataSet::cumsgs` order.
502pub trait CuPayloadRawBytes {
503    /// Returns raw payload sizes (stack + heap) for each output message.
504    /// `None` indicates the payload was not produced for that output.
505    fn payload_raw_bytes(&self) -> Vec<Option<u64>>;
506}
507
508/// Trait to trace back from the CopperList the origin of each message slot.
509///
510/// The returned slice must be aligned with `ErasedCuStampedDataSet::cumsgs()`:
511/// index `i` maps to copperlist slot `i`.
512pub trait MatchingTasks {
513    fn get_all_task_ids() -> &'static [&'static str];
514}
515
516/// Trait for providing JSON schemas for CopperList payload types.
517///
518/// This trait is implemented by the generated CuMsgs type via the `gen_cumsgs!` macro
519/// when MCAP export support is enabled. It provides compile-time schema information
520/// for each task's payload type, enabling proper schema generation for Foxglove.
521///
522/// The default implementation returns an empty vector for backwards compatibility
523/// with code that doesn't need MCAP export support.
524pub trait PayloadSchemas {
525    /// Returns a vector of (task_id, schema_json) pairs.
526    ///
527    /// Each entry corresponds to a CopperList output slot, in slot order.
528    /// The schema is a JSON Schema string generated from the payload type.
529    fn get_payload_schemas() -> Vec<(&'static str, String)> {
530        Vec::new()
531    }
532}
533
534/// A CopperListTuple needs to be encodable, decodable and fixed size in memory.
535pub trait CopperListTuple:
536    bincode::Encode
537    + bincode::Decode<()>
538    + Debug
539    + Serialize
540    + ErasedCuStampedDataSet
541    + MatchingTasks
542    + Default
543{
544} // Decode forces Sized already
545
546// Also anything that follows this contract can be a payload (blanket implementation)
547impl<T> CopperListTuple for T where
548    T: bincode::Encode
549        + bincode::Decode<()>
550        + Debug
551        + Serialize
552        + ErasedCuStampedDataSet
553        + MatchingTasks
554        + Default
555{
556}
557
558// We use this type to convey very small status messages.
559// MAX_SIZE from their repr module is not accessible so we need to copy paste their definition for 24
560// which is the maximum size for inline allocation (no heap)
561pub const COMPACT_STRING_CAPACITY: usize = size_of::<String>();
562
563#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
564pub struct CuCompactString(pub CompactString);
565
566impl Encode for CuCompactString {
567    fn encode<E: Encoder>(&self, encoder: &mut E) -> Result<(), EncodeError> {
568        let CuCompactString(compact_string) = self;
569        let bytes = &compact_string.as_bytes();
570        bytes.encode(encoder)
571    }
572}
573
574impl Debug for CuCompactString {
575    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
576        if self.0.is_empty() {
577            return write!(f, "CuCompactString(Empty)");
578        }
579        write!(f, "CuCompactString({})", self.0)
580    }
581}
582
583impl<Context> Decode<Context> for CuCompactString {
584    fn decode<D: Decoder>(decoder: &mut D) -> Result<Self, DecodeError> {
585        let bytes = <Vec<u8> as Decode<D::Context>>::decode(decoder)?; // Decode into a byte buffer
586        let compact_string =
587            CompactString::from_utf8(bytes).map_err(|e| DecodeError::Utf8 { inner: e })?;
588        Ok(CuCompactString(compact_string))
589    }
590}
591
592impl<'de, Context> BorrowDecode<'de, Context> for CuCompactString {
593    fn borrow_decode<D: BorrowDecoder<'de>>(decoder: &mut D) -> Result<Self, DecodeError> {
594        CuCompactString::decode(decoder)
595    }
596}
597
598#[cfg(feature = "defmt")]
599impl defmt::Format for CuError {
600    fn format(&self, f: defmt::Formatter) {
601        match &self.cause {
602            Some(c) => {
603                let cause_str = c.to_string();
604                defmt::write!(
605                    f,
606                    "CuError {{ message: {}, cause: {} }}",
607                    defmt::Display2Format(&self.message),
608                    defmt::Display2Format(&cause_str),
609                )
610            }
611            None => defmt::write!(
612                f,
613                "CuError {{ message: {}, cause: None }}",
614                defmt::Display2Format(&self.message),
615            ),
616        }
617    }
618}
619
620#[cfg(feature = "defmt")]
621impl defmt::Format for CuCompactString {
622    fn format(&self, f: defmt::Formatter) {
623        if self.0.is_empty() {
624            defmt::write!(f, "CuCompactString(Empty)");
625        } else {
626            defmt::write!(f, "CuCompactString({})", defmt::Display2Format(&self.0));
627        }
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use crate::CuCompactString;
634    use bincode::{config, decode_from_slice, encode_to_vec};
635    use compact_str::CompactString;
636
637    #[test]
638    fn test_cucompactstr_encode_decode_empty() {
639        let cstr = CuCompactString(CompactString::from(""));
640        let config = config::standard();
641        let encoded = encode_to_vec(&cstr, config).expect("Encoding failed");
642        assert_eq!(encoded.len(), 1); // This encodes the usize 0 in variable encoding so 1 byte which is 0.
643        let (decoded, _): (CuCompactString, usize) =
644            decode_from_slice(&encoded, config).expect("Decoding failed");
645        assert_eq!(cstr.0, decoded.0);
646    }
647
648    #[test]
649    fn test_cucompactstr_encode_decode_small() {
650        let cstr = CuCompactString(CompactString::from("test"));
651        let config = config::standard();
652        let encoded = encode_to_vec(&cstr, config).expect("Encoding failed");
653        assert_eq!(encoded.len(), 5); // This encodes a 4-byte string "test" plus 1 byte for the length prefix.
654        let (decoded, _): (CuCompactString, usize) =
655            decode_from_slice(&encoded, config).expect("Decoding failed");
656        assert_eq!(cstr.0, decoded.0);
657    }
658}
659
660// Tests that require std feature
661#[cfg(all(test, feature = "std"))]
662mod std_tests {
663    use crate::{CuError, with_cause};
664
665    #[test]
666    fn test_cuerror_from_str() {
667        let err = CuError::from("test error");
668        assert_eq!(err.message(), "test error");
669        assert!(err.cause().is_none());
670    }
671
672    #[test]
673    fn test_cuerror_from_string() {
674        let err = CuError::from(String::from("test error"));
675        assert_eq!(err.message(), "test error");
676        assert!(err.cause().is_none());
677    }
678
679    #[test]
680    fn test_cuerror_new_index() {
681        let err = CuError::new(42);
682        assert_eq!(err.message(), "[interned:42]");
683        assert!(err.cause().is_none());
684    }
685
686    #[test]
687    fn test_cuerror_new_with_cause() {
688        let io_err = std::io::Error::other("io error");
689        let err = CuError::new_with_cause("wrapped error", io_err);
690        assert_eq!(err.message(), "wrapped error");
691        assert!(err.cause().is_some());
692        assert!(err.cause().unwrap().to_string().contains("io error"));
693    }
694
695    #[test]
696    fn test_cuerror_add_cause() {
697        let err = CuError::from("base error").add_cause("additional context");
698        assert_eq!(err.message(), "base error");
699        assert!(err.cause().is_some());
700        assert_eq!(err.cause().unwrap().to_string(), "additional context");
701    }
702
703    #[test]
704    fn test_cuerror_with_cause_method() {
705        let io_err = std::io::Error::other("io error");
706        let err = CuError::from("base error").with_cause(io_err);
707        assert_eq!(err.message(), "base error");
708        assert!(err.cause().is_some());
709    }
710
711    #[test]
712    fn test_cuerror_with_cause_free_function() {
713        let io_err = std::io::Error::other("io error");
714        let err = with_cause("wrapped", io_err);
715        assert_eq!(err.message(), "wrapped");
716        assert!(err.cause().is_some());
717    }
718
719    #[test]
720    fn test_cuerror_clone() {
721        let io_err = std::io::Error::other("io error");
722        let err = CuError::new_with_cause("test", io_err);
723        let cloned = err.clone();
724        assert_eq!(err.message(), cloned.message());
725        // Cause string representation should match
726        assert_eq!(
727            err.cause().map(|c| c.to_string()),
728            cloned.cause().map(|c| c.to_string())
729        );
730    }
731
732    #[test]
733    fn test_cuerror_serialize_deserialize_json() {
734        let io_err = std::io::Error::other("io error");
735        let err = CuError::new_with_cause("test", io_err);
736
737        let serialized = serde_json::to_string(&err).unwrap();
738        let deserialized: CuError = serde_json::from_str(&serialized).unwrap();
739
740        assert_eq!(err.message(), deserialized.message());
741        // Cause should be preserved as string
742        assert!(deserialized.cause().is_some());
743    }
744
745    #[test]
746    fn test_cuerror_serialize_deserialize_no_cause() {
747        let err = CuError::from("simple error");
748
749        let serialized = serde_json::to_string(&err).unwrap();
750        let deserialized: CuError = serde_json::from_str(&serialized).unwrap();
751
752        assert_eq!(err.message(), deserialized.message());
753        assert!(deserialized.cause().is_none());
754    }
755
756    #[test]
757    fn test_cuerror_display() {
758        let err = CuError::from("test error").add_cause("some context");
759        let display = format!("{}", err);
760        assert!(display.contains("test error"));
761        assert!(display.contains("some context"));
762    }
763
764    #[test]
765    fn test_cuerror_debug() {
766        let err = CuError::from("test error").add_cause("some context");
767        let debug = format!("{:?}", err);
768        assert!(debug.contains("test error"));
769        assert!(debug.contains("some context"));
770    }
771}