qubit_validator/report/validation_report.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//! Aggregated validation results.
10
11use std::collections::HashSet;
12use std::fmt;
13
14use super::FailureId;
15use super::RecordedOutcome;
16use super::SkipReason;
17use super::SkippedValidation;
18use super::ValidationLimits;
19use super::ValidationOutcomeError;
20use super::ValidationPath;
21use super::Violation;
22use super::next_report_id;
23use crate::ValidationOutcome;
24
25/// A bounded collection of validation violations and skipped occurrences.
26///
27/// Reports retain structured violation metadata. Callers must ensure that
28/// violation parameters contain no rejected input or sensitive values derived
29/// from it.
30///
31/// # Examples
32///
33/// ```
34/// use qubit_validator::ValidationReport;
35/// use qubit_validator::ValidatorId;
36/// use qubit_validator::Violation;
37/// use qubit_validator::ViolationCode;
38/// use qubit_validator::ValidationOutcome;
39/// use qubit_validator::ValidationOutcomeError;
40/// use qubit_validator::ValidationPath;
41///
42/// let mut report = ValidationReport::new();
43/// let violation = Violation::new(
44/// ValidatorId::new("example.non_empty"),
45/// ViolationCode::new("text.empty"),
46/// );
47/// let recorded = report.record_outcome(
48/// 0,
49/// ValidationPath::root(),
50/// ValidationOutcome::invalid(vec![violation])?,
51/// )?;
52/// assert!(recorded.complete());
53/// assert!(!report.is_valid());
54/// assert_eq!(report.violations().len(), 1);
55/// # Ok::<(), ValidationOutcomeError>(())
56/// ```
57#[must_use]
58pub struct ValidationReport {
59 /// Accepted violations in occurrence order.
60 violations: Vec<Violation>,
61 /// Occurrence for each accepted violation, indexed like `violations`.
62 failure_occurrences: Vec<usize>,
63 /// Accepted skipped occurrences in occurrence order.
64 skipped: Vec<SkippedValidation>,
65 /// Unique identity used to reject failure references from other reports.
66 report_id: u64,
67 /// Whether configured limits prevented exhaustive collection.
68 truncated: bool,
69 /// Greatest occurrence successfully submitted to this report.
70 last_recorded_occurrence: Option<usize>,
71 /// Collection limits for all failures and skipped occurrences.
72 limits: ValidationLimits,
73}
74
75impl ValidationReport {
76 /// Creates an empty report with explicit collection limits.
77 ///
78 /// Limits apply only to retained violation and skipped-entry counts, not
79 /// to validation work or total memory use. See [`crate::ValidationLimits`]
80 /// for the resource boundaries callers must enforce separately.
81 ///
82 /// # Parameters
83 ///
84 /// - `limits`: Independent limits for retained violations and skipped
85 /// entries. `None` leaves the corresponding count unbounded.
86 ///
87 /// # Returns
88 ///
89 /// An empty, untruncated report configured with `limits`.
90 #[inline]
91 pub fn with_limits(limits: ValidationLimits) -> Self {
92 Self {
93 violations: Vec::new(),
94 failure_occurrences: Vec::new(),
95 skipped: Vec::new(),
96 report_id: next_report_id(),
97 truncated: false,
98 last_recorded_occurrence: None,
99 limits,
100 }
101 }
102
103 /// Creates an empty report without collection limits.
104 ///
105 /// Both retained violation and skipped-entry counts are unbounded. Use
106 /// [`Self::with_limits`] when either count needs an explicit limit.
107 ///
108 /// # Returns
109 ///
110 /// An empty, untruncated report with both collection limits set to `None`.
111 #[inline]
112 pub fn new() -> Self {
113 Self::with_limits(ValidationLimits {
114 max_violations: None,
115 max_skipped: None,
116 })
117 }
118
119 /// Marks that a caller stopped validation before it was exhaustive.
120 ///
121 /// Use this when the execution policy stopped before the report's own
122 /// collection limits rejected an outcome.
123 #[inline]
124 pub fn mark_truncated(&mut self) {
125 self.truncated = true;
126 }
127
128 /// Adds the result of one validation occurrence while preserving its
129 /// invariants.
130 ///
131 /// Occurrences must be submitted in non-decreasing order; multiple outcomes
132 /// may use the same occurrence. Violations and skipped entries are stored
133 /// in submission order and obey their respective report limits. The
134 /// violation limit counts each retained invalid violation once; skipped
135 /// outcomes refer to those failures by ID and consume no additional
136 /// violation capacity. For an invalid outcome, `path` prefixes each
137 /// relative violation path once.
138 ///
139 /// # Parameters
140 ///
141 /// - `occurrence`: Non-decreasing position of this validation occurrence.
142 /// - `path`: Path to the value for which the outcome was produced.
143 /// - `outcome`: Validation result and any associated failure metadata.
144 ///
145 /// # Errors
146 ///
147 /// Returns an outcome shape error for malformed outcomes, duplicate
148 /// prerequisite IDs, IDs not retained by this report, or an occurrence
149 /// lower than the last successfully submitted occurrence. Such an error
150 /// leaves this report unchanged.
151 ///
152 /// For a valid outcome, the path is ignored and not retained. Callers may
153 /// pass the root path to avoid cloning a field path. The occurrence still
154 /// participates in the ordering check.
155 ///
156 /// # Returns
157 ///
158 /// Returns whether the whole outcome fits the configured limits and the
159 /// IDs of failures retained from that outcome. A failed-prerequisite skip
160 /// is retained only when every reference is valid and it fits the skip
161 /// limit.
162 pub fn record_outcome(
163 &mut self,
164 occurrence: usize,
165 path: ValidationPath,
166 outcome: ValidationOutcome,
167 ) -> Result<RecordedOutcome, ValidationOutcomeError> {
168 if self.last_recorded_occurrence.is_some_and(|last| occurrence < last) {
169 return Err(ValidationOutcomeError::OutOfOrderOccurrence);
170 }
171 let result = match outcome {
172 ValidationOutcome::Valid => Ok(RecordedOutcome::new(true, Vec::new())),
173 ValidationOutcome::Invalid(violations) => {
174 if violations.is_empty() {
175 return Err(ValidationOutcomeError::EmptyViolations);
176 }
177 let retained = violations.len().min(self.remaining_failure_capacity());
178 let complete = retained == violations.len();
179 let mut failure_ids = Vec::with_capacity(retained);
180 for violation in violations.into_iter().take(retained) {
181 let full_path = path.concat(violation.path());
182 failure_ids.push(FailureId::new(self.report_id, self.violations.len()));
183 self.violations.push(violation.with_path(full_path));
184 self.failure_occurrences.push(occurrence);
185 }
186 if !complete {
187 self.mark_truncated();
188 }
189 Ok(RecordedOutcome::new(complete, failure_ids))
190 }
191 ValidationOutcome::Skipped {
192 reason: SkipReason::MissingOptional,
193 prerequisites,
194 } => {
195 if !prerequisites.is_empty() {
196 return Err(ValidationOutcomeError::UnexpectedPrerequisites);
197 }
198 if !self.has_skipped_capacity() {
199 self.mark_truncated();
200 Ok(RecordedOutcome::new(false, Vec::new()))
201 } else {
202 self.skipped.push(SkippedValidation::missing_optional(occurrence, path));
203 Ok(RecordedOutcome::new(true, Vec::new()))
204 }
205 }
206 ValidationOutcome::Skipped {
207 reason: SkipReason::FailedPrerequisite,
208 prerequisites,
209 } => {
210 if prerequisites.is_empty() {
211 return Err(ValidationOutcomeError::EmptyPrerequisites);
212 }
213 let mut unique = HashSet::with_capacity(prerequisites.len());
214 for prerequisite in &prerequisites {
215 if !unique.insert(*prerequisite) {
216 return Err(ValidationOutcomeError::DuplicatePrerequisiteFailure);
217 }
218 if prerequisite.report != self.report_id || self.violations.get(prerequisite.index).is_none() {
219 return Err(ValidationOutcomeError::UnknownPrerequisiteFailure);
220 }
221 }
222 if !self.has_skipped_capacity() {
223 self.mark_truncated();
224 Ok(RecordedOutcome::new(false, Vec::new()))
225 } else {
226 let skipped = SkippedValidation::failed_prerequisite(occurrence, path, prerequisites)?;
227 self.skipped.push(skipped);
228 Ok(RecordedOutcome::new(true, Vec::new()))
229 }
230 }
231 };
232 if result.is_ok() {
233 self.last_recorded_occurrence = Some(occurrence);
234 }
235 result
236 }
237
238 /// Returns whether the recorded report contains no failures and is not
239 /// marked incomplete. This evaluates recorded content only; it cannot
240 /// establish that the caller ran every intended check.
241 ///
242 /// # Returns
243 ///
244 /// Returns `true` when there are no recorded violations, no occurrence was
245 /// skipped because of a failed prerequisite, and the report was not
246 /// truncated. A newly created empty report therefore returns `true`.
247 ///
248 /// Use this result as a basis for acceptance only after the caller has
249 /// confirmed that the entire intended validation run completed
250 /// successfully. A completed zero-rule plan may have an empty, valid
251 /// report, but a newly created report alone does not prove completion.
252 /// This method cannot know whether rules remain unsubmitted. If the caller
253 /// stops validation early while checks may remain, it must call
254 /// [`Self::mark_truncated`] so the report is marked incomplete. Capacity
255 /// limits mark the report as truncated automatically only when an outcome
256 /// cannot be retained in full. Merely filling the available capacity does
257 /// not mark the report as truncated.
258 #[must_use]
259 pub fn is_valid(&self) -> bool {
260 self.violations.is_empty()
261 && !self.truncated
262 && self
263 .skipped
264 .iter()
265 .all(|item| item.reason() != SkipReason::FailedPrerequisite)
266 }
267
268 /// Returns all violations in occurrence order.
269 #[must_use = "the collected violations should be inspected"]
270 #[inline]
271 pub fn violations(&self) -> &[Violation] {
272 &self.violations
273 }
274
275 /// Iterates over retained original violations in occurrence order.
276 ///
277 /// # Returns
278 ///
279 /// Every retained original failure in the same count reported by
280 /// [`Self::failure_count`].
281 #[must_use = "inspect all retained failure evidence"]
282 #[inline]
283 pub fn failures(&self) -> impl Iterator<Item = &Violation> + '_ {
284 self.violations.iter()
285 }
286
287 /// Returns the number of retained original violations.
288 #[must_use]
289 #[inline]
290 pub const fn failure_count(&self) -> usize {
291 self.violations.len()
292 }
293
294 /// Returns the violation associated with a failure ID from this report.
295 ///
296 /// # Parameters
297 ///
298 /// - `id`: Failure identifier whose retained violation is requested.
299 ///
300 /// # Returns
301 ///
302 /// Returns `Some` when `id` was issued by this report and remains
303 /// retained; otherwise returns `None`.
304 #[must_use]
305 #[inline]
306 pub fn failure(&self, id: FailureId) -> Option<&Violation> {
307 if id.report == self.report_id {
308 self.violations.get(id.index)
309 } else {
310 None
311 }
312 }
313
314 /// Returns the occurrence associated with a retained failure ID.
315 ///
316 /// Only failures retained in this report have an occurrence. IDs from a
317 /// different report and failures rejected by the violation capacity return
318 /// `None`.
319 ///
320 /// # Parameters
321 ///
322 /// - `id`: Failure identifier whose retained occurrence is requested.
323 ///
324 /// # Examples
325 ///
326 /// ```
327 /// use qubit_validator::{ValidationOutcome, ValidationPath, ValidationReport};
328 /// use qubit_validator::{ValidatorId, Violation, ViolationCode};
329 ///
330 /// let mut report = ValidationReport::new();
331 /// let violation = Violation::new(
332 /// ValidatorId::new("example.required"),
333 /// ViolationCode::new("value.missing"),
334 /// );
335 /// let receipt = report.record_outcome(
336 /// 2,
337 /// ValidationPath::root(),
338 /// ValidationOutcome::invalid(vec![violation])?,
339 /// )?;
340 /// let failure_id = receipt.failure_ids()[0];
341 /// assert_eq!(report.failure_occurrence(failure_id), Some(2));
342 /// # Ok::<(), qubit_validator::ValidationOutcomeError>(())
343 /// ```
344 #[must_use]
345 #[inline]
346 pub fn failure_occurrence(&self, id: FailureId) -> Option<usize> {
347 self.failure(id)?;
348 self.failure_occurrences.get(id.index).copied()
349 }
350
351 /// Iterates over retained failures and their occurrences in report order.
352 ///
353 /// The iterator contains only failures retained within the configured
354 /// violation capacity. Multiple failures from one outcome share an
355 /// occurrence, and separate outcomes may use the same occurrence.
356 #[must_use = "inspect all retained failures and their occurrences"]
357 #[inline]
358 pub fn failures_with_occurrences(&self) -> impl Iterator<Item = (usize, &Violation)> + '_ {
359 self.failure_occurrences.iter().copied().zip(self.violations.iter())
360 }
361
362 /// Returns all skipped occurrences in occurrence order.
363 #[must_use = "the skipped occurrences should be inspected"]
364 #[inline]
365 pub fn skipped(&self) -> &[SkippedValidation] {
366 &self.skipped
367 }
368
369 /// Returns whether this report is incomplete.
370 ///
371 /// # Returns
372 ///
373 /// Returns `true` when a collection limit rejected part of an outcome or
374 /// the caller marked execution incomplete with [`Self::mark_truncated`].
375 #[must_use]
376 #[inline]
377 pub const fn is_truncated(&self) -> bool {
378 self.truncated
379 }
380
381 /// Returns the configured collection limits.
382 #[must_use]
383 #[inline]
384 pub const fn limits(&self) -> ValidationLimits {
385 self.limits
386 }
387
388 /// Returns the remaining capacity for retained original violations, or the
389 /// largest possible count when unbounded. Prerequisite references do not
390 /// consume this capacity.
391 fn remaining_failure_capacity(&self) -> usize {
392 self.limits
393 .max_violations
394 .map_or(usize::MAX, |limit| limit.saturating_sub(self.violations.len()))
395 }
396
397 /// Returns whether one more skipped occurrence fits the configured limit.
398 fn has_skipped_capacity(&self) -> bool {
399 self.limits.max_skipped.is_none_or(|limit| self.skipped.len() < limit)
400 }
401}
402
403impl Default for ValidationReport {
404 /// Creates an unbounded empty report.
405 #[inline]
406 fn default() -> Self {
407 Self::new()
408 }
409}
410
411impl fmt::Debug for ValidationReport {
412 /// Formats counts and truncation state without exposing report contents.
413 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
414 formatter
415 .debug_struct("ValidationReport")
416 .field("violation_count", &self.failure_count())
417 .field("skipped_count", &self.skipped.len())
418 .field("truncated", &self.truncated)
419 .finish()
420 }
421}
422
423impl fmt::Display for ValidationReport {
424 /// Formats counts and truncation state without exposing report contents.
425 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
426 write!(
427 formatter,
428 "validation report: {} violation(s), {} skipped, truncated={}",
429 self.failure_count(),
430 self.skipped.len(),
431 self.truncated,
432 )
433 }
434}