Skip to main content

qubit_validator/report/
validation_path.rs

1// =============================================================================
2//    Copyright (c) 2025 - 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8
9//! Safe validation paths.
10
11use std::fmt::Debug;
12use std::fmt::Display;
13use std::fmt::Formatter;
14use std::fmt::Result;
15
16use super::PathSegment;
17
18/// Reports whether a field label can be rendered as a dotted ASCII identifier.
19///
20/// Empty labels, non-ASCII bytes, and punctuation require quoted rendering.
21///
22/// # Parameters
23///
24/// - `field`: Label to check without allocating or changing it.
25#[inline]
26fn is_simple_field(field: &str) -> bool {
27    let mut bytes = field.bytes();
28    let Some(first) = bytes.next() else {
29        return false;
30    };
31    (first.is_ascii_alphabetic() || first == b'_') && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
32}
33
34/// Appends a JSON-escaped field label inside the path's bracket notation.
35///
36/// Control characters and JSON metacharacters are escaped; other Unicode
37/// characters are copied unchanged.
38///
39/// # Parameters
40///
41/// - `output`: String that receives the quoted field notation.
42/// - `field`: Field label to escape and append.
43fn append_quoted_field(output: &mut String, field: &str) {
44    const HEX: &[u8; 16] = b"0123456789abcdef";
45    output.push_str("[\"");
46    for character in field.chars() {
47        match character {
48            '"' => output.push_str("\\\""),
49            '\\' => output.push_str("\\\\"),
50            '\n' => output.push_str("\\n"),
51            '\r' => output.push_str("\\r"),
52            '\t' => output.push_str("\\t"),
53            '\u{0008}' => output.push_str("\\b"),
54            '\u{000c}' => output.push_str("\\f"),
55            control if (control as u32) < 0x20 => {
56                let byte = control as u8;
57                output.push_str("\\u00");
58                output.push(HEX[(byte >> 4) as usize] as char);
59                output.push(HEX[(byte & 0x0f) as usize] as char);
60            }
61            other => output.push(other),
62        }
63    }
64    output.push_str("\"]");
65}
66
67/// A structured path to a value being validated.
68///
69/// `Debug` and `Display` do not reveal field labels. Call [`Self::render`]
70/// explicitly only in a trusted presentation layer; even rendered map entries
71/// contain opaque positions rather than raw map keys.
72///
73/// Field labels are static program declarations. Runtime map keys must be
74/// represented by opaque map-entry segments.
75///
76/// # Examples
77///
78/// ```
79/// use qubit_validator::ValidationPath;
80///
81/// let path = ValidationPath::root().with_field("profile").with_index(2);
82/// assert_eq!(path.render(), "profile[2]");
83/// assert_eq!(path.as_segments().len(), 2);
84/// ```
85///
86/// Runtime field names cannot be retained in a path. Represent dynamic map
87/// entries with [`Self::with_map_entry`] instead.
88///
89/// ```compile_fail,E0597
90/// use qubit_validator::ValidationPath;
91/// let runtime_name = String::from("user supplied key");
92/// let _ = ValidationPath::root().with_field(&runtime_name);
93/// ```
94#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
95pub struct ValidationPath {
96    /// Segments stored from the root toward the validated value.
97    segments: Vec<PathSegment>,
98}
99
100impl ValidationPath {
101    /// Creates an empty root path.
102    ///
103    /// # Returns
104    ///
105    /// A path with no segments. Rendering this path produces an empty string.
106    #[must_use]
107    #[inline]
108    pub const fn root() -> Self {
109        Self { segments: Vec::new() }
110    }
111
112    /// Returns the path segments in order.
113    ///
114    /// # Returns
115    ///
116    /// A borrowed slice ordered from the root toward the target.
117    #[must_use]
118    #[inline]
119    pub fn as_segments(&self) -> &[PathSegment] {
120        &self.segments
121    }
122
123    /// Appends a statically declared field segment.
124    ///
125    /// Use only program-declared field labels. Runtime map keys belong in an
126    /// opaque map-entry segment so their text is not retained.
127    ///
128    /// # Parameters
129    ///
130    /// - `field`: Static label of the declared field.
131    ///
132    /// # Returns
133    ///
134    /// The path with `field` appended after its existing segments.
135    #[must_use]
136    pub fn with_field(mut self, field: &'static str) -> Self {
137        self.segments.push(PathSegment::Field(field));
138        self
139    }
140
141    /// Appends a zero-based sequence index segment.
142    ///
143    /// # Parameters
144    ///
145    /// - `index`: Position of the element in its sequence.
146    ///
147    /// # Returns
148    ///
149    /// The path with the index appended after its existing segments.
150    #[must_use]
151    pub fn with_index(mut self, index: usize) -> Self {
152        self.segments.push(PathSegment::Index(index));
153        self
154    }
155
156    /// Appends an opaque map entry position without retaining its key.
157    ///
158    /// # Parameters
159    ///
160    /// - `index`: Stable position assigned by the caller to the map entry.
161    ///
162    /// # Returns
163    ///
164    /// The path with the map-entry position appended.
165    #[must_use]
166    pub fn with_map_entry(mut self, index: usize) -> Self {
167        self.segments.push(PathSegment::MapEntry(index));
168        self
169    }
170
171    /// Appends the key side of a map entry without storing the key itself.
172    ///
173    /// # Returns
174    ///
175    /// The path with a map-key marker appended.
176    #[must_use]
177    pub fn with_map_key(mut self) -> Self {
178        self.segments.push(PathSegment::MapKey);
179        self
180    }
181
182    /// Appends the value side of a map entry.
183    ///
184    /// # Returns
185    ///
186    /// The path with a map-value marker appended.
187    #[must_use]
188    pub fn with_map_value(mut self) -> Self {
189        self.segments.push(PathSegment::MapValue);
190        self
191    }
192
193    /// Appends a relative path to this path without rendering either one.
194    ///
195    /// Returns a new path and leaves both inputs unchanged. Segments from
196    /// `relative` follow this path's segments in their original order.
197    ///
198    /// # Parameters
199    ///
200    /// - `relative`: Relative suffix to append to this path.
201    ///
202    /// # Returns
203    ///
204    /// A newly allocated path containing both segment sequences.
205    #[must_use]
206    pub fn concat(&self, relative: &Self) -> Self {
207        let mut segments = Vec::with_capacity(self.segments.len() + relative.segments.len());
208        segments.extend_from_slice(&self.segments);
209        segments.extend_from_slice(&relative.segments);
210        Self { segments }
211    }
212
213    /// Explicitly renders this path for a trusted presentation layer.
214    ///
215    /// The result can contain program-supplied field labels but never contains
216    /// a raw validation value or map key. Simple ASCII identifiers use dotted
217    /// notation; other field labels use JSON-escaped `[...]` notation. This
218    /// changes the rendered format for special field labels.
219    ///
220    /// # Returns
221    ///
222    /// A newly allocated string containing the rendered field and positional
223    /// segments, or an empty string for the root path.
224    #[must_use]
225    pub fn render(&self) -> String {
226        let mut rendered = String::new();
227        for segment in &self.segments {
228            match segment {
229                PathSegment::Field(field) => {
230                    if !rendered.is_empty() {
231                        rendered.push('.');
232                    }
233                    if is_simple_field(field) {
234                        rendered.push_str(field);
235                    } else {
236                        append_quoted_field(&mut rendered, field);
237                    }
238                }
239                PathSegment::Index(index) => {
240                    rendered.push('[');
241                    rendered.push_str(&index.to_string());
242                    rendered.push(']');
243                }
244                PathSegment::MapEntry(index) => {
245                    rendered.push_str(".<map-entry:");
246                    rendered.push_str(&index.to_string());
247                    rendered.push('>');
248                }
249                PathSegment::MapKey => rendered.push_str(".<map-key>"),
250                PathSegment::MapValue => rendered.push_str(".<map-value>"),
251            }
252        }
253        rendered
254    }
255}
256
257impl Debug for ValidationPath {
258    /// Formats only the segment count so field labels remain private.
259    ///
260    /// # Parameters
261    ///
262    /// - `self`: Path whose segment count is formatted.
263    /// - `formatter`: Formatter receiving the redacted debug representation.
264    fn fmt(&self, formatter: &mut Formatter<'_>) -> Result {
265        formatter
266            .debug_struct("ValidationPath")
267            .field("segment_count", &self.segments.len())
268            .finish_non_exhaustive()
269    }
270}
271
272impl Display for ValidationPath {
273    /// Formats a redacted placeholder instead of rendering path labels.
274    ///
275    /// # Parameters
276    ///
277    /// - `self`: Path represented by the fixed redacted placeholder.
278    /// - `formatter`: Formatter receiving the placeholder.
279    fn fmt(&self, formatter: &mut Formatter<'_>) -> Result {
280        formatter.write_str("<validation-path>")
281    }
282}