diff --git a/README.md b/README.md
index 83823ec..efccc33 100644
--- a/README.md
+++ b/README.md
@@ -628,12 +628,15 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED");
Environment detection with DisplayMode
`DisplayMode` detects the deployment environment (`Prod`, `Local` or
-`Staging`) so your code can branch on it. `DisplayMode::current()` resolves
-the mode in this order and caches the result on first access:
+`Staging`) and drives the `Display` output of `AppError`.
+`DisplayMode::current()` resolves the mode in this order and caches the
+result on first access (the environment is read once per process):
-1. `MASTERROR_ENV` environment variable (`prod`, `local`, or `staging`)
+1. `MASTERROR_ENV` environment variable (`prod`/`production`,
+ `local`/`dev`/`development`, or `staging`/`stage`)
2. `KUBERNETES_SERVICE_HOST` presence (selects `Prod`)
-3. Build configuration (`debug_assertions` → `Local`, release → `Prod`)
+3. Build configuration (`debug_assertions` → `Local`, release → `Prod`;
+ the only detection step available without the `std` feature)
~~~rust
use masterror::DisplayMode;
@@ -647,28 +650,24 @@ match mode {
}
~~~
-Note: `Display` for `AppError` does not consult `DisplayMode` yet — the
-output is identical in all modes. The only formatting branch today is the
-`colored` feature described below; mode-aware formatting is not wired up.
+`Display` for `AppError` dispatches on the detected mode:
-**Colored Terminal Output:**
-
-Enable the `colored` feature for enhanced terminal output. It applies
-whenever the feature is enabled, regardless of the detected mode:
+| Mode | Layout |
+|------|--------|
+| `Local` | Multi-line human-readable report: kind, code, message, source chain, metadata |
+| `Prod` | Compact single-line JSON: `kind`, `code`, optional `message`, metadata |
+| `Staging` | Same JSON as `Prod` plus a `source_chain` array |
-~~~toml
-[dependencies]
-masterror = { version = "0.28.0", features = ["colored"] }
-~~~
+All layouts apply per-field redaction policies: `Redact` renders the
+`[REDACTED]` placeholder, `Hash` renders a SHA-256 hex digest, `Last4`
+keeps only the trailing characters (fields that cannot be masked are
+omitted). Set `MASTERROR_ENV=local` on any host to force the
+human-readable layout, e.g. when debugging inside Kubernetes.
-Without the `colored` feature, errors display their `AppErrorKind` label:
-~~~
-NotFound
-~~~
+In `Local` mode the output looks like:
-With `colored`, a full multi-line format with context:
~~~
-Error: NotFound
+Error: Not found
Code: NOT_FOUND
Message: User not found
@@ -677,6 +676,23 @@ Context:
request_id: abc-def
~~~
+In `Prod` mode the same error renders as:
+
+~~~
+{"kind":"NotFound","code":"NOT_FOUND","message":"User not found","metadata":{"request_id":"abc-def","user_id":12345}}
+~~~
+
+**Colored Terminal Output:**
+
+Enable the `colored` feature for ANSI styling with automatic TTY
+detection. It affects only the `Local` layout; `Prod` and `Staging` JSON
+never contains escape sequences:
+
+~~~toml
+[dependencies]
+masterror = { version = "0.28.0", features = ["colored"] }
+~~~
+
diff --git a/README.template.md b/README.template.md
index 448747f..ab4005b 100644
--- a/README.template.md
+++ b/README.template.md
@@ -623,12 +623,15 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED");
Environment detection with DisplayMode
`DisplayMode` detects the deployment environment (`Prod`, `Local` or
-`Staging`) so your code can branch on it. `DisplayMode::current()` resolves
-the mode in this order and caches the result on first access:
+`Staging`) and drives the `Display` output of `AppError`.
+`DisplayMode::current()` resolves the mode in this order and caches the
+result on first access (the environment is read once per process):
-1. `MASTERROR_ENV` environment variable (`prod`, `local`, or `staging`)
+1. `MASTERROR_ENV` environment variable (`prod`/`production`,
+ `local`/`dev`/`development`, or `staging`/`stage`)
2. `KUBERNETES_SERVICE_HOST` presence (selects `Prod`)
-3. Build configuration (`debug_assertions` → `Local`, release → `Prod`)
+3. Build configuration (`debug_assertions` → `Local`, release → `Prod`;
+ the only detection step available without the `std` feature)
~~~rust
use masterror::DisplayMode;
@@ -642,28 +645,24 @@ match mode {
}
~~~
-Note: `Display` for `AppError` does not consult `DisplayMode` yet — the
-output is identical in all modes. The only formatting branch today is the
-`colored` feature described below; mode-aware formatting is not wired up.
+`Display` for `AppError` dispatches on the detected mode:
-**Colored Terminal Output:**
-
-Enable the `colored` feature for enhanced terminal output. It applies
-whenever the feature is enabled, regardless of the detected mode:
+| Mode | Layout |
+|------|--------|
+| `Local` | Multi-line human-readable report: kind, code, message, source chain, metadata |
+| `Prod` | Compact single-line JSON: `kind`, `code`, optional `message`, metadata |
+| `Staging` | Same JSON as `Prod` plus a `source_chain` array |
-~~~toml
-[dependencies]
-masterror = { version = "{{CRATE_VERSION}}", features = ["colored"] }
-~~~
+All layouts apply per-field redaction policies: `Redact` renders the
+`[REDACTED]` placeholder, `Hash` renders a SHA-256 hex digest, `Last4`
+keeps only the trailing characters (fields that cannot be masked are
+omitted). Set `MASTERROR_ENV=local` on any host to force the
+human-readable layout, e.g. when debugging inside Kubernetes.
-Without the `colored` feature, errors display their `AppErrorKind` label:
-~~~
-NotFound
-~~~
+In `Local` mode the output looks like:
-With `colored`, a full multi-line format with context:
~~~
-Error: NotFound
+Error: Not found
Code: NOT_FOUND
Message: User not found
@@ -672,6 +671,23 @@ Context:
request_id: abc-def
~~~
+In `Prod` mode the same error renders as:
+
+~~~
+{"kind":"NotFound","code":"NOT_FOUND","message":"User not found","metadata":{"request_id":"abc-def","user_id":12345}}
+~~~
+
+**Colored Terminal Output:**
+
+Enable the `colored` feature for ANSI styling with automatic TTY
+detection. It affects only the `Local` layout; `Prod` and `Staging` JSON
+never contains escape sequences:
+
+~~~toml
+[dependencies]
+masterror = { version = "{{CRATE_VERSION}}", features = ["colored"] }
+~~~
+
diff --git a/src/app_error.rs b/src/app_error.rs
index 4b74b60..bc72784 100644
--- a/src/app_error.rs
+++ b/src/app_error.rs
@@ -6,10 +6,10 @@
//!
//! [`AppError`] is a thin, framework-agnostic wrapper around a canonical
//! error taxonomy [`AppErrorKind`] plus an optional public-facing message.
-//! Without the `colored` feature, the `Display` for `AppError` prints only
-//! the kind, not the message, to keep logs and errors concise by default.
-//! With `colored` enabled, `Display` renders a multi-line report (kind, code,
-//! message, source chain, metadata).
+//! The `Display` implementation dispatches on [`DisplayMode::current`]:
+//! `Local` renders a multi-line human-readable report (kind, code, message,
+//! source chain, metadata), while `Prod` and `Staging` render compact JSON.
+//! The `colored` feature adds ANSI styling to the `Local` layout only.
//!
//! ## Design
//!
@@ -75,6 +75,7 @@ mod context;
mod core;
mod inline_vec;
mod metadata;
+pub(crate) mod redaction;
pub use core::{AppError, AppResult, DisplayMode, Error, ErrorChain, MessageEditPolicy};
#[cfg(all(test, feature = "backtrace"))]
diff --git a/src/app_error/core.rs b/src/app_error/core.rs
index 6f35b68..3d72d37 100644
--- a/src/app_error/core.rs
+++ b/src/app_error/core.rs
@@ -52,13 +52,14 @@ pub mod telemetry;
/// - `CapturedBacktrace` type alias
pub mod types;
-/// Environment detection and alternative formatting helpers.
+/// Environment detection and mode-aware `Display` layouts.
///
/// Provides [`DisplayMode`], which detects the deployment environment from
/// `MASTERROR_ENV`, `KUBERNETES_SERVICE_HOST` or build configuration and
-/// caches the result. The `Display` implementation for `Error` does not
-/// consult this mode; it is a detection API for callers to choose their own
-/// formatting.
+/// caches the result once per process. The `Display` implementation for
+/// `Error` dispatches on this mode: `Local` renders a multi-line
+/// human-readable report, while `Prod` and `Staging` render compact JSON
+/// with redaction-aware metadata.
pub mod display;
#[cfg(all(test, feature = "backtrace"))]
@@ -183,9 +184,12 @@ mod tests {
#[test]
fn error_display_shows_kind() {
+ let _guard = display::force_display_mode(DisplayMode::Local);
let err = Error::new(AppErrorKind::Internal, "test");
let display = format!("{}", err);
- assert!(!display.is_empty());
+ assert!(display.contains("Error: Internal server error"));
+ assert!(display.contains("Code: INTERNAL"));
+ assert!(display.contains("Message: test"));
}
#[cfg(feature = "std")]
diff --git a/src/app_error/core/display.rs b/src/app_error/core/display.rs
index cfe349f..e0ab9dc 100644
--- a/src/app_error/core/display.rs
+++ b/src/app_error/core/display.rs
@@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: MIT
-use alloc::string::ToString;
+use alloc::string::{String, ToString};
use core::{
error::Error as CoreError,
fmt::{Formatter, Result as FmtResult},
@@ -10,17 +10,29 @@ use core::{
};
use super::error::Error;
-use crate::{FieldRedaction, FieldValue, MessageEditPolicy};
+use crate::{
+ FieldRedaction, FieldValue, MessageEditPolicy, Metadata,
+ app_error::redaction::{REDACTED_PLACEHOLDER, hash_field_value, mask_last4_field_value}
+};
+
+/// Sentinel stored in [`CACHED_MODE`] while no mode has been detected yet.
+const MODE_CACHE_UNSET: u8 = 255;
+
+/// Process-wide cache holding the detected [`DisplayMode`] discriminant.
+static CACHED_MODE: AtomicU8 = AtomicU8::new(MODE_CACHE_UNSET);
-/// Detected deployment environment.
+/// Detected deployment environment driving the `Display` layout of
+/// [`struct@crate::Error`].
///
/// [`DisplayMode::current`] identifies the environment the process runs in,
/// based on the `MASTERROR_ENV` environment variable, Kubernetes detection
/// (`KUBERNETES_SERVICE_HOST`) or build configuration, and caches the result.
///
-/// Note: the `Display` implementation for [`struct@crate::Error`] does not
-/// currently switch its output based on this mode; the mode is a detection
-/// API that callers can consult to choose their own formatting.
+/// The `Display` implementation for [`struct@crate::Error`] dispatches on
+/// this mode: `Local` renders a multi-line human-readable report, while
+/// `Prod` and `Staging` render compact single-line JSON. Set
+/// `MASTERROR_ENV=local` to force the human-readable layout in any
+/// environment.
///
/// # Examples
///
@@ -40,17 +52,21 @@ pub enum DisplayMode {
///
/// Selected when `MASTERROR_ENV` is `prod`/`production`, when
/// `KUBERNETES_SERVICE_HOST` is set, or for release builds by default.
+ /// Errors render as compact JSON without a source chain.
Prod = 0,
/// Local development environment.
///
/// Selected when `MASTERROR_ENV` is `local`/`dev`/`development`, or for
- /// debug builds by default.
+ /// debug builds by default. Errors render as a multi-line
+ /// human-readable report; the `colored` feature adds ANSI styling to
+ /// this layout only.
Local = 1,
/// Staging environment.
///
- /// Selected when `MASTERROR_ENV` is `staging`/`stage`.
+ /// Selected when `MASTERROR_ENV` is `staging`/`stage`. Errors render as
+ /// compact JSON extended with the source chain.
Staging = 2
}
@@ -62,7 +78,9 @@ impl DisplayMode {
/// 2. Kubernetes environment detection (`KUBERNETES_SERVICE_HOST`)
/// 3. Build configuration (`cfg!(debug_assertions)`)
///
- /// The result is cached for performance.
+ /// Without the `std` feature only step 3 applies. The result is cached
+ /// on first access, so the environment is read once per process and
+ /// later changes to the variables have no effect.
///
/// # Examples
///
@@ -77,21 +95,28 @@ impl DisplayMode {
/// ```
#[must_use]
pub fn current() -> Self {
- static CACHED_MODE: AtomicU8 = AtomicU8::new(255);
+ #[cfg(test)]
+ if let Some(mode) = test_display_mode_override::get() {
+ return mode;
+ }
let cached = CACHED_MODE.load(Ordering::Relaxed);
- if cached != 255 {
- return match cached {
- 0 => Self::Prod,
- 1 => Self::Local,
- 2 => Self::Staging,
- _ => unreachable!()
- };
+ if cached != MODE_CACHE_UNSET {
+ return Self::from_discriminant(cached);
}
let mode = Self::detect();
CACHED_MODE.store(mode as u8, Ordering::Relaxed);
mode
}
+ /// Converts a cached discriminant back into a mode.
+ fn from_discriminant(value: u8) -> Self {
+ match value {
+ 0 => Self::Prod,
+ 2 => Self::Staging,
+ _ => Self::Local
+ }
+ }
+
/// Detects the appropriate display mode from environment.
///
/// This is an internal helper called by [`current()`](Self::current).
@@ -124,29 +149,108 @@ impl DisplayMode {
}
}
-#[allow(dead_code)]
+/// Overrides the detected display mode for testing purposes.
+///
+/// Setting an override clears the process-wide cache so the next
+/// [`DisplayMode::current`] call observes the new value. Overridden modes are
+/// consulted before the cache and never stored in it, keeping detection
+/// deterministic for tests that do not override.
+///
+/// # Arguments
+///
+/// * `mode` - `Some(mode)` to force a mode, `None` to clear the override
+#[cfg(test)]
+pub(crate) fn set_display_mode_override(mode: Option
) {
+ test_display_mode_override::set(mode);
+ CACHED_MODE.store(MODE_CACHE_UNSET, Ordering::Relaxed);
+}
+
+/// Resets the display mode cache and override to the uninitialized state.
+///
+/// Forces the next [`DisplayMode::current`] call to re-run detection. Tests
+/// call it after overriding the mode, mirroring
+/// `reset_backtrace_preference`.
+#[cfg(test)]
+pub(crate) fn reset_display_mode() {
+ test_display_mode_override::set(None);
+ CACHED_MODE.store(MODE_CACHE_UNSET, Ordering::Relaxed);
+}
+
+#[cfg(test)]
+mod test_display_mode_override {
+ use core::sync::atomic::{AtomicU8, Ordering};
+
+ use super::DisplayMode;
+
+ const OVERRIDE_UNSET: u8 = 255;
+
+ static OVERRIDE_STATE: AtomicU8 = AtomicU8::new(OVERRIDE_UNSET);
+
+ pub(super) fn set(mode: Option) {
+ let state = match mode {
+ Some(mode) => mode as u8,
+ None => OVERRIDE_UNSET
+ };
+ OVERRIDE_STATE.store(state, Ordering::Release);
+ }
+
+ pub(super) fn get() -> Option {
+ match OVERRIDE_STATE.load(Ordering::Acquire) {
+ OVERRIDE_UNSET => None,
+ value => Some(DisplayMode::from_discriminant(value))
+ }
+ }
+}
+
+#[cfg(test)]
+pub(crate) use test_support::force_display_mode;
+
+#[cfg(test)]
+mod test_support {
+ use std::sync::{Mutex, MutexGuard, PoisonError};
+
+ use super::{DisplayMode, reset_display_mode, set_display_mode_override};
+
+ static DISPLAY_MODE_LOCK: Mutex<()> = Mutex::new(());
+
+ /// Guard restoring the default display mode detection on drop.
+ pub(crate) struct DisplayModeGuard {
+ _lock: MutexGuard<'static, ()>
+ }
+
+ impl Drop for DisplayModeGuard {
+ fn drop(&mut self) {
+ reset_display_mode();
+ }
+ }
+
+ /// Forces the display mode for the lifetime of the returned guard.
+ ///
+ /// Serializes tests that override the mode so concurrent overrides do
+ /// not observe each other.
+ pub(crate) fn force_display_mode(mode: DisplayMode) -> DisplayModeGuard {
+ let lock = DISPLAY_MODE_LOCK
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner);
+ set_display_mode_override(Some(mode));
+ DisplayModeGuard {
+ _lock: lock
+ }
+ }
+}
+
impl Error {
/// Formats the error as compact JSON (`kind`, `code`, optional `message`,
- /// public metadata).
+ /// redaction-aware metadata).
///
- /// Not wired into the `Display` implementation; callers that want this
- /// layout must invoke it explicitly.
+ /// Selected by the `Display` implementation when [`DisplayMode::current`]
+ /// returns [`DisplayMode::Prod`]. The output never contains ANSI escape
+ /// sequences.
///
/// # Arguments
///
/// * `f` - Formatter to write output to
- #[cfg(not(test))]
pub(crate) fn fmt_prod(&self, f: &mut Formatter<'_>) -> FmtResult {
- self.fmt_prod_impl(f)
- }
-
- #[cfg(test)]
- #[allow(missing_docs)]
- pub fn fmt_prod(&self, f: &mut Formatter<'_>) -> FmtResult {
- self.fmt_prod_impl(f)
- }
-
- fn fmt_prod_impl(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, r#"{{"kind":"{:?}","code":"{}""#, self.kind, self.code)?;
if !matches!(self.edit_policy, MessageEditPolicy::Redact)
&& let Some(msg) = &self.message
@@ -155,141 +259,73 @@ impl Error {
write_json_escaped(f, msg.as_ref())?;
write!(f, "\"")?;
}
- if !self.metadata.is_empty() {
- let has_public_fields = self
- .metadata
- .iter_with_redaction()
- .any(|(_, _, redaction)| !matches!(redaction, FieldRedaction::Redact));
- if has_public_fields {
- write!(f, r#","metadata":{{"#)?;
- let mut first = true;
- for (name, value, redaction) in self.metadata.iter_with_redaction() {
- if matches!(redaction, FieldRedaction::Redact) {
- continue;
- }
- if !first {
- write!(f, ",")?;
- }
- first = false;
- write!(f, r#""{}":"#, name)?;
- write_metadata_value(f, value)?;
- }
- write!(f, "}}")?;
- }
- }
+ write_json_metadata_section(f, &self.metadata)?;
write!(f, "}}")
}
/// Formats the error as a multi-line human-readable report (kind, code,
- /// message, source chain, metadata).
+ /// message, source chain, redaction-aware metadata).
///
- /// Not wired into the `Display` implementation; callers that want this
- /// layout must invoke it explicitly.
+ /// Selected by the `Display` implementation when [`DisplayMode::current`]
+ /// returns [`DisplayMode::Local`]. The `colored` feature applies ANSI
+ /// styling to this layout when the terminal supports it.
///
/// # Arguments
///
/// * `f` - Formatter to write output to
- #[cfg(not(test))]
pub(crate) fn fmt_local(&self, f: &mut Formatter<'_>) -> FmtResult {
- self.fmt_local_impl(f)
- }
-
- #[cfg(test)]
- #[allow(missing_docs)]
- pub fn fmt_local(&self, f: &mut Formatter<'_>) -> FmtResult {
- self.fmt_local_impl(f)
- }
+ #[cfg(feature = "colored")]
+ use crate::colored::style;
- fn fmt_local_impl(&self, f: &mut Formatter<'_>) -> FmtResult {
+ writeln!(f, "Error: {}", self.kind)?;
#[cfg(feature = "colored")]
- {
- use crate::colored::style;
- writeln!(f, "Error: {}", self.kind)?;
- writeln!(f, "Code: {}", style::error_code(self.code.to_string()))?;
- if let Some(msg) = &self.message {
- writeln!(f, "Message: {}", style::error_message(msg))?;
- }
- if let Some(source) = &self.source {
- writeln!(f)?;
- let mut current: &dyn CoreError = source.as_dyn();
- let mut depth = 0;
- while depth < 10 {
- writeln!(
- f,
- " {}: {}",
- style::source_context("Caused by"),
- style::source_context(current.to_string())
- )?;
- if let Some(next) = current.source() {
- current = next;
- depth += 1;
- } else {
- break;
- }
- }
- }
- if !self.metadata.is_empty() {
- writeln!(f)?;
- writeln!(f, "Context:")?;
- for (key, value) in self.metadata.iter() {
- writeln!(f, " {}: {}", style::metadata_key(key), value)?;
- }
- }
- Ok(())
- }
+ writeln!(f, "Code: {}", style::error_code(self.code.to_string()))?;
#[cfg(not(feature = "colored"))]
+ writeln!(f, "Code: {}", self.code)?;
+ if !matches!(self.edit_policy, MessageEditPolicy::Redact)
+ && let Some(msg) = &self.message
{
- writeln!(f, "Error: {}", self.kind)?;
- writeln!(f, "Code: {}", self.code)?;
- if let Some(msg) = &self.message {
- writeln!(f, "Message: {}", msg)?;
- }
- if let Some(source) = &self.source {
- writeln!(f)?;
- let mut current: &dyn CoreError = source.as_dyn();
- let mut depth = 0;
- while depth < 10 {
- writeln!(f, " Caused by: {}", current)?;
- if let Some(next) = current.source() {
- current = next;
- depth += 1;
- } else {
- break;
- }
- }
- }
- if !self.metadata.is_empty() {
- writeln!(f)?;
- writeln!(f, "Context:")?;
- for (key, value) in self.metadata.iter() {
- writeln!(f, " {}: {}", key, value)?;
+ #[cfg(feature = "colored")]
+ writeln!(f, "Message: {}", style::error_message(msg))?;
+ #[cfg(not(feature = "colored"))]
+ writeln!(f, "Message: {}", msg)?;
+ }
+ if let Some(source) = &self.source {
+ writeln!(f)?;
+ let mut current: &dyn CoreError = source.as_dyn();
+ let mut depth = 0;
+ while depth < 10 {
+ #[cfg(feature = "colored")]
+ writeln!(
+ f,
+ " {}: {}",
+ style::source_context("Caused by"),
+ style::source_context(current.to_string())
+ )?;
+ #[cfg(not(feature = "colored"))]
+ writeln!(f, " Caused by: {}", current)?;
+ if let Some(next) = current.source() {
+ current = next;
+ depth += 1;
+ } else {
+ break;
}
}
- Ok(())
}
+ write_local_metadata_section(f, &self.metadata)
}
/// Formats the error as JSON with additional context (`source_chain` and
- /// public metadata).
+ /// redaction-aware metadata).
///
- /// Not wired into the `Display` implementation; callers that want this
- /// layout must invoke it explicitly.
+ /// Selected by the `Display` implementation when [`DisplayMode::current`]
+ /// returns [`DisplayMode::Staging`]. The output never contains ANSI
+ /// escape sequences.
///
/// # Arguments
///
/// * `f` - Formatter to write output to
- #[cfg(not(test))]
pub(crate) fn fmt_staging(&self, f: &mut Formatter<'_>) -> FmtResult {
- self.fmt_staging_impl(f)
- }
-
- #[cfg(test)]
- #[allow(missing_docs)]
- pub fn fmt_staging(&self, f: &mut Formatter<'_>) -> FmtResult {
- self.fmt_staging_impl(f)
- }
-
- fn fmt_staging_impl(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, r#"{{"kind":"{:?}","code":"{}""#, self.kind, self.code)?;
if !matches!(self.edit_policy, MessageEditPolicy::Redact)
&& let Some(msg) = &self.message
@@ -320,34 +356,97 @@ impl Error {
}
write!(f, "]")?;
}
- if !self.metadata.is_empty() {
- let has_public_fields = self
- .metadata
- .iter_with_redaction()
- .any(|(_, _, redaction)| !matches!(redaction, FieldRedaction::Redact));
- if has_public_fields {
- write!(f, r#","metadata":{{"#)?;
- let mut first = true;
- for (name, value, redaction) in self.metadata.iter_with_redaction() {
- if matches!(redaction, FieldRedaction::Redact) {
- continue;
- }
- if !first {
- write!(f, ",")?;
- }
- first = false;
- write!(f, r#""{}":"#, name)?;
- write_metadata_value(f, value)?;
- }
- write!(f, "}}")?;
+ write_json_metadata_section(f, &self.metadata)?;
+ write!(f, "}}")
+ }
+}
+
+/// Metadata value prepared for the local layout after applying redaction.
+enum LocalFieldValue<'a> {
+ /// Public value rendered as-is.
+ Raw(&'a FieldValue),
+ /// Value replaced by the redaction placeholder.
+ Placeholder,
+ /// Hashed or masked textual representation.
+ Owned(String)
+}
+
+/// Writes the `,"metadata":{...}` JSON section applying field redaction.
+///
+/// Fields marked [`FieldRedaction::Redact`] render as the placeholder,
+/// [`FieldRedaction::Hash`] as a SHA-256 hex digest and
+/// [`FieldRedaction::Last4`] as a masked value. Fields whose masking yields
+/// no value are omitted; if nothing remains, the section is skipped
+/// entirely.
+fn write_json_metadata_section(f: &mut Formatter<'_>, metadata: &Metadata) -> FmtResult {
+ let mut wrote_any = false;
+ for (name, value, redaction) in metadata.iter_with_redaction() {
+ let masked = match redaction {
+ FieldRedaction::Last4 => match mask_last4_field_value(value) {
+ Some(masked) => Some(masked),
+ None => continue
+ },
+ _ => None
+ };
+ if wrote_any {
+ write!(f, ",")?;
+ } else {
+ write!(f, r#","metadata":{{"#)?;
+ wrote_any = true;
+ }
+ write!(f, r#""{}":"#, name)?;
+ match redaction {
+ FieldRedaction::None => write_metadata_value(f, value)?,
+ FieldRedaction::Redact => write!(f, "\"{}\"", REDACTED_PLACEHOLDER)?,
+ FieldRedaction::Hash => write!(f, "\"{}\"", hash_field_value(value))?,
+ FieldRedaction::Last4 => {
+ write!(f, "\"")?;
+ write_json_escaped(f, masked.as_deref().unwrap_or_default())?;
+ write!(f, "\"")?;
}
}
- write!(f, "}}")
}
+ if wrote_any {
+ write!(f, "}}")?;
+ }
+ Ok(())
+}
+
+/// Writes the `Context:` block of the local layout applying field redaction.
+///
+/// Applies the same policies as [`write_json_metadata_section`]; the header
+/// is skipped when every field is omitted.
+fn write_local_metadata_section(f: &mut Formatter<'_>, metadata: &Metadata) -> FmtResult {
+ let mut wrote_header = false;
+ for (name, value, redaction) in metadata.iter_with_redaction() {
+ let rendered = match redaction {
+ FieldRedaction::None => LocalFieldValue::Raw(value),
+ FieldRedaction::Redact => LocalFieldValue::Placeholder,
+ FieldRedaction::Hash => LocalFieldValue::Owned(hash_field_value(value)),
+ FieldRedaction::Last4 => match mask_last4_field_value(value) {
+ Some(masked) => LocalFieldValue::Owned(masked),
+ None => continue
+ }
+ };
+ if !wrote_header {
+ writeln!(f)?;
+ writeln!(f, "Context:")?;
+ wrote_header = true;
+ }
+ #[cfg(feature = "colored")]
+ write!(f, " {}: ", crate::colored::style::metadata_key(name))?;
+ #[cfg(not(feature = "colored"))]
+ write!(f, " {}: ", name)?;
+ match rendered {
+ LocalFieldValue::Raw(value) => writeln!(f, "{}", value)?,
+ LocalFieldValue::Placeholder => writeln!(f, "{}", REDACTED_PLACEHOLDER)?,
+ LocalFieldValue::Owned(text) => writeln!(f, "{}", text)?
+ }
+ }
+ Ok(())
}
/// Writes a string with JSON escaping.
-#[allow(dead_code)]
fn write_json_escaped(f: &mut Formatter<'_>, s: &str) -> FmtResult {
for ch in s.chars() {
match ch {
@@ -364,7 +463,6 @@ fn write_json_escaped(f: &mut Formatter<'_>, s: &str) -> FmtResult {
}
/// Writes a metadata field value in JSON format.
-#[allow(dead_code)]
fn write_metadata_value(f: &mut Formatter<'_>, value: &FieldValue) -> FmtResult {
use crate::app_error::metadata::FieldValue;
match value {
@@ -400,9 +498,25 @@ fn write_metadata_value(f: &mut Formatter<'_>, value: &FieldValue) -> FmtResult
#[cfg(test)]
mod tests {
+ use std::fmt::Write as _;
+
+ use sha2::{Digest, Sha256};
+
use super::*;
use crate::{AppError, field};
+ fn sha256_hex(input: &[u8]) -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(input);
+ hasher
+ .finalize()
+ .iter()
+ .fold(String::with_capacity(64), |mut acc, byte| {
+ let _ = write!(&mut acc, "{:02x}", byte);
+ acc
+ })
+ }
+
#[test]
fn display_mode_current_returns_valid_mode() {
let mode = DisplayMode::current();
@@ -421,6 +535,45 @@ mod tests {
}
}
+ #[test]
+ fn display_mode_override_takes_priority() {
+ let _guard = force_display_mode(DisplayMode::Staging);
+ assert_eq!(DisplayMode::current(), DisplayMode::Staging);
+ }
+
+ #[test]
+ fn display_dispatches_prod_layout() {
+ let _guard = force_display_mode(DisplayMode::Prod);
+ let error = AppError::not_found("missing user");
+ let output = format!("{}", error);
+ assert!(output.starts_with(r#"{"kind":"NotFound""#), "{output}");
+ assert!(output.contains(r#""code":"NOT_FOUND""#));
+ assert!(output.contains(r#""message":"missing user""#));
+ assert!(!output.contains('\u{1b}'));
+ }
+
+ #[cfg(feature = "std")]
+ #[test]
+ fn display_dispatches_staging_layout() {
+ use std::io::Error as IoError;
+ let _guard = force_display_mode(DisplayMode::Staging);
+ let error = AppError::network("upstream down").with_source(IoError::other("timeout"));
+ let output = format!("{}", error);
+ assert!(output.starts_with(r#"{"kind":"Network""#), "{output}");
+ assert!(output.contains(r#""source_chain":["timeout"]"#));
+ assert!(!output.contains('\u{1b}'));
+ }
+
+ #[test]
+ fn display_dispatches_local_layout() {
+ let _guard = force_display_mode(DisplayMode::Local);
+ let error = AppError::not_found("missing user");
+ let output = format!("{}", error);
+ assert!(output.contains("Error:"));
+ assert!(output.contains("Code: NOT_FOUND"));
+ assert!(output.contains("missing user"));
+ }
+
#[test]
fn fmt_prod_outputs_json() {
let error = AppError::not_found("User not found");
@@ -450,6 +603,70 @@ mod tests {
let error = AppError::internal("Error").with_field(field::str("password", "secret"));
let output = format!("{}", error.fmt_prod_wrapper());
assert!(!output.contains("secret"));
+ assert!(output.contains(r#""password":"[REDACTED]""#));
+ }
+
+ #[test]
+ fn hash_redaction_renders_hex_digest_in_all_modes() {
+ let expected = sha256_hex(b"super-secret");
+ let error = AppError::internal("Error").with_field(
+ field::str("fingerprint", "super-secret").with_redaction(FieldRedaction::Hash)
+ );
+ let prod = format!("{}", error.fmt_prod_wrapper());
+ let staging = format!("{}", error.fmt_staging_wrapper());
+ let local = format!("{}", error.fmt_local_wrapper());
+ for output in [&prod, &staging, &local] {
+ assert!(output.contains(&expected), "{output}");
+ assert!(!output.contains("super-secret"), "{output}");
+ }
+ assert!(prod.contains(&format!(r#""fingerprint":"{}""#, expected)));
+ assert!(staging.contains(&format!(r#""fingerprint":"{}""#, expected)));
+ assert!(local.contains(&format!("fingerprint: {}", expected)));
+ }
+
+ #[test]
+ fn last4_redaction_masks_value_in_all_modes() {
+ let error = AppError::internal("Error").with_field(
+ field::str("card_number", "4111111111111111").with_redaction(FieldRedaction::Last4)
+ );
+ let prod = format!("{}", error.fmt_prod_wrapper());
+ let staging = format!("{}", error.fmt_staging_wrapper());
+ let local = format!("{}", error.fmt_local_wrapper());
+ for output in [&prod, &staging, &local] {
+ assert!(output.contains("************1111"), "{output}");
+ assert!(!output.contains("4111111111111111"), "{output}");
+ }
+ }
+
+ #[test]
+ fn last4_redaction_omits_unmaskable_field() {
+ let error = AppError::internal("Error")
+ .with_field(field::bool("consent", true).with_redaction(FieldRedaction::Last4));
+ let prod = format!("{}", error.fmt_prod_wrapper());
+ let staging = format!("{}", error.fmt_staging_wrapper());
+ let local = format!("{}", error.fmt_local_wrapper());
+ assert!(!prod.contains("metadata"), "{prod}");
+ assert!(!staging.contains("metadata"), "{staging}");
+ assert!(!local.contains("Context:"), "{local}");
+ for output in [&prod, &staging, &local] {
+ assert!(!output.contains("consent"), "{output}");
+ }
+ }
+
+ #[test]
+ fn redact_policy_uses_placeholder_in_local_layout() {
+ let error = AppError::internal("Error").with_field(field::str("password", "hunter2"));
+ let output = format!("{}", error.fmt_local_wrapper());
+ assert!(output.contains("password: [REDACTED]"), "{output}");
+ assert!(!output.contains("hunter2"));
+ }
+
+ #[test]
+ fn fmt_local_hides_redacted_message() {
+ let error = AppError::internal("sensitive data").redactable();
+ let output = format!("{}", error.fmt_local_wrapper());
+ assert!(!output.contains("sensitive data"));
+ assert!(!output.contains("Message:"));
}
#[test]
@@ -570,11 +787,11 @@ mod tests {
#[test]
fn fmt_local_with_metadata() {
let error = AppError::internal("Error")
- .with_field(field::str("key", "value"))
+ .with_field(field::str("region", "value"))
.with_field(field::i64("count", -42));
let output = format!("{}", error.fmt_local_wrapper());
assert!(output.contains("Context:"));
- assert!(output.contains("key: value"));
+ assert!(output.contains("region: value"));
assert!(output.contains("count: -42"));
}
@@ -619,7 +836,11 @@ mod tests {
#[test]
fn display_mode_current_caches_result() {
+ let _guard = force_display_mode(DisplayMode::Staging);
+ reset_display_mode();
+ assert_eq!(CACHED_MODE.load(Ordering::Relaxed), MODE_CACHE_UNSET);
let first = DisplayMode::current();
+ assert_eq!(CACHED_MODE.load(Ordering::Relaxed), first as u8);
let second = DisplayMode::current();
assert_eq!(first, second);
}
@@ -664,7 +885,7 @@ mod tests {
assert!(output.contains(r#""name":"value""#));
}
- #[cfg(feature = "colored")]
+ #[cfg(feature = "std")]
#[test]
fn fmt_local_with_deep_source_chain() {
use std::io::{Error as IoError, ErrorKind};
@@ -706,6 +927,7 @@ mod tests {
.with_field(field::str("password", "secret"));
let output = format!("{}", error.fmt_staging_wrapper());
assert!(output.contains(r#""public":"visible""#));
+ assert!(output.contains(r#""password":"[REDACTED]""#));
assert!(!output.contains("secret"));
}
diff --git a/src/app_error/core/error.rs b/src/app_error/core/error.rs
index f322aee..871f563 100644
--- a/src/app_error/core/error.rs
+++ b/src/app_error/core/error.rs
@@ -19,7 +19,10 @@ use serde_json::Value as JsonValue;
#[cfg(not(feature = "backtrace"))]
use super::types::CapturedBacktrace;
-use super::types::{MessageEditPolicy, StoredSource};
+use super::{
+ display::DisplayMode,
+ types::{MessageEditPolicy, StoredSource}
+};
use crate::{AppCode, AppErrorKind, RetryAdvice, app_error::metadata::Metadata};
/// Internal representation of error state.
@@ -95,41 +98,10 @@ impl DerefMut for Error {
impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
- #[cfg(not(feature = "colored"))]
- {
- Display::fmt(&self.kind, f)
- }
- #[cfg(feature = "colored")]
- {
- use crate::colored::style;
- writeln!(f, "Error: {}", self.kind)?;
- writeln!(f, "Code: {}", style::error_code(self.code.to_string()))?;
- if let Some(msg) = &self.message {
- writeln!(f, "Message: {}", style::error_message(msg))?;
- }
- if let Some(source) = &self.source {
- writeln!(f)?;
- let mut current: &dyn CoreError = source.as_dyn();
- let mut depth = 0;
- while depth < 10 {
- write!(f, " {}: ", style::source_context("Caused by"))?;
- writeln!(f, "{}", style::source_context(current.to_string()))?;
- if let Some(next) = current.source() {
- current = next;
- depth += 1;
- } else {
- break;
- }
- }
- }
- if !self.metadata.is_empty() {
- writeln!(f)?;
- writeln!(f, "Context:")?;
- for (key, value) in self.metadata.iter() {
- writeln!(f, " {}: {}", style::metadata_key(key), value)?;
- }
- }
- Ok(())
+ match DisplayMode::current() {
+ DisplayMode::Prod => self.fmt_prod(f),
+ DisplayMode::Staging => self.fmt_staging(f),
+ DisplayMode::Local => self.fmt_local(f)
}
}
}
diff --git a/src/app_error/redaction.rs b/src/app_error/redaction.rs
new file mode 100644
index 0000000..8191338
--- /dev/null
+++ b/src/app_error/redaction.rs
@@ -0,0 +1,283 @@
+// SPDX-FileCopyrightText: 2026 RAprogramm
+//
+// SPDX-License-Identifier: MIT
+
+//! Shared redaction helpers applied to metadata field values.
+//!
+//! Implements the value transformations backing
+//! [`FieldRedaction`](crate::FieldRedaction) policies: placeholder
+//! substitution, SHA-256 hashing and last-four masking. The same helpers power
+//! the `Display` layouts of [`Error`](crate::Error) and the RFC 7807
+//! [`ProblemJson`](crate::ProblemJson) payloads so redaction behaves
+//! identically everywhere.
+
+use alloc::string::{String, ToString};
+use core::{fmt::Write, iter::repeat_n, str::from_utf8};
+
+use itoa::Buffer as IntegerBuffer;
+use ryu::Buffer as FloatBuffer;
+use sha2::{Digest, Sha256};
+
+use super::{duration_to_string, metadata::FieldValue};
+
+/// Placeholder rendered instead of values redacted with
+/// [`FieldRedaction::Redact`](crate::FieldRedaction::Redact).
+pub(crate) const REDACTED_PLACEHOLDER: &str = "[REDACTED]";
+
+/// Fixed-capacity stack buffer used to format short values without
+/// allocating.
+struct StackBuffer {
+ buf: [u8; N],
+ len: usize
+}
+
+impl StackBuffer {
+ const fn new() -> Self {
+ Self {
+ buf: [0; N],
+ len: 0
+ }
+ }
+
+ fn as_bytes(&self) -> &[u8] {
+ &self.buf[..self.len]
+ }
+
+ fn as_str(&self) -> Option<&str> {
+ from_utf8(self.as_bytes()).ok()
+ }
+}
+
+impl Write for StackBuffer {
+ fn write_str(&mut self, s: &str) -> core::fmt::Result {
+ let remaining = N.saturating_sub(self.len);
+ if s.len() > remaining {
+ return Err(core::fmt::Error);
+ }
+ self.buf[self.len..self.len + s.len()].copy_from_slice(s.as_bytes());
+ self.len += s.len();
+ Ok(())
+ }
+}
+
+/// Hashes a field value with SHA-256 and returns the lowercase hex digest.
+pub(crate) fn hash_field_value(value: &FieldValue) -> String {
+ let mut hasher = Sha256::new();
+ match value {
+ FieldValue::Str(value) => hasher.update(value.as_ref().as_bytes()),
+ FieldValue::I64(value) => {
+ let mut buffer = IntegerBuffer::new();
+ hasher.update(buffer.format(*value).as_bytes());
+ }
+ FieldValue::U64(value) => {
+ let mut buffer = IntegerBuffer::new();
+ hasher.update(buffer.format(*value).as_bytes());
+ }
+ FieldValue::F64(value) => hasher.update(value.to_le_bytes()),
+ FieldValue::Bool(value) => {
+ if *value {
+ hasher.update(b"true");
+ } else {
+ hasher.update(b"false");
+ }
+ }
+ FieldValue::Uuid(value) => {
+ let mut repr = [0u8; 36];
+ let text = value.hyphenated().encode_lower(&mut repr);
+ hasher.update(text.as_bytes());
+ }
+ FieldValue::Duration(value) => {
+ hasher.update(value.as_secs().to_le_bytes());
+ hasher.update(value.subsec_nanos().to_le_bytes());
+ }
+ FieldValue::Ip(value) => {
+ let mut buffer = StackBuffer::<46>::new();
+ if write!(&mut buffer, "{value}").is_ok() {
+ hasher.update(buffer.as_bytes());
+ } else {
+ let fallback = value.to_string();
+ hasher.update(fallback.as_bytes());
+ }
+ }
+ #[cfg(feature = "serde_json")]
+ FieldValue::Json(value) => {
+ if let Ok(serialized) = serde_json::to_vec(value) {
+ hasher.update(&serialized);
+ }
+ }
+ }
+ let digest = hasher.finalize();
+ let mut hex = String::with_capacity(digest.len() * 2);
+ for byte in digest {
+ let _ = write!(&mut hex, "{:02x}", byte);
+ }
+ hex
+}
+
+/// Masks a field value keeping only its last four characters.
+///
+/// Returns `None` for values that have no meaningful textual form to mask
+/// (currently booleans), in which case the field must be omitted entirely.
+pub(crate) fn mask_last4_field_value(value: &FieldValue) -> Option {
+ match value {
+ FieldValue::Str(value) => Some(mask_last4(value.as_ref())),
+ FieldValue::I64(value) => {
+ let mut buffer = IntegerBuffer::new();
+ Some(mask_last4(buffer.format(*value)))
+ }
+ FieldValue::U64(value) => {
+ let mut buffer = IntegerBuffer::new();
+ Some(mask_last4(buffer.format(*value)))
+ }
+ FieldValue::F64(value) => {
+ let mut buffer = FloatBuffer::new();
+ Some(mask_last4(buffer.format(*value)))
+ }
+ FieldValue::Uuid(value) => {
+ let mut repr = [0u8; 36];
+ let text = value.hyphenated().encode_lower(&mut repr);
+ Some(mask_last4(text))
+ }
+ FieldValue::Duration(value) => Some(mask_last4(&duration_to_string(*value))),
+ FieldValue::Ip(value) => {
+ let mut buffer = StackBuffer::<46>::new();
+ if write!(&mut buffer, "{value}").is_err() {
+ return Some(mask_last4(&value.to_string()));
+ }
+ buffer.as_str().map(mask_last4)
+ }
+ #[cfg(feature = "serde_json")]
+ FieldValue::Json(value) => serde_json::to_string(value)
+ .ok()
+ .map(|text| mask_last4(&text)),
+ FieldValue::Bool(_) => None
+ }
+}
+
+/// Replaces all but the trailing characters of `value` with `*`.
+///
+/// Values of four characters or fewer keep only the last character.
+pub(crate) fn mask_last4(value: &str) -> String {
+ let chars = value.chars();
+ let total = chars.clone().count();
+ if total == 0 {
+ return String::new();
+ }
+ let keep = if total <= 4 { 1 } else { 4 };
+ let mask_len = total.saturating_sub(keep);
+ let mut masked = String::with_capacity(value.len());
+ masked.extend(repeat_n('*', mask_len));
+ masked.extend(chars.skip(mask_len));
+ masked
+}
+
+#[cfg(test)]
+mod tests {
+ use core::{net::IpAddr, time::Duration};
+
+ use super::*;
+
+ fn assert_hex_digest(digest: &str) {
+ assert_eq!(digest.len(), 64);
+ assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
+ }
+
+ #[test]
+ fn hash_covers_all_scalar_variants() {
+ assert_hex_digest(&hash_field_value(&FieldValue::Str("secret".into())));
+ assert_hex_digest(&hash_field_value(&FieldValue::I64(-42)));
+ assert_hex_digest(&hash_field_value(&FieldValue::U64(42)));
+ assert_hex_digest(&hash_field_value(&FieldValue::F64(1.5)));
+ assert_hex_digest(&hash_field_value(&FieldValue::Bool(true)));
+ assert_hex_digest(&hash_field_value(&FieldValue::Bool(false)));
+ assert_hex_digest(&hash_field_value(&FieldValue::Duration(
+ Duration::from_millis(1500)
+ )));
+ }
+
+ #[test]
+ fn hash_is_deterministic_per_value() {
+ let first = hash_field_value(&FieldValue::I64(7));
+ let second = hash_field_value(&FieldValue::I64(7));
+ let other = hash_field_value(&FieldValue::I64(8));
+ assert_eq!(first, second);
+ assert_ne!(first, other);
+ }
+
+ #[test]
+ fn hash_covers_uuid_and_ip() {
+ let uuid = uuid::Uuid::nil();
+ assert_hex_digest(&hash_field_value(&FieldValue::Uuid(uuid)));
+ let ip: IpAddr = "192.168.0.1".parse().expect("ipv4");
+ assert_hex_digest(&hash_field_value(&FieldValue::Ip(ip)));
+ let ip6: IpAddr = "::1".parse().expect("ipv6");
+ assert_hex_digest(&hash_field_value(&FieldValue::Ip(ip6)));
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn hash_covers_json() {
+ let value = serde_json::json!({"card": "4111"});
+ assert_hex_digest(&hash_field_value(&FieldValue::Json(value)));
+ }
+
+ #[test]
+ fn mask_covers_all_scalar_variants() {
+ assert_eq!(
+ mask_last4_field_value(&FieldValue::Str("4111111111111111".into()))
+ .expect("string masks"),
+ "************1111"
+ );
+ assert_eq!(
+ mask_last4_field_value(&FieldValue::I64(-123_456)).expect("i64 masks"),
+ "***3456"
+ );
+ assert_eq!(
+ mask_last4_field_value(&FieldValue::U64(123_456)).expect("u64 masks"),
+ "**3456"
+ );
+ let masked_float = mask_last4_field_value(&FieldValue::F64(1234.5)).expect("f64 masks");
+ assert!(masked_float.starts_with('*'));
+ let duration = Duration::from_secs(90);
+ assert_eq!(
+ mask_last4_field_value(&FieldValue::Duration(duration)).expect("duration masks"),
+ mask_last4(&duration_to_string(duration))
+ );
+ assert!(mask_last4_field_value(&FieldValue::Bool(true)).is_none());
+ }
+
+ #[test]
+ fn mask_covers_uuid_and_ip() {
+ let uuid = uuid::Uuid::nil();
+ let masked = mask_last4_field_value(&FieldValue::Uuid(uuid)).expect("uuid masks");
+ assert!(masked.ends_with("0000"));
+ assert!(masked.starts_with('*'));
+ let ip: IpAddr = "10.0.0.15".parse().expect("ipv4");
+ let masked_ip = mask_last4_field_value(&FieldValue::Ip(ip)).expect("ip masks");
+ assert!(masked_ip.ends_with("0.15"));
+ }
+
+ #[cfg(feature = "serde_json")]
+ #[test]
+ fn mask_covers_json() {
+ let value = serde_json::json!("tok_123456");
+ let masked = mask_last4_field_value(&FieldValue::Json(value)).expect("json masks");
+ assert!(masked.ends_with("456\""));
+ }
+
+ #[test]
+ fn mask_last4_handles_empty_and_short_values() {
+ assert_eq!(mask_last4(""), "");
+ assert_eq!(mask_last4("a"), "a");
+ assert_eq!(mask_last4("abcd"), "***d");
+ assert_eq!(mask_last4("abcde"), "*bcde");
+ }
+
+ #[test]
+ fn stack_buffer_rejects_overflow() {
+ let mut buffer = StackBuffer::<4>::new();
+ assert!(write!(&mut buffer, "hello").is_err());
+ assert!(write!(&mut buffer, "hi").is_ok());
+ assert_eq!(buffer.as_str(), Some("hi"));
+ }
+}
diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs
index f42714c..cc706ad 100644
--- a/src/app_error/tests.rs
+++ b/src/app_error/tests.rs
@@ -33,6 +33,7 @@ impl StdError for AnyhowSource {
}
}
+use super::core::{DisplayMode, display::force_display_mode};
#[cfg(feature = "backtrace")]
use super::core::{reset_backtrace_preference, set_backtrace_preference_override};
@@ -731,36 +732,29 @@ fn app_error_fits_result_budget() {
#[test]
#[cfg(feature = "std")]
fn error_chain_iterates_through_sources() {
+ let _guard = force_display_mode(DisplayMode::Local);
let io_err = IoError::other("disk offline");
let app_err = AppError::internal("db down").with_context(io_err);
let chain: Vec<_> = app_err.chain().collect();
assert_eq!(chain.len(), 2);
let first_err = chain[0].to_string();
- assert!(
- first_err.contains("Internal")
- || first_err.contains("INTERNAL")
- || first_err.contains("Error:")
- );
- #[cfg(feature = "colored")]
+ assert!(first_err.contains("Error: Internal server error"));
+ assert!(first_err.contains("Code: INTERNAL"));
assert!(first_err.contains("db down"));
- #[cfg(not(feature = "colored"))]
- assert!(first_err.contains("Internal"));
assert_eq!(chain[1].to_string(), "disk offline");
}
#[test]
#[cfg(feature = "std")]
fn error_chain_single_error() {
+ let _guard = force_display_mode(DisplayMode::Local);
let err = AppError::bad_request("missing field");
let chain: Vec<_> = err.chain().collect();
assert_eq!(chain.len(), 1);
let err_str = chain[0].to_string();
- assert!(err_str.contains("Bad") || err_str.contains("BAD"));
- #[cfg(feature = "colored")]
- {
- assert!(chain[0].to_string().contains("Bad request"));
- assert!(chain[0].to_string().contains("missing field"));
- }
+ assert!(err_str.contains("Bad request"));
+ assert!(err_str.contains("Code: BAD_REQUEST"));
+ assert!(err_str.contains("missing field"));
}
#[test]
@@ -785,18 +779,12 @@ fn root_cause_returns_deepest_error() {
#[test]
#[cfg(feature = "std")]
fn root_cause_returns_self_when_no_source() {
+ let _guard = force_display_mode(DisplayMode::Local);
let err = AppError::timeout("operation timed out");
let root = err.root_cause();
let root_str = root.to_string();
- assert!(
- root_str.contains("timed out")
- || root_str.contains("TIMEOUT")
- || root_str.contains("Timeout")
- );
- #[cfg(feature = "colored")]
- assert!(root_str.contains("operation"));
- #[cfg(not(feature = "colored"))]
- assert!(root_str.contains("Timeout") || root_str.contains("timed out"));
+ assert!(root_str.contains("Code: TIMEOUT"));
+ assert!(root_str.contains("operation timed out"));
}
#[test]
@@ -893,18 +881,19 @@ fn downcast_mut_returns_none_for_shared_arc_attached_via_with_context() {
}
#[test]
-#[cfg(feature = "colored")]
-fn colored_display_bare_error_without_message() {
+fn local_display_bare_error_without_message() {
+ let _guard = force_display_mode(DisplayMode::Local);
let err = AppError::bare(AppErrorKind::Internal);
let output = format!("{}", err);
assert!(output.contains("Internal server error"));
assert!(output.contains("Code:"));
assert!(output.contains("INTERNAL"));
+ assert!(!output.contains("Message:"));
}
#[test]
-#[cfg(feature = "colored")]
-fn colored_display_deep_error_chain() {
+#[cfg(feature = "std")]
+fn local_display_deep_error_chain() {
use crate::field;
#[derive(Debug)]
struct CustomError {
@@ -921,6 +910,7 @@ fn colored_display_deep_error_chain() {
self.source.as_ref().map(|e| e as &(dyn StdError + 'static))
}
}
+ let _guard = force_display_mode(DisplayMode::Local);
let root = IoError::other("disk full");
let mid = CustomError {
msg: "write failed".to_string(),
diff --git a/src/lib.rs b/src/lib.rs
index c7b7f31..12f934c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -79,7 +79,8 @@
//! but not this feature — enable it explicitly to get the JSON-gated APIs
//! - `multipart` — `From` mapping to
//! `BadRequest` (implies `axum`)
-//! - `colored` — colored, multi-line `Display` output for [`struct@Error`]
+//! - `colored` — ANSI styling for the `Local` `Display` layout of
+//! [`struct@Error`] (see [`DisplayMode`])
//! - `tracing` — emit structured `tracing` events when errors are constructed
//! - `metrics` — increment an `error_total{code,category}` counter per error
//! - `backtrace` — capture backtraces (controlled by `RUST_BACKTRACE`)
diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs
index b603d72..2044a9d 100644
--- a/src/response/problem_json.rs
+++ b/src/response/problem_json.rs
@@ -8,25 +8,19 @@ use alloc::{
string::{String, ToString}
};
use core::{
- fmt::Write,
- iter::repeat_n,
mem::{replace, take},
- net::IpAddr,
- str::from_utf8
+ net::IpAddr
};
use http::StatusCode;
-use itoa::Buffer as IntegerBuffer;
-use ryu::Buffer as FloatBuffer;
use serde::Serialize;
#[cfg(feature = "serde_json")]
use serde_json::Value as JsonValue;
-use sha2::{Digest, Sha256};
use super::core::ErrorResponse;
use crate::{
AppCode, AppError, AppErrorKind, FieldRedaction, FieldValue, MessageEditPolicy, Metadata,
- app_error::duration_to_string
+ app_error::redaction::{REDACTED_PLACEHOLDER, hash_field_value, mask_last4_field_value}
};
/// Canonical mapping for a public [`AppCode`].
@@ -509,8 +503,6 @@ fn sanitize_metadata_ref(
}
}
-const REDACTED_PLACEHOLDER: &str = "[REDACTED]";
-
fn sanitize_problem_metadata_value_owned(
value: FieldValue,
redaction: FieldRedaction
@@ -545,143 +537,6 @@ fn sanitize_problem_metadata_value_ref(
}
}
-struct StackBuffer {
- buf: [u8; N],
- len: usize
-}
-
-impl StackBuffer {
- const fn new() -> Self {
- Self {
- buf: [0; N],
- len: 0
- }
- }
-
- fn as_bytes(&self) -> &[u8] {
- &self.buf[..self.len]
- }
-
- fn as_str(&self) -> Option<&str> {
- from_utf8(self.as_bytes()).ok()
- }
-}
-
-impl Write for StackBuffer {
- fn write_str(&mut self, s: &str) -> core::fmt::Result {
- let remaining = N.saturating_sub(self.len);
- if s.len() > remaining {
- return Err(core::fmt::Error);
- }
- self.buf[self.len..self.len + s.len()].copy_from_slice(s.as_bytes());
- self.len += s.len();
- Ok(())
- }
-}
-
-fn hash_field_value(value: &FieldValue) -> String {
- let mut hasher = Sha256::new();
- match value {
- FieldValue::Str(value) => hasher.update(value.as_ref().as_bytes()),
- FieldValue::I64(value) => {
- let mut buffer = IntegerBuffer::new();
- hasher.update(buffer.format(*value).as_bytes());
- }
- FieldValue::U64(value) => {
- let mut buffer = IntegerBuffer::new();
- hasher.update(buffer.format(*value).as_bytes());
- }
- FieldValue::F64(value) => hasher.update(value.to_le_bytes()),
- FieldValue::Bool(value) => {
- if *value {
- hasher.update(b"true");
- } else {
- hasher.update(b"false");
- }
- }
- FieldValue::Uuid(value) => {
- let mut repr = [0u8; 36];
- let text = value.hyphenated().encode_lower(&mut repr);
- hasher.update(text.as_bytes());
- }
- FieldValue::Duration(value) => {
- hasher.update(value.as_secs().to_le_bytes());
- hasher.update(value.subsec_nanos().to_le_bytes());
- }
- FieldValue::Ip(value) => {
- let mut buffer = StackBuffer::<46>::new();
- if write!(&mut buffer, "{value}").is_ok() {
- hasher.update(buffer.as_bytes());
- } else {
- let fallback = value.to_string();
- hasher.update(fallback.as_bytes());
- }
- }
- #[cfg(feature = "serde_json")]
- FieldValue::Json(value) => {
- if let Ok(serialized) = serde_json::to_vec(value) {
- hasher.update(&serialized);
- }
- }
- }
- let digest = hasher.finalize();
- let mut hex = String::with_capacity(digest.len() * 2);
- for byte in digest {
- let _ = write!(&mut hex, "{:02x}", byte);
- }
- hex
-}
-
-fn mask_last4_field_value(value: &FieldValue) -> Option {
- match value {
- FieldValue::Str(value) => Some(mask_last4(value.as_ref())),
- FieldValue::I64(value) => {
- let mut buffer = IntegerBuffer::new();
- Some(mask_last4(buffer.format(*value)))
- }
- FieldValue::U64(value) => {
- let mut buffer = IntegerBuffer::new();
- Some(mask_last4(buffer.format(*value)))
- }
- FieldValue::F64(value) => {
- let mut buffer = FloatBuffer::new();
- Some(mask_last4(buffer.format(*value)))
- }
- FieldValue::Uuid(value) => {
- let mut repr = [0u8; 36];
- let text = value.hyphenated().encode_lower(&mut repr);
- Some(mask_last4(text))
- }
- FieldValue::Duration(value) => Some(mask_last4(&duration_to_string(*value))),
- FieldValue::Ip(value) => {
- let mut buffer = StackBuffer::<46>::new();
- if write!(&mut buffer, "{value}").is_err() {
- return Some(mask_last4(&value.to_string()));
- }
- buffer.as_str().map(mask_last4)
- }
- #[cfg(feature = "serde_json")]
- FieldValue::Json(value) => serde_json::to_string(value)
- .ok()
- .map(|text| mask_last4(&text)),
- FieldValue::Bool(_) => None
- }
-}
-
-fn mask_last4(value: &str) -> String {
- let chars = value.chars();
- let total = chars.clone().count();
- if total == 0 {
- return String::new();
- }
- let keep = if total <= 4 { 1 } else { 4 };
- let mask_len = total.saturating_sub(keep);
- let mut masked = String::with_capacity(value.len());
- masked.extend(repeat_n('*', mask_len));
- masked.extend(chars.skip(mask_len));
- masked
-}
-
/// Canonical mapping table covering every built-in [`AppCode`].
///
/// # Examples
@@ -1037,6 +892,7 @@ mod tests {
use crate::field::json;
use crate::{
AppError,
+ app_error::redaction::mask_last4,
field::{duration, f64, ip, str, u64, uuid}
};
diff --git a/tests/display_mode_env.rs b/tests/display_mode_env.rs
new file mode 100644
index 0000000..e63bb77
--- /dev/null
+++ b/tests/display_mode_env.rs
@@ -0,0 +1,28 @@
+// SPDX-FileCopyrightText: 2026 RAprogramm
+//
+// SPDX-License-Identifier: MIT
+
+//! Integration test exercising real `MASTERROR_ENV` detection.
+//!
+//! Kept as a single test in its own binary so the environment variable is
+//! set before the first `DisplayMode::current()` call in this process and
+//! the cached detection cannot be influenced by other tests.
+
+use masterror::{AppError, DisplayMode};
+
+#[test]
+fn masterror_env_prod_selects_json_display_layout() {
+ unsafe {
+ std::env::set_var("MASTERROR_ENV", "prod");
+ }
+ assert_eq!(DisplayMode::current(), DisplayMode::Prod);
+ let err = AppError::not_found("missing user");
+ let rendered = err.to_string();
+ assert!(
+ rendered.starts_with(r#"{"kind":"NotFound""#),
+ "unexpected layout: {rendered}"
+ );
+ assert!(rendered.contains(r#""code":"NOT_FOUND""#));
+ assert!(rendered.contains(r#""message":"missing user""#));
+ assert!(!rendered.contains('\u{1b}'));
+}