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