Skip to main content

corpus_ipc/validation/
error.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3use std::fmt;
4
5/// Stable classification of a protocol validation failure.
6///
7/// These identifiers are part of the crate's error contract: serde wrappers
8/// and [`fmt::Display`] use [`ValidationKind::as_str`], so assertions should
9/// match the kind rather than a full prose sentence.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum ValidationKind {
12    /// An `f32` was NaN or ±infinity.
13    NonFinite,
14    /// A numeric value was outside its documented inclusive range.
15    OutOfRange,
16    /// Two related sequences (for example `values` and `valid_mask`) differed in length.
17    LengthMismatch,
18    /// A collection length or string byte length exceeded [`ProtocolLimits`](crate::ProtocolLimits).
19    LimitExceeded,
20    /// Nested metadata violated policy (empty key, or a nested object rejected
21    /// by the typed map).
22    NestedMetadata,
23    /// A repeated identifier in a list that must be unique (`channel_id`, `layer_id`).
24    DuplicateIdentifier,
25}
26
27impl ValidationKind {
28    /// Stable machine-readable kind name.
29    #[must_use]
30    pub const fn as_str(self) -> &'static str {
31        match self {
32            Self::NonFinite => "non_finite",
33            Self::OutOfRange => "out_of_range",
34            Self::LengthMismatch => "length_mismatch",
35            Self::LimitExceeded => "limit_exceeded",
36            Self::NestedMetadata => "nested_metadata",
37            Self::DuplicateIdentifier => "duplicate_identifier",
38        }
39    }
40}
41
42impl fmt::Display for ValidationKind {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.write_str(self.as_str())
45    }
46}
47
48/// Observed count, byte length, float, or documented range attached to a
49/// [`ValidationError`].
50#[derive(Debug, Clone, Copy)]
51pub enum ValidationMeasure {
52    /// Collection length or identifier code.
53    Count(u64),
54    /// UTF-8 byte length of a string.
55    Bytes(u64),
56    /// Observed `f32` (compared by bits; may be NaN or ±Inf).
57    Float(f32),
58    /// Inclusive documented range that a float must occupy.
59    Range { min: f32, max: f32 },
60}
61
62impl PartialEq for ValidationMeasure {
63    fn eq(&self, other: &Self) -> bool {
64        match (self, other) {
65            (Self::Count(a), Self::Count(b)) | (Self::Bytes(a), Self::Bytes(b)) => a == b,
66            (Self::Float(a), Self::Float(b)) => a.to_bits() == b.to_bits(),
67            (
68                Self::Range {
69                    min: a_min,
70                    max: a_max,
71                },
72                Self::Range {
73                    min: b_min,
74                    max: b_max,
75                },
76            ) => a_min.to_bits() == b_min.to_bits() && a_max.to_bits() == b_max.to_bits(),
77            _ => false,
78        }
79    }
80}
81
82impl Eq for ValidationMeasure {}
83
84impl fmt::Display for ValidationMeasure {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        match self {
87            Self::Count(n) | Self::Bytes(n) => write!(f, "{n}"),
88            Self::Float(v) => write!(f, "{v}"),
89            Self::Range { min, max } => write!(f, "[{min}, {max}]"),
90        }
91    }
92}
93
94/// Typed validation failure for a wire payload.
95///
96/// `path` is a JSON-like field path (`tempo`, `spikes`, `traces[1].channel_id`).
97/// `actual` / `limit` identify the observed value or size and the applicable
98/// bound when those are meaningful for [`kind`](Self::kind).
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct ValidationError {
101    /// Field or indexed path that failed.
102    pub path: String,
103    /// Stable error kind.
104    pub kind: ValidationKind,
105    /// Observed value, count, or byte length when applicable.
106    pub actual: Option<ValidationMeasure>,
107    /// Applicable limit or documented range.
108    pub limit: Option<ValidationMeasure>,
109}
110
111impl ValidationError {
112    /// Non-finite float at `path`.
113    #[must_use]
114    pub fn non_finite(path: impl Into<String>, actual: f32) -> Self {
115        Self {
116            path: path.into(),
117            kind: ValidationKind::NonFinite,
118            actual: Some(ValidationMeasure::Float(actual)),
119            limit: None,
120        }
121    }
122
123    /// Float outside `[min, max]` at `path`.
124    #[must_use]
125    pub fn out_of_range(path: impl Into<String>, actual: f32, min: f32, max: f32) -> Self {
126        Self {
127            path: path.into(),
128            kind: ValidationKind::OutOfRange,
129            actual: Some(ValidationMeasure::Float(actual)),
130            limit: Some(ValidationMeasure::Range { min, max }),
131        }
132    }
133
134    /// Related sequences had different lengths.
135    #[must_use]
136    pub fn length_mismatch(path: impl Into<String>, actual: usize, expected: usize) -> Self {
137        Self {
138            path: path.into(),
139            kind: ValidationKind::LengthMismatch,
140            actual: Some(ValidationMeasure::Count(usize_as_u64(actual))),
141            limit: Some(ValidationMeasure::Count(usize_as_u64(expected))),
142        }
143    }
144
145    /// Collection length exceeded `limit`.
146    #[must_use]
147    pub fn limit_exceeded(path: impl Into<String>, actual: usize, limit: usize) -> Self {
148        Self {
149            path: path.into(),
150            kind: ValidationKind::LimitExceeded,
151            actual: Some(ValidationMeasure::Count(usize_as_u64(actual))),
152            limit: Some(ValidationMeasure::Count(usize_as_u64(limit))),
153        }
154    }
155
156    /// String byte length exceeded `limit`.
157    #[must_use]
158    pub fn byte_limit(path: impl Into<String>, actual: usize, limit: usize) -> Self {
159        Self {
160            path: path.into(),
161            kind: ValidationKind::LimitExceeded,
162            actual: Some(ValidationMeasure::Bytes(usize_as_u64(actual))),
163            limit: Some(ValidationMeasure::Bytes(usize_as_u64(limit))),
164        }
165    }
166
167    /// Nested metadata policy violation (for example an empty key).
168    #[must_use]
169    pub fn nested_metadata(path: impl Into<String>) -> Self {
170        Self {
171            path: path.into(),
172            kind: ValidationKind::NestedMetadata,
173            actual: None,
174            limit: None,
175        }
176    }
177
178    /// Duplicate identifier at `path`.
179    #[must_use]
180    pub fn duplicate_identifier(path: impl Into<String>, identifier: impl fmt::Display) -> Self {
181        Self {
182            path: path.into(),
183            kind: ValidationKind::DuplicateIdentifier,
184            actual: Some(ValidationMeasure::Count(0)),
185            limit: None,
186        }
187        .with_duplicate_display(identifier)
188    }
189
190    fn with_duplicate_display(mut self, identifier: impl fmt::Display) -> Self {
191        // Keep `actual` as a count when the identifier is numeric; otherwise the
192        // path carries the identity and `actual` stays unused.
193        let label = identifier.to_string();
194        if let Ok(n) = label.parse::<u64>() {
195            self.actual = Some(ValidationMeasure::Count(n));
196        } else {
197            self.actual = None;
198            if !self.path.contains(&label) {
199                self.path = format!("{}:{label}", self.path);
200            }
201        }
202        self
203    }
204}
205
206impl fmt::Display for ValidationError {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        write!(f, "{} at `{}`", self.kind, self.path)?;
209        match (&self.actual, &self.limit) {
210            (Some(actual), Some(limit)) => write!(f, ", actual={actual}, limit={limit}"),
211            (Some(actual), None) => write!(f, ", actual={actual}"),
212            (None, Some(limit)) => write!(f, ", limit={limit}"),
213            (None, None) => Ok(()),
214        }
215    }
216}
217
218impl std::error::Error for ValidationError {}
219pub(crate) fn usize_as_u64(n: usize) -> u64 {
220    u64::try_from(n).unwrap_or(u64::MAX)
221}