Skip to main content

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}