qubit-redact 0.9.0

Rule-driven redaction for fields, diagnostics, HTTP data, and Rust domain objects
Documentation
// =============================================================================
//    Copyright (c) 2026 Haixing Hu.
//
//    SPDX-License-Identifier: Apache-2.0
//
//    Licensed under the Apache License, Version 2.0.
// =============================================================================
//! Transaction-owned structural accounting backed by
//! [`qubit_budget::StructureBudget`].

use qubit_budget::StructureBudget;
use qubit_budget::StructureLimits;

use super::structural_entry::StructuralEntry;

/// Tracks the shared structural resources of one redaction transaction.
#[derive(Debug)]
pub(crate) struct StructuralBudget {
    /// Underlying node, collection, and key budget.
    budget: StructureBudget,
    /// Active domain-value nesting depth.
    current_depth: usize,
    /// Optional maximum domain or format nesting depth.
    max_depth: Option<usize>,
    /// Whether a prior rejection prevents any later traversal.
    traversal_closed: bool,
    /// Cumulative collection items admitted across namespaces.
    collection_items_seen: usize,
}

impl StructuralBudget {
    /// Creates the structural ledger from immutable transaction limits.
    ///
    /// # Parameters
    ///
    /// - `limits`: Shared structural ceilings for this transaction.
    ///
    /// # Returns
    ///
    /// A fresh ledger at depth zero with traversal open.
    #[must_use]
    #[inline(always)]
    pub(crate) fn new(limits: StructureLimits) -> Self {
        Self {
            budget: limits.budget(),
            current_depth: 0,
            max_depth: limits.max_depth(),
            traversal_closed: false,
            collection_items_seen: 0,
        }
    }

    /// Returns the active domain nesting depth.
    ///
    /// # Returns
    ///
    /// The number of currently entered domain-value scopes.
    #[must_use]
    #[inline(always)]
    pub(crate) const fn current_depth(&self) -> usize {
        self.current_depth
    }

    /// Enters an explicitly nested domain value.
    ///
    /// # Returns
    ///
    /// `Entered` after charging and increasing depth, or the specific depth or
    /// traversal rejection without entering a scope.
    pub(crate) fn enter_value(&mut self) -> StructuralEntry {
        if self.traversal_closed {
            return StructuralEntry::TraversalLimitReached;
        }
        if self.max_depth.is_some_and(|max_depth| self.current_depth >= max_depth) {
            return StructuralEntry::DepthLimitReached;
        }
        if self.budget.enter_node(self.current_depth.saturating_add(1)).is_err() {
            self.close_traversal();
            return StructuralEntry::TraversalLimitReached;
        }
        self.current_depth += 1;
        StructuralEntry::Entered
    }

    /// Charges one field node without changing the nesting depth.
    ///
    /// # Returns
    ///
    /// Whether one field node was charged; a node-limit rejection closes
    /// traversal.
    pub(crate) fn admit_field(&mut self) -> bool {
        if self.traversal_closed {
            return false;
        }
        if self.budget.charge_node().is_err() {
            self.close_traversal();
            return false;
        }
        true
    }

    /// Charges a format node without changing domain nesting depth.
    ///
    /// # Parameters
    ///
    /// - `depth`: Root-inclusive format depth, without changing domain nesting.
    ///
    /// # Returns
    ///
    /// `Entered` after charging a node, or the specific depth or traversal
    /// rejection.
    pub(crate) fn admit_format_node(&mut self, depth: usize) -> StructuralEntry {
        if self.traversal_closed {
            return StructuralEntry::TraversalLimitReached;
        }
        if self.max_depth.is_some_and(|max_depth| depth > max_depth) {
            return StructuralEntry::DepthLimitReached;
        }
        if self.budget.charge_node().is_err() {
            self.close_traversal();
            return StructuralEntry::TraversalLimitReached;
        }
        StructuralEntry::Entered
    }

    /// Charges one collection item.
    ///
    /// # Returns
    ///
    /// Whether one cumulative collection item was charged before access.
    pub(crate) fn admit_collection_item(&mut self) -> bool {
        if self.traversal_closed {
            return false;
        }
        let next = self.collection_items_seen.saturating_add(1);
        if self.budget.check_sequence_items(next).is_err() {
            self.close_traversal();
            return false;
        }
        self.collection_items_seen = next;
        true
    }

    /// Checks a raw UTF-8 key before classification or value access.
    ///
    /// Returns `false` and closes traversal when the per-key limit is exceeded
    /// or a previous admission already closed the transaction.
    ///
    /// # Parameters
    ///
    /// - `bytes`: Raw UTF-8 key length before classification or normalization.
    ///
    /// # Returns
    ///
    /// Whether the key fits and traversal remains open; rejection closes
    /// traversal.
    pub(crate) fn admit_key(&mut self, bytes: usize) -> bool {
        if self.traversal_closed {
            return false;
        }
        if self.budget.check_key_bytes(bytes).is_err() {
            self.close_traversal();
            return false;
        }
        true
    }

    /// Leaves one explicitly nested domain value.
    ///
    /// # Panics
    ///
    /// In debug builds, panics if no successful `enter_value` has a matching
    /// open scope. Callers must balance only successfully entered scopes.
    #[inline]
    pub(crate) fn leave_value(&mut self) {
        debug_assert!(self.current_depth > 0, "domain scope depth underflow");
        self.current_depth -= 1;
    }

    /// Closes traversal after a resource limit rejects it.
    #[inline(always)]
    fn close_traversal(&mut self) {
        self.traversal_closed = true;
    }
}