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}