Skip to main content

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}