qubit_redact/facade/diagnostic_redaction_output.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//! Fail-closed presentation of independently resolvable batch results.
9
10use std::borrow::Cow;
11
12use super::DiagnosticRedactionBatchOutput;
13use super::DiagnosticRedactionHandle;
14use super::RedactedText;
15use super::RedactionSummary;
16use crate::output::log_escape::escape_log_control_characters;
17
18/// Presents batch items for diagnostics without exposing resolution errors.
19///
20/// A complete item retains its redacted text. An incomplete item, a missing
21/// item, or a handle created by another batch resolves to one caller-selected
22/// marker. This fail-closed behavior is intended for `Debug`, `Display`, logs,
23/// and other presentation paths that cannot recover from individual handle
24/// failures. The public batch contract deliberately maps every unresolved or
25/// incomplete item to the same safe marker.
26///
27/// The marker is escaped once when this object is created, so repeated
28/// resolution neither allocates nor permits control characters to forge log
29/// structure.
30/// The escaped marker is selected after publication and is outside the
31/// batch's output budget; a final logging sink may impose its own size limit.
32///
33/// # Examples
34///
35/// ```
36/// use qubit_redact::Redactor;
37///
38/// let mut batch = Redactor::strict().diagnostic_batch();
39/// let handle = batch.redact_field("password", "raw-secret");
40/// let diagnostics = batch.finish_with_marker("<redaction incomplete>");
41/// assert_eq!(diagnostics.text(handle).as_str(), "<redacted>");
42/// ```
43pub struct DiagnosticRedactionOutput {
44 /// Strict publication that owns the batch identity, items, and summary.
45 output: DiagnosticRedactionBatchOutput,
46 /// Escaped fallback used for incomplete or unresolvable items.
47 marker: RedactedText,
48}
49
50impl DiagnosticRedactionOutput {
51 /// Creates a diagnostic view over one completed batch publication.
52 ///
53 /// `marker` is escaped immediately and reused for every fail-closed
54 /// resolution.
55 ///
56 /// # Parameters
57 ///
58 /// - `output`: Completed batch publication retained by this diagnostic
59 /// view.
60 /// - `marker`: Fallback text escaped once outside the completed batch’s
61 /// byte budget.
62 ///
63 /// # Returns
64 ///
65 /// A diagnostic view sharing one escaped fallback across all incomplete or
66 /// invalid items.
67 #[must_use]
68 #[inline]
69 pub(crate) fn new(output: DiagnosticRedactionBatchOutput, marker: &str) -> Self {
70 let marker = escape_log_control_characters(Cow::Borrowed(marker));
71 Self {
72 output,
73 marker: RedactedText::from_escaped(marker.into_owned()),
74 }
75 }
76
77 /// Resolves an item to complete redacted text or the shared marker.
78 ///
79 /// The marker is returned when `handle` belongs to another batch, names a
80 /// missing item, or identifies an item whose completion is `Truncated` or
81 /// `Exhausted`. No allocation occurs during resolution.
82 ///
83 /// # Parameters
84 ///
85 /// - `handle`: Opaque capability issued by a batch.
86 ///
87 /// # Returns
88 ///
89 /// Borrowed complete item text, or this view’s shared escaped fallback
90 /// marker.
91 #[must_use]
92 #[inline]
93 pub fn text(&self, handle: DiagnosticRedactionHandle) -> &RedactedText {
94 self.output
95 .resolve(handle)
96 .ok()
97 .and_then(|output| output.complete_text().ok())
98 .unwrap_or(&self.marker)
99 }
100
101 /// Returns the aggregate accounting summary for the underlying batch.
102 ///
103 /// # Returns
104 ///
105 /// The underlying batch’s aggregate completion, reasons, and resource
106 /// usage.
107 #[must_use]
108 #[inline(always)]
109 pub const fn summary(&self) -> &RedactionSummary {
110 self.output.summary()
111 }
112}
113
114// These regressions forge an invalid index through facade-private handle
115// fields. Keep them local instead of widening the public or crate-visible
116// handle contract.
117#[cfg(test)]
118mod tests {
119 use std::ptr;
120
121 use super::DiagnosticRedactionHandle;
122 use crate::Redactor;
123
124 /// A missing item from the same batch reuses the escaped diagnostic marker.
125 #[test]
126 fn test_text_reuses_escaped_marker_for_missing_same_batch_item() {
127 let mut batch = Redactor::standard().diagnostic_batch();
128 let valid = batch.redact_field("name", "Ada");
129 let missing = DiagnosticRedactionHandle {
130 batch_id: valid.batch_id,
131 item_index: usize::MAX,
132 };
133 let diagnostics = batch.finish_with_marker("<redaction\nincomplete>");
134
135 let first = diagnostics.text(missing);
136 let second = diagnostics.text(missing);
137
138 assert_eq!(first.as_str(), "<redaction\\nincomplete>");
139 assert!(ptr::eq(first, second));
140 }
141}