qubit-validator 0.1.0

Type-safe validation traits and registries for Qubit applications
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
// =============================================================================
//    Copyright (c) 2025 - 2026 Haixing Hu.
//
//    SPDX-License-Identifier: Apache-2.0
//
//    Licensed under the Apache License, Version 2.0.
// =============================================================================

//! Aggregated validation results.

use std::collections::HashSet;
use std::fmt;

use super::FailureId;
use super::RecordedOutcome;
use super::SkipReason;
use super::SkippedValidation;
use super::ValidationLimits;
use super::ValidationOutcomeError;
use super::ValidationPath;
use super::Violation;
use super::next_report_id;
use crate::ValidationOutcome;

/// A bounded collection of validation violations and skipped occurrences.
///
/// Reports retain structured violation metadata. Callers must ensure that
/// violation parameters contain no rejected input or sensitive values derived
/// from it.
///
/// # Examples
///
/// ```
/// use qubit_validator::ValidationReport;
/// use qubit_validator::ValidatorId;
/// use qubit_validator::Violation;
/// use qubit_validator::ViolationCode;
/// use qubit_validator::ValidationOutcome;
/// use qubit_validator::ValidationOutcomeError;
/// use qubit_validator::ValidationPath;
///
/// let mut report = ValidationReport::new();
/// let violation = Violation::new(
///     ValidatorId::new("example.non_empty"),
///     ViolationCode::new("text.empty"),
/// );
/// let recorded = report.record_outcome(
///     0,
///     ValidationPath::root(),
///     ValidationOutcome::invalid(vec![violation])?,
/// )?;
/// assert!(recorded.complete());
/// assert!(!report.is_valid());
/// assert_eq!(report.violations().len(), 1);
/// # Ok::<(), ValidationOutcomeError>(())
/// ```
#[must_use]
pub struct ValidationReport {
    /// Accepted violations in occurrence order.
    violations: Vec<Violation>,
    /// Occurrence for each accepted violation, indexed like `violations`.
    failure_occurrences: Vec<usize>,
    /// Accepted skipped occurrences in occurrence order.
    skipped: Vec<SkippedValidation>,
    /// Unique identity used to reject failure references from other reports.
    report_id: u64,
    /// Whether configured limits prevented exhaustive collection.
    truncated: bool,
    /// Greatest occurrence successfully submitted to this report.
    last_recorded_occurrence: Option<usize>,
    /// Collection limits for all failures and skipped occurrences.
    limits: ValidationLimits,
}

impl ValidationReport {
    /// Creates an empty report with explicit collection limits.
    ///
    /// Limits apply only to retained violation and skipped-entry counts, not
    /// to validation work or total memory use. See [`crate::ValidationLimits`]
    /// for the resource boundaries callers must enforce separately.
    ///
    /// # Parameters
    ///
    /// - `limits`: Independent limits for retained violations and skipped
    ///   entries. `None` leaves the corresponding count unbounded.
    ///
    /// # Returns
    ///
    /// An empty, untruncated report configured with `limits`.
    #[inline]
    pub fn with_limits(limits: ValidationLimits) -> Self {
        Self {
            violations: Vec::new(),
            failure_occurrences: Vec::new(),
            skipped: Vec::new(),
            report_id: next_report_id(),
            truncated: false,
            last_recorded_occurrence: None,
            limits,
        }
    }

    /// Creates an empty report without collection limits.
    ///
    /// Both retained violation and skipped-entry counts are unbounded. Use
    /// [`Self::with_limits`] when either count needs an explicit limit.
    ///
    /// # Returns
    ///
    /// An empty, untruncated report with both collection limits set to `None`.
    #[inline]
    pub fn new() -> Self {
        Self::with_limits(ValidationLimits {
            max_violations: None,
            max_skipped: None,
        })
    }

    /// Marks that a caller stopped validation before it was exhaustive.
    ///
    /// Use this when the execution policy stopped before the report's own
    /// collection limits rejected an outcome.
    #[inline]
    pub fn mark_truncated(&mut self) {
        self.truncated = true;
    }

    /// Adds the result of one validation occurrence while preserving its
    /// invariants.
    ///
    /// Occurrences must be submitted in non-decreasing order; multiple outcomes
    /// may use the same occurrence. Violations and skipped entries are stored
    /// in submission order and obey their respective report limits. The
    /// violation limit counts each retained invalid violation once; skipped
    /// outcomes refer to those failures by ID and consume no additional
    /// violation capacity. For an invalid outcome, `path` prefixes each
    /// relative violation path once.
    ///
    /// # Parameters
    ///
    /// - `occurrence`: Non-decreasing position of this validation occurrence.
    /// - `path`: Path to the value for which the outcome was produced.
    /// - `outcome`: Validation result and any associated failure metadata.
    ///
    /// # Errors
    ///
    /// Returns an outcome shape error for malformed outcomes, duplicate
    /// prerequisite IDs, IDs not retained by this report, or an occurrence
    /// lower than the last successfully submitted occurrence. Such an error
    /// leaves this report unchanged.
    ///
    /// For a valid outcome, the path is ignored and not retained. Callers may
    /// pass the root path to avoid cloning a field path. The occurrence still
    /// participates in the ordering check.
    ///
    /// # Returns
    ///
    /// Returns whether the whole outcome fits the configured limits and the
    /// IDs of failures retained from that outcome. A failed-prerequisite skip
    /// is retained only when every reference is valid and it fits the skip
    /// limit.
    pub fn record_outcome(
        &mut self,
        occurrence: usize,
        path: ValidationPath,
        outcome: ValidationOutcome,
    ) -> Result<RecordedOutcome, ValidationOutcomeError> {
        if self.last_recorded_occurrence.is_some_and(|last| occurrence < last) {
            return Err(ValidationOutcomeError::OutOfOrderOccurrence);
        }
        let result = match outcome {
            ValidationOutcome::Valid => Ok(RecordedOutcome::new(true, Vec::new())),
            ValidationOutcome::Invalid(violations) => {
                if violations.is_empty() {
                    return Err(ValidationOutcomeError::EmptyViolations);
                }
                let retained = violations.len().min(self.remaining_failure_capacity());
                let complete = retained == violations.len();
                let mut failure_ids = Vec::with_capacity(retained);
                for violation in violations.into_iter().take(retained) {
                    let full_path = path.concat(violation.path());
                    failure_ids.push(FailureId::new(self.report_id, self.violations.len()));
                    self.violations.push(violation.with_path(full_path));
                    self.failure_occurrences.push(occurrence);
                }
                if !complete {
                    self.mark_truncated();
                }
                Ok(RecordedOutcome::new(complete, failure_ids))
            }
            ValidationOutcome::Skipped {
                reason: SkipReason::MissingOptional,
                prerequisites,
            } => {
                if !prerequisites.is_empty() {
                    return Err(ValidationOutcomeError::UnexpectedPrerequisites);
                }
                if !self.has_skipped_capacity() {
                    self.mark_truncated();
                    Ok(RecordedOutcome::new(false, Vec::new()))
                } else {
                    self.skipped.push(SkippedValidation::missing_optional(occurrence, path));
                    Ok(RecordedOutcome::new(true, Vec::new()))
                }
            }
            ValidationOutcome::Skipped {
                reason: SkipReason::FailedPrerequisite,
                prerequisites,
            } => {
                if prerequisites.is_empty() {
                    return Err(ValidationOutcomeError::EmptyPrerequisites);
                }
                let mut unique = HashSet::with_capacity(prerequisites.len());
                for prerequisite in &prerequisites {
                    if !unique.insert(*prerequisite) {
                        return Err(ValidationOutcomeError::DuplicatePrerequisiteFailure);
                    }
                    if prerequisite.report != self.report_id || self.violations.get(prerequisite.index).is_none() {
                        return Err(ValidationOutcomeError::UnknownPrerequisiteFailure);
                    }
                }
                if !self.has_skipped_capacity() {
                    self.mark_truncated();
                    Ok(RecordedOutcome::new(false, Vec::new()))
                } else {
                    let skipped = SkippedValidation::failed_prerequisite(occurrence, path, prerequisites)?;
                    self.skipped.push(skipped);
                    Ok(RecordedOutcome::new(true, Vec::new()))
                }
            }
        };
        if result.is_ok() {
            self.last_recorded_occurrence = Some(occurrence);
        }
        result
    }

    /// Returns whether the recorded report contains no failures and is not
    /// marked incomplete. This evaluates recorded content only; it cannot
    /// establish that the caller ran every intended check.
    ///
    /// # Returns
    ///
    /// Returns `true` when there are no recorded violations, no occurrence was
    /// skipped because of a failed prerequisite, and the report was not
    /// truncated. A newly created empty report therefore returns `true`.
    ///
    /// Use this result as a basis for acceptance only after the caller has
    /// confirmed that the entire intended validation run completed
    /// successfully. A completed zero-rule plan may have an empty, valid
    /// report, but a newly created report alone does not prove completion.
    /// This method cannot know whether rules remain unsubmitted. If the caller
    /// stops validation early while checks may remain, it must call
    /// [`Self::mark_truncated`] so the report is marked incomplete. Capacity
    /// limits mark the report as truncated automatically only when an outcome
    /// cannot be retained in full. Merely filling the available capacity does
    /// not mark the report as truncated.
    #[must_use]
    pub fn is_valid(&self) -> bool {
        self.violations.is_empty()
            && !self.truncated
            && self
                .skipped
                .iter()
                .all(|item| item.reason() != SkipReason::FailedPrerequisite)
    }

    /// Returns all violations in occurrence order.
    #[must_use = "the collected violations should be inspected"]
    #[inline]
    pub fn violations(&self) -> &[Violation] {
        &self.violations
    }

    /// Iterates over retained original violations in occurrence order.
    ///
    /// # Returns
    ///
    /// Every retained original failure in the same count reported by
    /// [`Self::failure_count`].
    #[must_use = "inspect all retained failure evidence"]
    #[inline]
    pub fn failures(&self) -> impl Iterator<Item = &Violation> + '_ {
        self.violations.iter()
    }

    /// Returns the number of retained original violations.
    #[must_use]
    #[inline]
    pub const fn failure_count(&self) -> usize {
        self.violations.len()
    }

    /// Returns the violation associated with a failure ID from this report.
    ///
    /// # Parameters
    ///
    /// - `id`: Failure identifier whose retained violation is requested.
    ///
    /// # Returns
    ///
    /// Returns `Some` when `id` was issued by this report and remains
    /// retained; otherwise returns `None`.
    #[must_use]
    #[inline]
    pub fn failure(&self, id: FailureId) -> Option<&Violation> {
        if id.report == self.report_id {
            self.violations.get(id.index)
        } else {
            None
        }
    }

    /// Returns the occurrence associated with a retained failure ID.
    ///
    /// Only failures retained in this report have an occurrence. IDs from a
    /// different report and failures rejected by the violation capacity return
    /// `None`.
    ///
    /// # Parameters
    ///
    /// - `id`: Failure identifier whose retained occurrence is requested.
    ///
    /// # Examples
    ///
    /// ```
    /// use qubit_validator::{ValidationOutcome, ValidationPath, ValidationReport};
    /// use qubit_validator::{ValidatorId, Violation, ViolationCode};
    ///
    /// let mut report = ValidationReport::new();
    /// let violation = Violation::new(
    ///     ValidatorId::new("example.required"),
    ///     ViolationCode::new("value.missing"),
    /// );
    /// let receipt = report.record_outcome(
    ///     2,
    ///     ValidationPath::root(),
    ///     ValidationOutcome::invalid(vec![violation])?,
    /// )?;
    /// let failure_id = receipt.failure_ids()[0];
    /// assert_eq!(report.failure_occurrence(failure_id), Some(2));
    /// # Ok::<(), qubit_validator::ValidationOutcomeError>(())
    /// ```
    #[must_use]
    #[inline]
    pub fn failure_occurrence(&self, id: FailureId) -> Option<usize> {
        self.failure(id)?;
        self.failure_occurrences.get(id.index).copied()
    }

    /// Iterates over retained failures and their occurrences in report order.
    ///
    /// The iterator contains only failures retained within the configured
    /// violation capacity. Multiple failures from one outcome share an
    /// occurrence, and separate outcomes may use the same occurrence.
    #[must_use = "inspect all retained failures and their occurrences"]
    #[inline]
    pub fn failures_with_occurrences(&self) -> impl Iterator<Item = (usize, &Violation)> + '_ {
        self.failure_occurrences.iter().copied().zip(self.violations.iter())
    }

    /// Returns all skipped occurrences in occurrence order.
    #[must_use = "the skipped occurrences should be inspected"]
    #[inline]
    pub fn skipped(&self) -> &[SkippedValidation] {
        &self.skipped
    }

    /// Returns whether this report is incomplete.
    ///
    /// # Returns
    ///
    /// Returns `true` when a collection limit rejected part of an outcome or
    /// the caller marked execution incomplete with [`Self::mark_truncated`].
    #[must_use]
    #[inline]
    pub const fn is_truncated(&self) -> bool {
        self.truncated
    }

    /// Returns the configured collection limits.
    #[must_use]
    #[inline]
    pub const fn limits(&self) -> ValidationLimits {
        self.limits
    }

    /// Returns the remaining capacity for retained original violations, or the
    /// largest possible count when unbounded. Prerequisite references do not
    /// consume this capacity.
    fn remaining_failure_capacity(&self) -> usize {
        self.limits
            .max_violations
            .map_or(usize::MAX, |limit| limit.saturating_sub(self.violations.len()))
    }

    /// Returns whether one more skipped occurrence fits the configured limit.
    fn has_skipped_capacity(&self) -> bool {
        self.limits.max_skipped.is_none_or(|limit| self.skipped.len() < limit)
    }
}

impl Default for ValidationReport {
    /// Creates an unbounded empty report.
    #[inline]
    fn default() -> Self {
        Self::new()
    }
}

impl fmt::Debug for ValidationReport {
    /// Formats counts and truncation state without exposing report contents.
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("ValidationReport")
            .field("violation_count", &self.failure_count())
            .field("skipped_count", &self.skipped.len())
            .field("truncated", &self.truncated)
            .finish()
    }
}

impl fmt::Display for ValidationReport {
    /// Formats counts and truncation state without exposing report contents.
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            formatter,
            "validation report: {} violation(s), {} skipped, truncated={}",
            self.failure_count(),
            self.skipped.len(),
            self.truncated,
        )
    }
}