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}