Skip to main content

hermod/protocol/
types.rs

1//! Core protocol types for trace-forward protocol
2//!
3//! These types match the Haskell definitions in cardano-node to ensure
4//! wire-protocol compatibility.
5
6use chrono::{DateTime, Utc};
7use pallas_codec::minicbor::{Decode, Encode};
8use serde::{Deserialize, Serialize};
9use std::fmt;
10
11/// Severity level for trace messages
12///
13/// Must match the Haskell `SeverityS` enum exactly for wire compatibility.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
15#[repr(u8)]
16pub enum Severity {
17    /// Debug messages
18    Debug = 0,
19    /// Information
20    Info = 1,
21    /// Normal runtime conditions
22    Notice = 2,
23    /// General warnings
24    Warning = 3,
25    /// General errors
26    Error = 4,
27    /// Severe situations
28    Critical = 5,
29    /// Take immediate action
30    Alert = 6,
31    /// System is unusable
32    Emergency = 7,
33}
34
35impl Encode<()> for Severity {
36    fn encode<W: pallas_codec::minicbor::encode::Write>(
37        &self,
38        e: &mut pallas_codec::minicbor::Encoder<W>,
39        _ctx: &mut (),
40    ) -> Result<(), pallas_codec::minicbor::encode::Error<W::Error>> {
41        // Haskell Generic Serialise for nullary constructors: array(1)[constructor_index]
42        e.array(1)?.u8(*self as u8)?;
43        Ok(())
44    }
45}
46
47impl<'b> Decode<'b, ()> for Severity {
48    fn decode(
49        d: &mut pallas_codec::minicbor::Decoder<'b>,
50        _ctx: &mut (),
51    ) -> Result<Self, pallas_codec::minicbor::decode::Error> {
52        d.array()?;
53        let val = d.u8()?;
54        match val {
55            0 => Ok(Severity::Debug),
56            1 => Ok(Severity::Info),
57            2 => Ok(Severity::Notice),
58            3 => Ok(Severity::Warning),
59            4 => Ok(Severity::Error),
60            5 => Ok(Severity::Critical),
61            6 => Ok(Severity::Alert),
62            7 => Ok(Severity::Emergency),
63            _ => Err(pallas_codec::minicbor::decode::Error::message(
64                "invalid severity value",
65            )),
66        }
67    }
68}
69
70impl fmt::Display for Severity {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            Severity::Debug => write!(f, "Debug"),
74            Severity::Info => write!(f, "Info"),
75            Severity::Notice => write!(f, "Notice"),
76            Severity::Warning => write!(f, "Warning"),
77            Severity::Error => write!(f, "Error"),
78            Severity::Critical => write!(f, "Critical"),
79            Severity::Alert => write!(f, "Alert"),
80            Severity::Emergency => write!(f, "Emergency"),
81        }
82    }
83}
84
85/// Detail level (formerly known as verbosity)
86///
87/// Must match the Haskell `DetailLevel` enum exactly.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
89#[repr(u8)]
90pub enum DetailLevel {
91    /// Minimal detail
92    DMinimal = 0,
93    /// Normal detail
94    DNormal = 1,
95    /// Detailed
96    DDetailed = 2,
97    /// Maximum detail
98    DMaximum = 3,
99}
100
101impl Encode<()> for DetailLevel {
102    fn encode<W: pallas_codec::minicbor::encode::Write>(
103        &self,
104        e: &mut pallas_codec::minicbor::Encoder<W>,
105        _ctx: &mut (),
106    ) -> Result<(), pallas_codec::minicbor::encode::Error<W::Error>> {
107        // Haskell Generic Serialise for nullary constructors: array(1)[constructor_index]
108        e.array(1)?.u8(*self as u8)?;
109        Ok(())
110    }
111}
112
113impl<'b> Decode<'b, ()> for DetailLevel {
114    fn decode(
115        d: &mut pallas_codec::minicbor::Decoder<'b>,
116        _ctx: &mut (),
117    ) -> Result<Self, pallas_codec::minicbor::decode::Error> {
118        d.array()?;
119        let val = d.u8()?;
120        match val {
121            0 => Ok(DetailLevel::DMinimal),
122            1 => Ok(DetailLevel::DNormal),
123            2 => Ok(DetailLevel::DDetailed),
124            3 => Ok(DetailLevel::DMaximum),
125            _ => Err(pallas_codec::minicbor::decode::Error::message(
126                "invalid detail level",
127            )),
128        }
129    }
130}
131
132/// A trace object sent over the wire
133///
134/// This must match the Haskell `TraceObject` structure exactly:
135/// ```haskell
136/// data TraceObject = TraceObject {
137///     toHuman     :: !(Maybe Text)
138///   , toMachine   :: !Text
139///   , toNamespace :: ![Text]
140///   , toSeverity  :: !SeverityS
141///   , toDetails   :: !DetailLevel
142///   , toTimestamp :: !UTCTime
143///   , toHostname  :: !Text
144///   , toThreadId  :: !Text
145/// }
146/// ```
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct TraceObject {
149    /// Human-readable representation (if available)
150    pub to_human: Option<String>,
151    /// Machine-readable representation (JSON)
152    pub to_machine: String,
153    /// Hierarchical namespace for the trace
154    pub to_namespace: Vec<String>,
155    /// Severity level
156    pub to_severity: Severity,
157    /// Detail level
158    pub to_details: DetailLevel,
159    /// Timestamp when the trace was created
160    pub to_timestamp: DateTime<Utc>,
161    /// Hostname of the machine generating the trace
162    pub to_hostname: String,
163    /// Thread ID that generated the trace
164    pub to_thread_id: String,
165}
166
167impl Encode<()> for TraceObject {
168    fn encode<W: pallas_codec::minicbor::encode::Write>(
169        &self,
170        e: &mut pallas_codec::minicbor::Encoder<W>,
171        ctx: &mut (),
172    ) -> Result<(), pallas_codec::minicbor::encode::Error<W::Error>> {
173        // Haskell Generic Serialise for a product type with N fields:
174        // array(N+1)[constructor_index, field1, ..., fieldN]
175        // TraceObject has 8 fields, constructor index 0.
176        e.array(9)?;
177        e.u8(0)?;
178
179        // toHuman :: Maybe Text
180        match &self.to_human {
181            Some(h) => {
182                e.array(1)?;
183                e.str(h)?;
184            }
185            None => {
186                e.array(0)?;
187            }
188        }
189
190        // toMachine :: Text
191        e.str(&self.to_machine)?;
192
193        // toNamespace :: [Text]
194        e.array(self.to_namespace.len() as u64)?;
195        for ns in &self.to_namespace {
196            e.str(ns)?;
197        }
198
199        // toSeverity :: SeverityS
200        self.to_severity.encode(e, ctx)?;
201
202        // toDetails :: DetailLevel
203        self.to_details.encode(e, ctx)?;
204
205        // toTimestamp :: UTCTime
206        // Haskell's Serialise UTCTime uses tag 1000 (extended time) + map(2):
207        //   key 1  -> i64 POSIX seconds
208        //   key -12 -> u64 picoseconds within the second
209        let secs = self.to_timestamp.timestamp();
210        let psecs = self.to_timestamp.timestamp_subsec_nanos() as u64 * 1_000;
211        e.tag(pallas_codec::minicbor::data::Tag::new(1000))?;
212        e.map(2)?;
213        e.u8(1)?;
214        e.i64(secs)?;
215        e.i64(-12)?;
216        e.u64(psecs)?;
217
218        // toHostname :: Text
219        e.str(&self.to_hostname)?;
220
221        // toThreadId :: Text
222        e.str(&self.to_thread_id)?;
223
224        Ok(())
225    }
226}
227
228impl<'b> Decode<'b, ()> for TraceObject {
229    fn decode(
230        d: &mut pallas_codec::minicbor::Decoder<'b>,
231        ctx: &mut (),
232    ) -> Result<Self, pallas_codec::minicbor::decode::Error> {
233        let len = d.array()?;
234        if len != Some(9) {
235            return Err(pallas_codec::minicbor::decode::Error::message(
236                "TraceObject must have 9 elements (constructor index + 8 fields)",
237            ));
238        }
239        // Skip constructor index
240        let _constructor_idx = d.u8()?;
241
242        // toHuman :: Maybe Text
243        let to_human = {
244            let opt_len = d.array()?;
245            match opt_len {
246                Some(0) => None,
247                Some(1) => Some(d.str()?.to_string()),
248                _ => {
249                    return Err(pallas_codec::minicbor::decode::Error::message(
250                        "invalid Maybe encoding",
251                    ));
252                }
253            }
254        };
255
256        // toMachine :: Text
257        let to_machine = d.str()?.to_string();
258
259        // toNamespace :: [Text]
260        // Haskell's Serialise [a] uses indefinite-length encoding for non-empty lists
261        let mut to_namespace = Vec::new();
262        for s in d.array_iter::<String>()? {
263            to_namespace.push(s?);
264        }
265
266        // toSeverity :: SeverityS
267        let to_severity = Severity::decode(d, ctx)?;
268
269        // toDetails :: DetailLevel
270        let to_details = DetailLevel::decode(d, ctx)?;
271
272        // toTimestamp :: UTCTime
273        // Haskell's Serialise UTCTime encodes as tag 1000 + map(2): {1: i64_secs, -12: u64_psecs}
274        // Also accepts tag 1 + float for compatibility.
275        let tag = d.tag()?;
276        let to_timestamp = if tag == pallas_codec::minicbor::data::Tag::new(1000) {
277            let map_len = d.map()?;
278            if map_len != Some(2) {
279                return Err(pallas_codec::minicbor::decode::Error::message(
280                    "expected map of length 2 for UTCTime (tag 1000)",
281                ));
282            }
283            let k0 = d.i64()?;
284            if k0 != 1 {
285                return Err(pallas_codec::minicbor::decode::Error::message(
286                    "expected key 1 (secs) in tag-1000 UTCTime",
287                ));
288            }
289            let secs = d.i64()?;
290            let k1 = d.i64()?;
291            if k1 != -12 {
292                return Err(pallas_codec::minicbor::decode::Error::message(
293                    "expected key -12 (psecs) in tag-1000 UTCTime",
294                ));
295            }
296            let psecs = d.u64()?;
297            let nanos = (psecs / 1_000) as u32;
298            DateTime::from_timestamp(secs, nanos).ok_or_else(|| {
299                pallas_codec::minicbor::decode::Error::message("invalid timestamp")
300            })?
301        } else if tag == pallas_codec::minicbor::data::Tag::new(1) {
302            // Compatibility: tag 1 with float64
303            let timestamp_f64 = d.f64()?;
304            let secs = timestamp_f64.floor() as i64;
305            let nanos = ((timestamp_f64 - secs as f64) * 1_000_000_000.0) as u32;
306            DateTime::from_timestamp(secs, nanos).ok_or_else(|| {
307                pallas_codec::minicbor::decode::Error::message("invalid timestamp")
308            })?
309        } else {
310            return Err(pallas_codec::minicbor::decode::Error::message(
311                "expected UTCTime tag (1000 or 1)",
312            ));
313        };
314
315        // toHostname :: Text
316        let to_hostname = d.str()?.to_string();
317
318        // toThreadId :: Text
319        let to_thread_id = d.str()?.to_string();
320
321        Ok(TraceObject {
322            to_human,
323            to_machine,
324            to_namespace,
325            to_severity,
326            to_details,
327            to_timestamp,
328            to_hostname,
329            to_thread_id,
330        })
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use pallas_codec::minicbor;
338
339    fn encode<T: minicbor::Encode<()>>(value: &T) -> Vec<u8> {
340        let mut buf = Vec::new();
341        minicbor::encode_with(value, &mut buf, &mut ()).unwrap();
342        buf
343    }
344
345    fn decode<T: for<'b> minicbor::Decode<'b, ()>>(buf: &[u8]) -> T {
346        minicbor::decode_with(buf, &mut ()).unwrap()
347    }
348
349    // --- Severity ---
350
351    #[test]
352    fn test_severity_encoding() {
353        // Existing smoke-test kept for reference
354        assert_eq!(decode::<Severity>(&encode(&Severity::Info)), Severity::Info);
355    }
356
357    #[test]
358    fn all_severity_variants_round_trip() {
359        for sev in [
360            Severity::Debug,
361            Severity::Info,
362            Severity::Notice,
363            Severity::Warning,
364            Severity::Error,
365            Severity::Critical,
366            Severity::Alert,
367            Severity::Emergency,
368        ] {
369            let decoded = decode::<Severity>(&encode(&sev));
370            assert_eq!(decoded, sev, "Severity::{:?} round-trip failed", sev);
371        }
372    }
373
374    #[test]
375    fn severity_encoded_as_array1_constructor_index() {
376        // Haskell Generic Serialise: array(1)[index]
377        // Debug=0 → 0x81 0x00
378        assert_eq!(encode(&Severity::Debug), &[0x81, 0x00]);
379        // Emergency=7 → 0x81 0x07
380        assert_eq!(encode(&Severity::Emergency), &[0x81, 0x07]);
381    }
382
383    // --- DetailLevel ---
384
385    #[test]
386    fn test_detail_level_encoding() {
387        assert_eq!(
388            decode::<DetailLevel>(&encode(&DetailLevel::DNormal)),
389            DetailLevel::DNormal
390        );
391    }
392
393    #[test]
394    fn all_detail_level_variants_round_trip() {
395        for dl in [
396            DetailLevel::DMinimal,
397            DetailLevel::DNormal,
398            DetailLevel::DDetailed,
399            DetailLevel::DMaximum,
400        ] {
401            let decoded = decode::<DetailLevel>(&encode(&dl));
402            assert_eq!(decoded, dl, "DetailLevel::{:?} round-trip failed", dl);
403        }
404    }
405
406    // --- TraceObject ---
407
408    fn make_trace_object(to_human: Option<&str>) -> TraceObject {
409        TraceObject {
410            to_human: to_human.map(str::to_string),
411            to_machine: r#"{"k":1}"#.to_string(),
412            to_namespace: vec!["Cardano".to_string(), "Node".to_string()],
413            to_severity: Severity::Warning,
414            to_details: DetailLevel::DDetailed,
415            to_timestamp: chrono::DateTime::from_timestamp(1_700_000_000, 500_000_000).unwrap(),
416            to_hostname: "node-1".to_string(),
417            to_thread_id: "99".to_string(),
418        }
419    }
420
421    #[test]
422    fn trace_object_with_human_round_trip() {
423        let original = make_trace_object(Some("human readable"));
424        let decoded = decode::<TraceObject>(&encode(&original));
425        assert_eq!(decoded.to_human, Some("human readable".to_string()));
426        assert_eq!(decoded.to_machine, original.to_machine);
427        assert_eq!(decoded.to_namespace, original.to_namespace);
428        assert_eq!(decoded.to_severity, original.to_severity);
429        assert_eq!(decoded.to_details, original.to_details);
430        assert_eq!(decoded.to_hostname, original.to_hostname);
431        assert_eq!(decoded.to_thread_id, original.to_thread_id);
432        // Timestamp preserved to nanosecond precision
433        assert_eq!(
434            decoded.to_timestamp.timestamp(),
435            original.to_timestamp.timestamp()
436        );
437        assert_eq!(
438            decoded.to_timestamp.timestamp_subsec_nanos(),
439            original.to_timestamp.timestamp_subsec_nanos()
440        );
441    }
442
443    #[test]
444    fn trace_object_without_human_round_trip() {
445        let original = make_trace_object(None);
446        let decoded = decode::<TraceObject>(&encode(&original));
447        assert_eq!(decoded.to_human, None);
448    }
449
450    #[test]
451    fn trace_object_empty_namespace_round_trip() {
452        let mut original = make_trace_object(None);
453        original.to_namespace = vec![];
454        let decoded = decode::<TraceObject>(&encode(&original));
455        assert!(decoded.to_namespace.is_empty());
456    }
457
458    #[test]
459    fn timestamp_tag1_compat_decode() {
460        // Build CBOR that uses tag(1) + float for the timestamp field (compatibility path)
461        // We construct a full TraceObject byte string with tag(1) instead of tag(1000)
462        // by encoding everything manually and substituting the timestamp.
463        let original = make_trace_object(None);
464        let normal_buf = encode(&original);
465
466        // Find the tag-1000 bytes and replace with tag-1 + float
467        // Simpler: build the expected bytes manually and verify the decoder accepts them.
468        // Tag 1 in CBOR = 0xC1, followed by float64
469        let secs = original.to_timestamp.timestamp() as f64;
470        let mut buf: Vec<u8> = Vec::new();
471        let mut enc = minicbor::Encoder::new(&mut buf);
472
473        // Encode array(9) header + constructor index + 6 fields, then tag(1)+float64+hostname+threadid
474        enc.array(9).unwrap().u8(0).unwrap();
475        // to_human = None → array(0)
476        enc.array(0).unwrap();
477        // to_machine
478        enc.str(r#"{"k":1}"#).unwrap();
479        // to_namespace = [] (simplified)
480        enc.array(0).unwrap();
481        // to_severity (Warning=3)
482        enc.array(1).unwrap().u8(3).unwrap();
483        // to_details (DDetailed=2)
484        enc.array(1).unwrap().u8(2).unwrap();
485        // to_timestamp via tag(1) + float64
486        enc.tag(minicbor::data::Tag::new(1))
487            .unwrap()
488            .f64(secs)
489            .unwrap();
490        // to_hostname
491        enc.str("node-1").unwrap();
492        // to_thread_id
493        enc.str("99").unwrap();
494
495        let decoded = decode::<TraceObject>(&buf);
496        assert_eq!(
497            decoded.to_timestamp.timestamp(),
498            original.to_timestamp.timestamp()
499        );
500        // Discard the `normal_buf` usage warning
501        let _ = normal_buf;
502    }
503}