qubit_redact/facade/redaction_batch_diagnostics.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::RedactedText;
13use super::RedactionBatchHandle;
14use super::RedactionBatchOutput;
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///
31/// # Examples
32///
33/// ```
34/// use qubit_redact::Redactor;
35///
36/// let mut batch = Redactor::strict().batch();
37/// let handle = batch.redact_field("password", "raw-secret");
38/// let diagnostics = batch.finish_for_diagnostics("<redaction incomplete>");
39/// assert_eq!(diagnostics.text(handle).as_str(), "<redacted>");
40/// ```
41pub struct RedactionBatchDiagnostics {
42 /// Strict publication that owns the batch identity, items, and summary.
43 output: RedactionBatchOutput,
44 /// Escaped fallback used for incomplete or unresolvable items.
45 marker: RedactedText,
46}
47
48impl RedactionBatchDiagnostics {
49 /// Creates a diagnostic view over one completed batch publication.
50 ///
51 /// `marker` is escaped immediately and reused for every fail-closed
52 /// resolution.
53 #[must_use]
54 pub(crate) fn new(output: RedactionBatchOutput, marker: &str) -> Self {
55 let marker = escape_log_control_characters(Cow::Borrowed(marker));
56 Self {
57 output,
58 marker: RedactedText::from_escaped(marker.into_owned()),
59 }
60 }
61
62 /// Resolves an item to complete redacted text or the shared marker.
63 ///
64 /// The marker is returned when `handle` belongs to another batch, names a
65 /// missing item, or identifies an item whose completion is `Truncated` or
66 /// `Exhausted`. No allocation occurs during resolution.
67 #[must_use]
68 #[inline]
69 pub fn text(&self, handle: RedactionBatchHandle) -> &RedactedText {
70 self.output
71 .resolve(handle)
72 .ok()
73 .and_then(|output| output.complete_text().ok())
74 .unwrap_or(&self.marker)
75 }
76
77 /// Returns the aggregate accounting summary for the underlying batch.
78 #[must_use]
79 #[inline(always)]
80 pub const fn summary(&self) -> &RedactionSummary {
81 self.output.summary()
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use std::ptr;
88
89 use super::RedactionBatchHandle;
90 use crate::Redactor;
91
92 /// A missing item from the same batch reuses the escaped diagnostic marker.
93 #[test]
94 fn test_text_reuses_escaped_marker_for_missing_same_batch_item() {
95 let mut batch = Redactor::standard().batch();
96 let valid = batch.redact_field("name", "Ada");
97 let missing = RedactionBatchHandle {
98 batch_id: valid.batch_id,
99 item_index: usize::MAX,
100 };
101 let diagnostics = batch.finish_for_diagnostics("<redaction\nincomplete>");
102
103 let first = diagnostics.text(missing);
104 let second = diagnostics.text(missing);
105
106 assert_eq!(first.as_str(), "<redaction\\nincomplete>");
107 assert!(ptr::eq(first, second));
108 }
109}